
Build, deploy, run and debug Flutter applications on embedded Linux targets from VS Code.
It turns a board reachable over SSH — a Raspberry Pi, a Yocto or Buildroot image, an
arm64, armv7 or riscv64 industrial panel — into a first-class Flutter device: cross-compile
the bundle on your machine, copy it over scp or rsync, launch it on the device, and get
hot reload, the debugger, DevTools and the profiler pointed at it.
Install: search for Flutter Embedded Linux Toolkit in the Extensions view, or run
code --install-extension buzzcola3.flutter-embedded-linux-toolkit.
Features
- Targets view – add, edit and remove devices from the sidebar; the active target is highlighted.
- Liveness – every target is probed on a timer and shows up as online, offline or checking.
- Architecture detection – adding a target asks the device what it is instead of asking you.
- Flutter devices – targets are published as Flutter custom devices, so they appear in
flutter devices, the Flutter device picker and flutter run -d <id>.
- Build – cross-compiles a bundle for the target, with
flutter build linux or a
project-supplied build command, and verifies the result is really the target's architecture.
- Deploy – copies the bundle to the device over
scp or rsync.
- Run – deploys and launches the bundle on the device over SSH, in debug, profile or
release; in profile it forwards the VM service and attaches the debugger, so DevTools and
the profiler work.
- Dependency verification – reads every shared library the built bundle asks for, asks the
device what it already has, and shows the two as a matrix, grouped per plugin and for the
embedder and engine separately.
- Build mode – picked per run from the status bar, not just from a setting.
- Output log – every command and its process output is written to the Flutter Embedded Linux
log channel, one line per step with how long it took, so a slow run says which step is slow.
Build modes, and which one runs where
| Mode |
Started by |
Runner |
What you get |
debug |
Start Debugging (F5) |
Flutter's own |
hot reload, full debugger |
profile |
Run App in Profile Mode |
this extension |
VM service forwarded, debugger attached, DevTools |
release |
Run Without Debugging (Ctrl+F5), Run App in Release Mode |
this extension |
no VM service, so no debugger |
Run Without Debugging means release here. On a desktop it only decides whether a debugger is
attached, but running on the board without a debugger is what shipping to it looks like, and a JIT
bundle on the debug engine is not what that asks for. An explicit "flutterMode" in the launch
configuration always wins over this.
Run on Target covers all three from the status bar: pick the mode there, press the ▶ in the
Targets view.
The difference is what happens underneath. Flutter's custom devices are debug-only, and not by
configuration: CustomDevice.supportsRuntimeMode returns buildMode == BuildMode.debug, so
flutter run --profile -d <device> exits with "Profile mode is not supported for …" before it
builds anything. A profile or release launch is therefore cancelled before Flutter sees it and
handed to the same path Run on Target uses: build, deploy, provision the matching runtime,
start the app over SSH. In profile it then reads the VM service URI from the app's output,
forwards that port over SSH and attaches the Dart debugger to it, which is what keeps DevTools
and the profiler available — including the performance page the profile button asks for.
Each mode keeps its own engine, plugin build and stamp on the device under
flutterEmbeddedLinux.flutterDevices.runtimePath, and the names the embedder loads are symlinks
to whichever mode is active. Switching between debug and profile costs a symlink rather than
another transfer. Profile deliberately pairs the release embedder with the profile engine:
the engine is built four ways, the embedder only two.
Building and cross-compiling
Build Bundle produces the directory that gets copied to the device. Two strategies, chosen by
flutterEmbeddedLinux.build.tool:
flutter runs flutter build linux --<mode> --target-platform=linux-<arch>. Only usable
when the target is the host's own architecture: the tool exits with "Cross-build from Linux
x64 host to Linux arm64 target is not currently supported" before it starts, and no
toolchain or sysroot changes that.
bundle is the built-in cross bundler, described below.
command runs flutterEmbeddedLinux.build.command from the project root through a
shell. This is the realistic path for embedded builds: a cross-compiling container, a
different embedder (flutter-pi, flutter-drm), or an extra bundling step after
flutter build. flutterEmbeddedLinux.build.bundlePath says where it leaves the bundle.
The built-in cross bundler
A bundle is built in three parts, none of which needs a C++ cross compiler:
| Part |
Contents |
How it is produced |
| Assets |
AssetManifest.bin, assets/, fonts/, … |
flutter build bundle, which is architecture independent |
| Code |
app.so (release/profile) or kernel_blob.bin (debug) |
flutter assemble kernel_snapshot_program compiles Dart to kernel, then the cross gen_snapshot emits a target app.so. Debug is JIT and ships the kernel as-is |
| Engine |
libflutter_engine.so, icudtl.dat, flutter-drm-embedder |
downloaded prebuilt and copied in |
| Plugins |
plugins/lib<name>_plugin.so |
located, never compiled — see below |
flutter build linux --target-platform=linux-arm64 is deliberately not used. It refuses to
run — "Cross-build from Linux x64 host to Linux arm64 target is not currently supported" — and
it builds a GTK application that flutter-drm has no use for. The AOT step therefore uses the
gen_snapshot published alongside the engine, which runs on the host and emits target code.
The
native half is fetched, not built:
| Artifact |
Source |
Pinned to |
libflutter_engine.so |
ardera/flutter-ci, tag engine/<revision> |
the SDK's own bin/internal/engine.version |
icudtl.dat |
same release, universal.tar.xz |
same |
flutter-drm-embedder |
buzzcola3/flutter-drm-embedder releases |
flutterEmbeddedLinux.build.embedderVersion (default latest) |
gen_snapshot (release/profile) |
ardera/flutter-ci, same engine release |
the SDK's engine revision |
Archives are unpacked into the Flutter artifact cache with a stamp recording which release they
came from, so the next build of the same target and mode needs no network. A directory left by
another tool has no stamp, so it is refetched rather than trusted — a stale engine from an
earlier SDK would otherwise be silently wrong. Downloads are anonymous; set
flutterEmbeddedLinux.build.githubToken only if GitHub's rate limit becomes a problem.
Engines are generic builds shared by every embedder in this family, so there is no flutter-drm
versus flutter-pi distinction at the engine level — the embedder binary is the flutter-drm
specific part. Engines are published per mode (debug, profile, release, debug_unopt),
while the embedder ships in two variants only, so a profile build pairs the profile engine with
the release embedder. The cross gen_snapshot comes from the same release as the engine; Flutter's own
copy cannot cross-compile to arm64 and is never downloaded for it.
flutterEmbeddedLinux.build.enginePack still overrides all of this with a directory you
prepared yourself.
Plugins
Native plugins are C++. They can either be collected from an existing build, or cross-compiled
here by setting flutterEmbeddedLinux.build.plugins.compile.
Compiling does not go through flutter build linux, which refuses to cross-compile to
arm64 from an x64 host. Nor does it go through the app's own linux/CMakeLists.txt, which
declares add_dependencies(flutter flutter_assemble) and so re-runs Flutter's assemble step.
Instead a small CMake project is generated that pulls in each plugin's linux/ directory
directly, with a toolchain file naming the cross compilers and sysroot.
In that project flutter is an INTERFACE library carrying only include directories — there
is deliberately nothing to link. The flutter-drm embedder compiles the fl_* plugin API into
its own executable and exports it (98 dynamic symbols), so a plugin resolves those at load time.
Linking a shim instead records a DT_NEEDED for libflutter_linux_gtk.so, and the plugin then
fails to load on a device that has no such library. The headers themselves are architecture
independent and come from the SDK's linux-x64 artifacts.
Sysroots
Run Flutter Embedded Linux: Create Sysroot from Device (palette, or right-click a target).
It copies the device's own headers and libraries — /usr/include, /usr/lib/<triple>,
/lib/<triple> and the pkg-config directories — into the extension's storage with rsync, then
offers to use it for plugin builds.
Taking the sysroot from the device rather than from distribution packages means there is nothing
to guess: no distribution, no release, no package set. A plugin is compiled against exactly the
glibc, GTK and GStreamer the device runs. The transfer uses rsync --relative, so
/usr/lib/<triple> lands at usr/lib/<triple> rather than flattened, and every absolute
symlink is rewritten to a relative one afterwards — a rootfs is full of links like
/usr/lib/<triple>/libfoo.so → /lib/<triple>/libfoo.so.1, which on the host would resolve to the
host's library of the wrong architecture.
The device needs its development packages installed (libgtk-3-dev and friends); a stripped
image has the libraries but not the headers, and the sysroot is rejected with that explanation
rather than failing later in CMake.
A sysroot is not optional for anything non-trivial: plugins routinely pull in GTK, epoxy and
GStreamer, whose headers and libraries must exist for the target architecture. The
generated toolchain sets CMAKE_FIND_ROOT_PATH_MODE_* and the pkg-config sysroot variables so
CMake cannot quietly satisfy a target dependency with a host library — the usual cause of a
cross build that links but will not run. Where the host has no such sysroot,
flutterEmbeddedLinux.build.plugins.containerImage runs the same CMake commands inside an image
that does.
Collected libraries come from build/linux/<arch>/<mode> (or
flutterEmbeddedLinux.build.pluginsPath) into the bundle's plugins/, which the embedder adds
to its library search path.
Which plugins to look for comes from .flutter-plugins-dependencies, filtered to the ones it
marks native_build — a federated package like path_provider_linux is listed as a Linux
plugin but implements itself in Dart, with no linux/ directory to build and nothing to
bundle. Each library is then chosen by reading its ELF header, because a host build of the same
plugin normally sits right beside the target one under build/linux/. Finding only a host
build is an error rather than a warning: shipping it produces a bundle that fails on the device
with an unrelated-looking loader message. A library picked up from another mode's output says so.
auto (the default) uses the command when one is configured, then flutter when the target is
the host's own architecture and bundle for anything else — which is every real device. The command
and bundle path both expand ${arch}, ${mode}, ${targetPlatform} and ${projectRoot}, and
the first three are also exported as FELT_ARCH, FELT_MODE and FELT_TARGET_PLATFORM.
{
"flutterEmbeddedLinux.build.command": "./scripts/build_flutter_drm_release_arm64.sh",
"flutterEmbeddedLinux.build.bundlePath": "dist/flutter-drm-${arch}"
}
Every build is checked before deployment. The bundle's ELF headers are read and compared
against the target's architecture, because a misconfigured cross build does not fail — it
quietly emits host binaries that only break once they are on the device. A bundle full of x64
objects headed for an arm64 target is reported as a build error instead.
Authentication
Targets authenticate with an SSH key by default. The add and edit wizards also ask for a
password; leaving the box empty keeps key authentication (and, when editing, keeps whatever
password is already stored).
Passwords are stored in plain text, one file per target under the extension's global
storage directory, written 0600. This is a development tool aimed at development boards, and
a keyring adds a moving part for a password that is usually raspberry — so it does not use
one. Assume anything with access to your user account can read these files.
Two things are still true, and are the reason the files live where they do:
- They are never written to
settings.json. Target definitions are workspace settings and
get committed and pushed; only the usesPassword flag goes there. The passwords sit outside
the project, so they cannot travel with it.
ssh is handed the password through sshpass -e, an environment variable, so it never shows
up in the process list. Password authentication therefore needs sshpass on the host
(apt install sshpass); a clear error says so if it is missing. Flutter's custom device
commands run with their own environment and read the same file directly.
The file is removed when the target is deleted. If storing a password in the clear is not
acceptable for your setup, use an SSH key instead: ssh-copy-id user@device once is enough,
and everything here works with no stored password at all.
Architecture detection
The add and edit wizards read the architecture off the device over SSH rather than asking,
falling back to a picker only when the device cannot be reached. The probe reads the ELF
header of /bin/sh (od -An -tx1 -N20, which busybox provides on Buildroot), and uses
uname -m only as a fallback: uname reports the kernel, so a 64-bit kernel with a
32-bit userspace — Raspberry Pi OS 32-bit, many Buildroot configs — claims aarch64 while
only armv7 binaries can execute there. The ELF header is the userspace ABI that a deployed
bundle actually has to match.
Running on a device
flutter run and the editor's Run button work through Flutter's own device pipeline. Flutter
supports debug mode only for custom devices (supportsRuntimeMode accepts nothing else),
which is a good fit: a debug build is JIT, so the kernel it ships is architecture independent
and nothing is cross-compiled. Hot reload, hot restart and breakpoints all work.
What Flutter never installs is the runtime, since the embedder and engine are not part of an app
bundle. Run Flutter Embedded Linux: Provision Device Runtime once per device: it downloads
the debug engine (the release engine cannot execute kernel), the embedder and icudtl.dat,
and copies them to flutterEmbeddedLinux.flutterDevices.runtimePath (/opt/flutter-drm).
That directory is deliberately outside the app directory, which Flutter deletes and recreates on
every run.
The generated runDebug then links icudtl.dat into the freshly installed bundle — the embedder
reads it from there — sets LD_LIBRARY_PATH to the runtime and the bundle's plugins/, and
execs the embedder against the bundle directory. Profile and release additionally pass the
matching --profile or --release flag: the embedder otherwise runs the app as debug and looks
for a kernel_blob.bin that an AOT bundle does not contain.
So the whole loop is: add a target, provision it once, pick it in the device picker, press Run.
Profile and release are not part of that loop, because Flutter's custom devices refuse those
modes. They take the extension's own path instead — Run on Target, or a profile/release
launch from the Run and Debug menu, which is intercepted and sent the same way — building the
bundle here and launching it on the target directly, without the Flutter tool in the middle.
Flutter devices
Every target with an arm64 or x64 architecture is mirrored into Flutter's custom device
list under the id felt-<target>, using the flutter custom-devices CLI.
Note the mismatch in scope: flutterEmbeddedLinux.targets is a workspace setting (and VS Code
replaces array settings rather than merging them, so a workspace list hides the user-level
one), while Flutter's device list is per-user and shared by every project. Each workspace
therefore only ever removes the devices it registered itself; devices belonging to another
workspace are left alone, so opening a second project cannot delete the first one's devices. Flutter's custom
devices feature is enabled automatically on first sync, and entries are updated or removed as
targets change. Set flutterEmbeddedLinux.flutterDevices.autoSync to false to manage the
list by hand, or run Flutter Embedded Linux: Register Targets as Flutter Devices to force
a sync.
The flutter binary is resolved from flutterEmbeddedLinux.flutterSdkPath, then the Dart
extension's dart.flutterSdkPath, then FLUTTER_ROOT, then PATH — so an SDK that is only
known to the Dart extension still works.
Targets are listed by Flutter whether or not the device is switched on: their ping command is
a host-side no-op, since Flutter drops custom devices whose ping fails. Actual reachability is
reported by the Targets view instead. The trade-off is that flutter run against a device
that is off fails when it tries to copy the bundle, rather than by not offering the device.
For flutter run -d felt-<target> to work end to end:
- SSH must be non-interactive — the generated commands use
BatchMode=yes, so the key
has to be loaded and the host key already trusted (ssh user@host once by hand).
- The launched process must print the Dart VM service URL on stdout. The default command runs
the deployed bundle's own executable; override it with
runCommand for flutter-pi and
friends.
- 32-bit
arm targets are not exposed: Flutter builds only linux-x64 and linux-arm64.
- New devices reach the Flutter extension's picker when its daemon next enumerates devices —
reload the window if the picker looks stale.
Dependency verification
A bundle that builds, transfers and installs cleanly can still fail the moment it starts,
because a shared library it was linked against is not on the device. The loader's message
names an .so file and nothing else — not which plugin asked for it, and not whether the
device has it in some form. Verify Dependencies, on a target's context menu and in the
Dependencies view, answers that.
It reads the DT_NEEDED entries of everything in the built bundle — the embedder, the engine,
app.so and each plugin library — directly out of the ELF headers. No readelf is involved:
cross binutils for the target are usually not installed on a machine that cross-builds for it,
so the dynamic section is parsed in the extension. The device is then asked once, over a single
SSH round trip, what it has: ldconfig -p when there is a cache, and a listing of the library
directories when there is not, which is the case on most Buildroot and musl images. Which of
the two answered is reported, because a miss from a filesystem listing is a doubt rather than
a fact.
Requirements are grouped by where they come from, one group per plugin, and the split is the
useful part — a missing library is attributed to the plugin that wants it rather than to the
bundle as a whole:
| Group |
What it is |
When it changes |
| Runtime |
The embedder and engine — the DRM, GBM, EGL, input and libc stack that has to be there before anything starts at all |
With the device image |
| Application |
app.so, the AOT snapshot, which normally needs nothing |
Never, in practice |
| Plugin (one per plugin) |
Whatever that plugin links: GStreamer, GTK, FFmpeg, OpenSSL |
With the pubspec |
| Device |
Facts no ELF header reveals: /dev/dri/card*, input nodes, whether the SSH user can open the DRM device, and whether the C library is glibc at all |
With the device image |
Grouped this way, a failure reads as "this image is missing the DRM stack" or "this one
plugin needs GStreamer" — different problems with different fixes — rather than as a flat list
of six missing files.
Each row resolves to one of:
- satisfied – the device has it, for the target's own architecture.
- shipped in the bundle – supplied by the bundle itself, such as
libflutter_engine.so.
- missing – not on the device. On a multiarch device a copy built for another
architecture is reported here too, naming the architecture that is installed.
- too old – the file is there, and it is still not enough. Symbol versions are checked
as well as names, so a
libc.so.6 that lacks the GLIBC_2.34 a plugin was linked against
is reported here rather than passing as satisfied. This is the failure that otherwise reads
as "but I installed that already".
- unknown – it could not be established either way, and the row says which doubt applies.
The check also runs by itself before a launch, warning without ever blocking it — the check is
an inference from library names, and an unusual image can satisfy a binary in ways the device's
own library list does not show. Set flutterEmbeddedLinux.dependencies.checkBeforeRun to
false to turn that half off; the command stays available either way.
Configuration
{
"flutterEmbeddedLinux.flutterSdkPath": "/opt/flutter",
"flutterEmbeddedLinux.defaultBuildMode": "debug",
"flutterEmbeddedLinux.deploy.method": "rsync",
"flutterEmbeddedLinux.dependencies.checkBeforeRun": true,
"flutterEmbeddedLinux.flutterDevices.autoSync": true,
"flutterEmbeddedLinux.monitor.intervalSeconds": 15,
"flutterEmbeddedLinux.monitor.timeoutSeconds": 3,
"flutterEmbeddedLinux.targets": [
{
"name": "RPi 4",
"host": "192.168.1.50",
"user": "pi",
"arch": "arm64",
"deployPath": "/opt/flutter-app",
"identityFile": "~/.ssh/id_rpi",
"runCommand": "/opt/flutter-app/my_app"
}
]
}
Project layout
src/
extension.ts activation entry point, wires everything together
constants.ts command ids, view ids, config keys
build/ bundling, engine packs, plugin compilation, ELF inspection
commands/ command implementations and registration
config/ typed, validated access to workspace settings
core/ container holding shared long-lived objects
models/ domain types
services/ flutter CLI, deployment, target registry, dependency checks
ui/ tree views and status bar
test/ integration tests (@vscode/test-cli)
resources/icons/ contributed icons
Development
| Command |
Description |
npm run watch |
Incremental type check + esbuild bundle |
npm run compile |
Type check, lint and bundle once |
npm run package |
Production bundle (used by vsce) |
npm test |
Run the integration tests |
Press F5 to launch an Extension Development Host.