A component of Ferrix, checked out at
tools/vendor/google/pixel7(itscomponents.toml); build and test it from there withcargo xtask. Ferrix's conventions apply, including one author per commit.
What runs Ferrix on the Pixel 7 and watches it, from the phone and from the PC. None of it is part of Ferrix's own image. Each part builds apart from the Cargo workspace and the gates.
| Where | What |
|---|---|
android/ |
The Android app, "Boot Ferrix" (dev.ferrix.launcher): Kotlin and Compose, a Gradle project of its own |
helper.py |
The helper on the PC that the app's Boot button and the monitor ask to boot the phone |
build-desktop.sh |
Builds the VM's desktop, desktop.Image, and with --push puts it on the phone |
package-release.py |
Packs a desktop for a GitHub release, where the app's updates find it |
selfhost.sh |
Ferrix building Ferrix on the phone: stage 20's self-hosting test on Arm hardware (below, "Building Ferrix on the phone") |
monitor/ |
The desktop monitor, "Pixel 7 · Ferrix": Tauri 2, a Cargo workspace of its own (monitor/README.md) |
The loader these boot, and the phone's state, are in src/boot/vendor/google/pixel7
(HANDOVER.md there).
An Android app with two ways to run Ferrix on the Pixel 7, and the helper on the PC that the first one needs:
- Boot Ferrix reboots the phone into Ferrix, through the PC.
- Run in a VM runs Ferrix beside Android, as a guest of the phone's own
KVM through Android's crosvm. No PC and no reboot are needed, and it takes
about 6 seconds to
FERRIX-BOOT-OK.
The phone cannot start Ferrix by itself. Its kernel has no kexec, and
nothing may be flashed (src/boot/vendor/google/pixel7/HANDOVER.md, "Never write anything that survives
a reset"). What starts Ferrix is fastboot boot from a PC, so the button asks
the PC. The phone has to be plugged into it, and nothing on the phone is
changed: Ferrix runs from RAM, and its watchdog brings Android back about 75
seconds later.
phone: Boot Ferrix ──HTTP to 127.0.0.1:47707──▶ adb reverse ──USB──▶ helper.py on the PC
helper: adb reboot bootloader → fastboot stage vendor_boot.img → fastboot boot boot.img
→ wait for Android → save the ramoops record → report FERRIX-BOOT-OK or the panic
python3 tools/vendor/google/pixel7/helper.py # the newest $P/*/boot.img
python3 tools/vendor/google/pixel7/helper.py --image PATH # a given oneP is ~/.local/share/ferrix/pixel7, where vendor_boot.img and the run
directories are. The helper listens on 127.0.0.1 only and keeps the phone's
adb reverse pointing at it; a reboot drops it, and it is put back. Each run's
record lands in $P/launcher-<time>/run.log. The endpoints are GET /status
and POST /boot. Build an image first with $P/build-run.sh, as the
handover's "Running the phone with nobody there" says.
Kotlin and Jetpack Compose, Material 3 with the wallpaper's dynamic colours.
The Gradle project here builds offline from the cache PhoneLink's build left,
with the SDK in ~/Android/Sdk and a JDK that has javac:
cd tools/vendor/google/pixel7/android
JAVA_HOME=~/Android/jdk/jdk-21.0.12.1+1 ANDROID_HOME=~/Android/Sdk \
~/.gradle/wrapper/dists/gradle-9.8.0-bin/*/gradle-9.8.0/bin/gradle \
--offline -Dorg.gradle.java.installations.paths=$HOME/Android/jdk/jdk-21.0.12.1+1 \
assembleDebug
adb -s <serial> install -r app/build/outputs/apk/debug/app-debug.apkThe system JDK on example is a runtime with no javac, which Gradle's Java
compile step needs even in a Kotlin-only app, so JAVA_HOME has to name the
SDK's JDK. The app is dev.ferrix.launcher, labelled "Boot Ferrix".
Any app on the phone with the internet permission could reach the helper's port while the cable is in, and all it could do is what the button does.
The app runs, through su (Magisk asks once), after starting the screen's
bridge (below):
cd /data/local/tmp/ferrix-vm && /apex/com.android.virt/bin/crosvm run \
--disable-sandbox -m 4096 --cpus 8 -s crosvm.sock --serial type=stdout,num=1,stdin \
--gpu 'backend=2d,displays=[[mode=windowed[1080,2400]]]' --android-display-service cid:70000 \
--input 'single-touch[path=touch.sock,width=1080,height=2400]' \
--input 'keyboard[path=keyboard.sock]' --input 'mouse[path=mouse.sock]' ferrix.ImageWith desktop.Image beside it, the app boots that instead, with
-p ferrix.checks=skip; with chromium.img too
(tools/common/fetch/fetch-chromium-arm64.sh, pushed by
build-desktop.sh --chrome --push), it adds three blank 1 MiB disks and then
the volume, so that the volume is vdd, the first disk Ferrix mounts at
/data. home.img comes after it when it is there (below, "Your own
desktop"), and Ferrix mounts it at /home.
The 16550 takes crosvm's standard input too, the su process's, which the app keeps open for the run: a line it writes there reaches the guest's shell on its console.
ferrix.Image is the raw loader Image from the helper's run directory, the
same loader and kernel fastboot boot gets, which the helper pushes whenever
the phone's copy differs. It needs to have been built with guest support
(9df3769b or later). The loader finds it is a guest, entered at EL1 with
crosvm's 16550 as stdout-path, and sends its log and the kernel's there.
crosvm's machine has 2 to 8 vCPUs, GICv3, PSCI through hvc, and RAM at
0x8000_0000, a virtio-gpu, and virtio-input single-touch, keyboard and
mouse devices, which Ferrix drives since its kernel reads crosvm's pci-host-cam-generic host
and gives each function the INTx line its interrupt-map names. The guest
powers off at the end of its run, and crosvm exits.
The desktop. When desktop.Image is beside ferrix.Image, the app boots
it instead: the loader wrapping a kernel and initramfs whose init is the
compositor. tools/vendor/google/pixel7/build-desktop.sh <dir> [scale] [--push] builds it
(cargo xtask flash --compositor --size 1080x2400 --scale 2 --layout us --wallpaper none, then the loader) and with --push copies it to the phone.
The scale is 2 unless given, since 1080x2400 at 1 is text a few millimetres
high, and the keymap US, which the app's key codes are. Delete
desktop.Image to go back to the console image.
Your own desktop. build-desktop.sh <dir> 2 --chrome --dotfiles --push
builds the desktop run-compositor --everything boots on the PC: this
machine's ~/.config/hypr/hyprland.conf and the dotfiles beside it, the
session running as the user ferrix (flash --compositor --session), with
the phone's screen line put after the configuration's own, and waybar one
and a half times the size the PC's files give it on top of the screen's
scale (--bar-zoom=N for another size), without the modules a phone has no
room for (--bar-drop=LIST; the window title, audio, cpu, memory, network
and tray unless given) and 150 px short of the right edge, where the app's
keyboard and menu buttons float (--bar-margin-right=N). With --push it
also makes home.img on the phone, an empty 8 GiB sparse ferrix-home
volume, when there is none. The first boot seeds it from the dotfiles, and
what is changed there afterwards is kept: a new desktop.Image never
replaces it, and neither does a later --push. --reset-home makes it
again, empty, so that the next boot seeds it anew. The desktop commits
/home and /data every 30 seconds, since the VM has no root disk whose
committer would: what was changed in the last half-minute before "Stop" is
lost.
Updates. The app keeps desktop.Image on the newest GitHub release
that carries it, with no PC (below, "Updates").
Starting a VM opens it full screen: one bar, with the run's state and a menu
to pause or resume it, stop it, run it again, or go back, and the console
under it. The console follows the newest line while the reader is within ten
lines of the end. Pause and stop go to crosvm's control socket
(crosvm suspend|resume|stop /data/local/tmp/ferrix-vm/crosvm.sock). A
paused guest's counter keeps running, so pausing during the boot's
self-checks can fail a timing check, as it did once in stage 3 (FX-0302).
The guest's display is the phone's, upright: its full size in pixels (1080×2400 on this Pixel 7), measured when the VM starts. Once it is there, the VM opens on it, with the menu floating in a corner, and the menu switches between the screen and the console. How it gets there, all of it tried on this phone (Android 17 CP3A.260905.009, Magisk):
- With
--gpu … --android-display-service cid:70000, crosvm makes a binder,android.crosvm.ICrosvmAndroidDisplayService, and gives it toandroid.system.virtualizationservice(setDisplayService) under that cid, and virtualizationservice hands it on to root only. Before CP3A.260905.009 the name was free and the service kept one binder for everyone; now crosvm refuses a name withoutcid:. 70000 is above the cids virtualizationservice gives its own VMs. - So a root process fetches it: the app's own
Bridgeclass, run by the su script asCLASSPATH=<this APK> app_process /system/bin dev.ferrix.launcher.Bridge …, which callsIVirtualizationServiceInternal.waitDisplayService(70000)by its transaction code, 17, the one the Terminal app's own inlined call uses (the Terminal app's APK no longer carries callable stubs). The service keeps the last binder it was given even after that crosvm died, so the bridge takes only one that answers a ping and is not the one held before this run's crosvm started. - The bridge hands it to the app through the app's exported provider
(
GuestLink, which takes calls from root only and for the current run's token), by the activity manager'sgetContentProviderExternaland the provider'scall. - The app calls the binder itself, which works from an untrusted app:
setSurface(transaction 1) with its SurfaceView's surface when it has one,saveFrameForSurface(4) andremoveSurface(3) when it goes, anddrawSavedFrameForSurface(5) when it is back. Each takesforCursorlast. - The guest's compositor puts its pointer on the card's cursor plane, which
crosvm draws into a second surface, as AOSP's Terminal app has it: a
64×64 RGBA SurfaceView over the screen, lent with
setSurface(…, forCursor = true).setCursorStream(2) takes one end of a socket pair, on which crosvm writes the cursor's position as(x: i32, y: i32)little-endian; the app reads the other end and moves the cursor's surface, made a child of the screen'sSurfaceControl, there. - crosvm's
--input <type>[path=…]connects to a Unix socket at that path as it starts, and will not start if nothing listens there ("failed to open event device"). The bridge listens ontouch.sock,keyboard.sockandmouse.sockfirst, each made under another name and renamed into place once it listens, and the script starts crosvm once all three are there. If they are not there within ten seconds, the guest runs with its console only. - The bridge also hands the app a binder of its own, which takes batches of
virtio_input_events (8 bytes, little-endian:
u16 type, u16 code, u32 value), a transaction for each device (touch, keyboard, mouse, fromFIRST_CALL_TRANSACTION), and writes them to crosvm's connection for it.
The bridge ends when crosvm does, by its process or a connection, so
stopping the VM stops it too. It prints its steps as FERRIX-VM-BRIDGE lines
among the console's.
A finger on the screen is what the menu says, touch or trackpad, as Microsoft's Remote Desktop app has them; the app remembers the choice.
- Touch. The pointer goes where the finger lands (
ABS_X/ABS_Y, no press); moving pressesBTN_TOUCHat the landing point and drags, lifting unmoved is a tap. Held still (Android's long-press time) is the mouse'sBTN_RIGHT. - Trackpad. A finger moves the pointer by how far it goes (
REL_X/REL_Y, 1.2 guest pixels a pixel, up to three times that for a quick finger); a tap isBTN_LEFT; held still first, it drags withBTN_LEFTdown. - Both. Two fingers moving are the wheel (
REL_WHEEL, a click each 12 dp, content following the fingers); two fingers tapped areBTN_RIGHT.
The keyboard button beside the menu opens the soft keyboard, the view being
an editor for a visible password (no suggestions, no composing), and a row
over it: Esc, Tab, Ctrl, Alt, Super, the arrows, |, / and -. Ctrl, Alt
and Super are sticky: tapped, they hold the next key; tapped twice, until
tapped again. Text becomes key presses for a US keymap, the guest's, with
Shift where a US keyboard needs it; a character it has no key for is dropped.
A hardware keyboard's keys go through as they are pressed and released, all
but Back, the volume and the media keys.
The soft keyboard covers the bottom of the screen, which stays where it is.
The app tells the guest how much is covered, on its console, once the
compositor has printed hyprix: card0 <monitor> and two seconds have passed,
and each time the height has held for 150 ms and changed:
hyprctl keyword monitor Virtual-1,addreserved,0,<H>,0,0 >/dev/null 2>&1H is the keyboard's and the row's height in the compositor's logical
pixels, the phone's divided by the monitor's scale, rounded up; the scale is
the mode's width over the logical width in hyprix: 1 monitor [card0 Virtual-1 1080x2400 540x1200], and 1 without it. Closing the keyboard sends
0.
Each tagged release carries the desktop in two editions, as a distribution has them, and the app keeps the phone on the newest one. The card "Or run it in a VM" says which edition the phone keeps and how its updates stand.
| Edition | What the release has for it | Download |
|---|---|---|
| Full | ferrix-pixel7-full.Image.gz, the desktop with Chromium (build-desktop.sh --chrome), and ferrix-pixel7-chromium.img.gz, Debian's arm64 Chromium volume it runs from |
~17 MB, and ~200 MB for the volume |
| Minimal | ferrix-pixel7-minimal.Image.gz, the desktop alone |
~13 MB |
.github/workflows/release.yml's pixel7 job builds both after the
release is published, as build-desktop.sh does by hand, with ferrousli's
busybox and the AArch64 ports in them, and package-release.py packs them.
Beside each edition goes ferrix-pixel7-<edition>.json, which names each
asset with the SHA-256 and size of what it unpacks to. A tag with no phone
build, or one whose job failed, is passed over for the next older one.
The app asks GitHub (api.github.com/repos/ferrix-os/ferrix/releases, no
token) when it starts and hourly while it is in front, and at "Check now".
It reads the VM's directory with su, as it runs the VM, and then:
- Newer desktop, unmetered network: fetched by itself.
- Metered network: it waits, with the size, for "Download now".
- A build from the PC (
build-desktop.sh --push, which removes the app's record): left alone, with "Use " to replace it. - Chromium's volume (Full): fetched when missing, or when the release's
pins (the SHA-256 of
tools/common/fetch/fetch-chromium-arm64.sh, since mkfs.btrfs never makes the same image twice) differ from the ones the app installed. A volume the app did not install is the PC's and is kept, unless "Use " is tapped. Replacing it starts Chromium's profile anew. - Switching edition: checked at once, under the same rules. Minimal
leaves
chromium.imgwhere it is.
Each file is unpacked as it downloads, into su cat > a .<name>.part
beside the VM's files, and hashed on the way. It is renamed into place only
when its hash and size are the manifest's. A running VM keeps the file it
opened, so an update takes effect at the next run. The app records what it
installed in /data/local/tmp/ferrix-vm/release.json, which is how it
tells its own desktop from the PC's.
A debug build can be pointed at another list in GitHub's shape, to try a
release's assets before a tag publishes them. package-release.py --serve
lists a directory it packed as one release; forward it and give the app the
URL:
tools/vendor/google/pixel7/package-release.py --serve <out-dir> # port 47708
adb reverse tcp:47708 tcp:47708
adb shell am force-stop dev.ferrix.launcher
adb shell am start -n dev.ferrix.launcher/.MainActivity --es releases http://127.0.0.1:47708/releasesThe app reads the extra as it starts, hence the force-stop, and keeps it
until it is given an empty one (--es releases ''), which puts it back on
GitHub. Then "Check now".
Stage 20's self-hosting test on Arm hardware: Ferrix compiles Ferrix inside
the phone's VM, and the image it makes is booted and judged on the PC. It
first passed on 2026-10-03 (docs/roadmap/stage-20-self-hosting.md).
FERRIX_SYSROOT_ARCH=arm64 tools/common/fetch/fetch-rustc-sysroot.sh # once
tools/vendor/google/pixel7/selfhost.sh stage # toolchain, tree, vendored crates, init
tools/vendor/google/pixel7/selfhost.sh push # to /data/local/tmp/ferrix-vm/selfhost.img
tools/vendor/google/pixel7/selfhost.sh run # boots; status shows the serial log
tools/vendor/google/pixel7/selfhost.sh pull # after SELFHOST-STATUS and the power-off
cargo xtask test-selfhost --arch aarch64 --volume ~/.local/share/ferrix/selfhost-arm64/selfhost.imgNo image is made for the test. run stops any VM the app started and boots
the phone's own desktop.Image with three blank disks and the volume, so the
volume is vdd and Ferrix mounts it at /data, and with
-p "ferrix.checks=skip ferrix.init=/data/selfhost/init", which the loader
passes to the kernel. pid 1 is then a script on the volume that links the
paths gcc names into /data and runs cargo xtask build --arch aarch64;
the Chromium desktop's own links already put glibc's loader and libraries
there. No screen or input is attached. The guest has 5 GiB and four
processors and builds at two jobs (FERRIX_SELFHOST_MEMORY, _CPUS,
_JOBS): Ferrix keeps a btrfs file's pages in memory, and the phone has
7.6 GiB.
The USB link to the phone drops now and then, more often under long
transfers: a single adb pull of the volume never got past 30%. The script
moves the volume in checksummed gzip pieces, sends a piece again when it does
not arrive whole, and runs the phone's long steps detached, so a drop cannot
end them; a command it runs answers with its own status, not adb's.