A small installer for Rockchip RK3576 boards (the Flipper One and other RK3576 boards used for testing), designed to run from a Linux initramfs for on-device installs.
It can:
- Discover the running board from the device tree (
/proc/device-tree). - Enumerate local storage the RK3576 boot ROM can boot from (UFS, eMMC, SD) via sysfs.
- Check a UFS target's logical units against the Flipper provisioning scheme and offer to reprovision it — writing the Configuration Descriptor over the kernel's UFS BSG endpoint.
- Browse update bundles published per channel (
release,testing,nightly, and per-developerdev/<user>/<branch>builds), and verify each artifact against the SHA-256 digests in the bundle's manifest. - Query the image server for available U-Boot images and exported profile snapshots for the board (the custom development build flow).
- Mount removable storage (SD / USB) read-only and search it for update bundles, offline U-Boot images and profile snapshots.
- Present a TUI (Cursive + Crossterm) for serial-console operation.
- Present a GUI (Slint + LinuxKMS) on the Flipper One 256×144 DRM screen, driven by the on-device buttons (a Linux input event device).
- Run the installation:
blkdiscard, write a fresh GPT,mkfs.btrfswith a subvolume skeleton,btrfs receivethe selected profile snapshots, and install a kernel for each.
Both frontends are thin views over a single shared installer state:
┌──────────────────────────────┐
serial console ───>│ TUI (cursive / crossterm) │
└───────┬──────────────┬───────┘
│ ^
│ actions │ snapshots
v │
┌───────┴──────────────┴───────┐
│ Controller │
│ owns AppState — the single │
│ source of truth │
└───────┬──────────────┬───────┘
^ │
│ actions │ snapshots
│ v
on-device ┌───────┴──────────────┴───────┐
buttons ──────────>│ GUI (slint / linuxkms) │
└──────────────────────────────┘
Both frontends render the same menu tree, built once in
core::menu; only the current position in that tree is
per-frontend, so the two can sit on different screens while sharing every
selection.
core::model— plain data model (AppState, devices, images, snapshots, bundles).core::controller— ownsAppState, applies all mutations, and broadcasts immutable snapshots to every subscribed frontend so the TUI and GUI mirror each other live.core::menu— the menu tree: what each screen contains, built once and rendered by both frontends. Only the position in the tree is per-frontend.core::board/core::storage/core::removable— discovery.core::ufs— UFS descriptors and attributes over the SCSI BSG endpoint (transport and wire format only);core::provision— the logical-unit scheme, the comparison against it, and the destructive rewrite.core::bundle/core::archive/core::catalog— the two kinds of image source.core::fetch— every byte the installer reads, plus the SHA-256 primitives;core::stage— verifying artifacts before the target is touched.core::install— the destructive install pipeline (honours--dry-run).tui/gui— the two frontends.
Built and tested with rustc 1.95.0 (via rustup). The GUI depends on
Slint 1.17, which sets a minimum of rustc 1.92; the TUI-only build has a
lower floor (its highest-MSRV dependency is uuid, at rustc 1.85).
This repository uses a git submodule for the on-device font
(flipctl-fonts, tracking the
dev branch). Clone recursively, or initialise it after cloning:
git clone --recurse-submodules <repo-url>
# or, in an existing checkout:
git submodule update --init --recursive# Default: both frontends, host build (for development).
cargo build
# Release build for the device (RK3576 is ARMv8):
rustup target add aarch64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnu
# Slim, TUI-only variant (no GUI shared-library dependencies):
cargo build --release --no-default-features --features tui \
--target aarch64-unknown-linux-gnuThe binary links dynamically against the system C library and, for the GUI,
against libinput, libudev, libxkbcommon, libfontconfig and libfreetype
(the last two pulled in by Slint for font discovery/rendering), all located via
pkg-config. These shared libraries must therefore be present in the initramfs
alongside the binary (the TUI-only build needs none of them). The drm crate
talks to the kernel directly via ioctls (pregenerated bindings), so no libdrm
is needed. Install the build tools and dev packages (Debian/Ubuntu):
sudo apt install pkg-config libinput-dev libudev-dev libxkbcommon-dev \
libfontconfig-dev libfreetype-devThe GUI's text is rendered with the compiled-in HaxrCorp 4090 (FlipCTL) pixel
font (vendored as the third_party/flipctl-fonts submodule and embedded by the
Slint compiler), so no system fonts are required on the device.
Cargo feature flags:
| Feature | Frontend |
|---|---|
tui |
Cursive/Crossterm (serial) |
gui |
Slint/LinuxKMS (on-device screen) |
# Both frontends concurrently (default), safe dry-run:
sudo ./flipperos-installer
# Serial console only:
sudo ./flipperos-installer --tui
# On-device screen only, real install:
sudo ./flipperos-installer --gui --no-dry-run \
--kms-device /dev/dri/by-path/platform-2acf0000.spi-cs-0-card
# Install from a bundle sitting on a USB stick, streaming rather than staging:
sudo ./flipperos-installer --no-dry-run --fetch stream \
--bundle /mnt/usb/flipperone-update-20260812-83ddb68-nightly-15.tar.zst
# Hand-picked U-Boot + rootfs pair from the image server:
sudo ./flipperos-installer --custom --server https://images.flipperos.exampleBy default the installer runs in dry-run mode: every destructive command is
logged but not executed. Pass --no-dry-run to actually flash. --help lists
every flag; the bundle-specific ones are described under
Update bundles.
Once a real install has finished, both frontends offer to reboot: <ReBoot>
(Alt+B) in the serial console's button bar, and the RUN soft button on the
device, where it replaces Install. Use it instead of power-cycling — it closes
both frontends first, which is what leaves the serial console usable afterwards
(a terminal still in raw mode / the alternate screen survives a power cut). The
action is absent in a dry run, since nothing was written to boot into.
Two examples double as development probes, and both run without a screen or a serial console:
# Browse the real bucket and resolve the newest bundle.
cargo run --example bundle_probe --no-default-features
# Drive a whole installation headlessly, in dry-run mode.
cargo run --example dry_run --no-default-featuresAn update bundle is one artifact set that pins a U-Boot build and a rootfs build together, with a SHA-256 digest for every file. Installing from a bundle is the default. Bundles are published per channel:
bundles/<channel>/<build>/ channel = release | testing | nightly
bundles/dev/<user>/<branch>/<build>/ per-developer topic branches
and each build directory holds a manifest.json, the flashable
u-boot/<board>/u-boot-rockchip.bin for every supported board, the
profile-packs/ (the same <Profile>_<build>_stock[_inc]_pack.zst and
home_<build>_pack.zst files the image server publishes), MCU firmware the
installer ignores, and a *.tar.zst of the whole tree.
Directory listing is not available on the public object host, so the installer
lists through the bucket's object API (--bundle-bucket, or
--bundle-list-url to override the endpoint outright) and downloads from the
public base URL (--bundle-url). Builds are ordered by directory name,
descending; the build number shown in the UI comes from the manifest once a
bundle is selected, so nothing depends on the shape of the directory name.
A bundle can equally come from a local directory holding a manifest.json, or
from a *.tar.zst — passed with --bundle or found on removable media, which
the installer mounts read-only during discovery (--no-automount opts out).
A local archive is unpacked into the scratch directory before installing; a
remote install never reads the archive, only the individual files its manifest
lists and the operator's profile selection call for.
The Fetch row chooses when artifacts are checked:
verify first(the default) downloads exactly what the run needs — the U-Boot image, the Minimal full pack, each selected incremental and the/homeseed — into--cache-dir, compares each against its manifest digest, and only then starts partitioning. A bad or truncated artifact therefore cannot leave a wiped device behind. The space needed (~0.8–1.3 GiB) is checked up front.streamwrites as it downloads, hashing on the way through. The verdict necessarily arrives after the bytes have landed, so a mismatch is reported as a warning that says the target must not be booted.
Either way, an artifact whose manifest publishes no digest is installed with a warning that verification was skipped. Both modes also work for the custom development build flow, whose manifests now carry digests too.
The custom development build flow reads the image server's two-level catalog
(default base https://dl-linux-images.flipp.dev, override with --server):
- U-Boot:
/u-boot/manifest.jsonlists build directories; each build'smanifest.jsoncontains<board>/u-boot-rockchip.bin. The installer flashes<board>/u-boot-rockchip.binfor the detected board (flipper-one, elsegeneric). - Snapshots (rootfs):
/rootfs/manifest.jsonlists build directories; each build'smanifest.jsoncontains per-profile packs<Profile>_<build>_stock_pack.zst(full) and<Profile>_<build>_stock_inc_pack.zst(incremental delta vs. Minimal).
Both lists are presented newest first, and the operator picks one U-Boot build and one snapshot build — any combination, which is what makes this the custom flow rather than the default one.
Either way — bundle or custom pair — Minimal is always deployed (from its full
pack); any extra profiles the operator selects are received from their
incremental packs on top of Minimal. Packs are zstd-compressed btrfs send
streams, decompressed in-process and piped into btrfs receive.
A removable-media mirror of the same u-boot/ and rootfs/ tree is picked up
automatically and merged into the lists.
A UFS device is divided into logical units by its manufacturer, and the layout we need differs from the one they ship. When a UFS device is selected as the install target, the installer reads its Configuration Descriptor and compares it against the scheme in config/flipperos-ufs.toml:
| LU | Role | Memory type | Size |
|---|---|---|---|
| 0 | main system — GPT, loader partition, Btrfs | Normal | all remaining |
| 1 | U-Boot, flagged Boot LU A | Enhanced1 | 16 MiB |
| 2 | U-Boot, flagged Boot LU B | Enhanced1 | 16 MiB |
| 3 | recovery — kernel + initrd for on-device rescue | Enhanced1 | 128 MiB |
If the layout differs in a way that matters — which units exist, their size, memory type or boot flag, whether the boot feature is on, or whether the mask ROM is pointed at a boot LU at all — both frontends raise a confirmation prompt. Reprovisioning is destructive: the device rebuilds its whole mapping and everything on it is lost. Differences that do not change what the device is (the WriteBooster size, data reliability, provisioning type) are logged and tolerated. The full report is on the target-device level's details popup.
Provisioning goes through /dev/bsg/ufs-bsg<host> (needs CONFIG_SCSI_UFS_BSG) and
takes four steps: write the Configuration Descriptor, set bBootLunEn, set
fDeviceInit so the device rebuilds its logical units, and rescan the SCSI host
so the kernel picks up their new capacities. fDeviceInit is the same step
Rockchip's downstream USB-plug loader performs after provisioning
(ufshcd_complete_dev_init inside _ufs_start, see
drivers/ufs/ufs-rockchip-usbplug.c in rockchip-linux/u-boot), which is why no
power cycle is needed even though a device applies a new configuration only when it
initialises. The installer then re-reads the descriptor and refuses to call the job
done unless it reads back as the scheme.
Every descriptor field we do not vary is set to the value that loader writes, so a device provisioned by either agrees with the other.
Two details are worth knowing before touching this code. The UPIU header's
data_segment_length must be set by us for a WRITE DESCRIPTOR: the kernel fills
it in on its own query path but not on the raw BSG one, and a device that receives a
zero-length data segment answers a perfectly good descriptor with INVALID VALUE,
having never seen it (ufs-utils sets it in prepare_upiu for the same reason). And
the rescan must delete only data logical units: the well-known LUNs live under the
same SCSI target, the driver holds pointers to them, and deleting
hba->ufs_device_wlun earned a NULL dereference in rpm_drop_usage_count from
ufshcd_err_handler.
Because each boot LU then holds 16 MiB, a whole u-boot-rockchip.bin fits in one,
so a UFS bootloader update is fail-safe:
- read
bBootLunEnto see which boot LU the mask ROM currently reads; - write the image to the other one, in full;
- verify it against the manifest digest;
- only then point
bBootLunEnat it, and read the attribute back.
An interrupted or corrupted update therefore leaves the board booting exactly what
it booted before. A digest mismatch leaves the old boot LU active — the install
fails outright under verify first, and warns under stream, where the bytes have
already landed. A device with only one boot LU is written in place, with a warning
that says so. An install onto a device whose boot LU is too small for the image is
refused before anything is erased.
Which side is live is consequently not fixed, so the provisioning check accepts either — otherwise every second install would offer to wipe the device.
LU 1–3 are meant to end up RPMB write-protected; that is separate work, so
bLUWriteProtect is left clear for now (setting it would make them read-only
after a power-on reset, which would break the installer's own U-Boot write).
--no-ufs-check skips the check entirely, --reprovision-ufs reprovisions a
mismatched target without asking (for unattended and factory runs), and
--ufs-scheme <PATH> tries a different scheme without a rebuild.
The shared, top-level Btrfs subvolume skeleton (boot, @home, @var-log,
@var-cache, @snapshots, with boot kept uncompressed and the journal dir
NODATACOW) is described by a small TOML file that mirrors the build recipe. See
config/flipperos-btrfs.toml.
At install time the installer prefers a btrfs-layout.toml shipped with the
images and falls back to the copy compiled into the binary. Per-profile roots
(@Minimal, @Desktop, …) are not listed there — they are received from the
selected snapshot packs.
This is an early scaffold: the architecture, discovery, both frontends and the
dry-run install pipeline are in place. GPT partitioning, the U-Boot write and UFS
provisioning are done in-process (the gpt crate, and SG_IO ioctls for UFS);
the remaining destructive steps shell out to
blkdiscard, mkfs.btrfs, btrfs and chattr. Kernel installation is driven
by an embedded POSIX shell script
(scripts/flipperos-install-kernel.sh)
that chroots into each deployed profile and runs the profile's own
kernel-install for every installed kernel, writing into the shared /boot
subvolume. These tools (mount/umount, chroot, sh, and the profile's
kernel-install) must be present in the initramfs / profile.
The installer's own source in this repository is MIT-licensed (see
LICENSES/MIT.txt; the repository follows the
REUSE specification, so reuse lint is green).
However, the shipped binary links the Slint GUI toolkit, which we use under the GNU GPL v3.0 option of its tri-license (wehold no separate Slint agreement, and want the binary to be buildable purely from public sources). Linking GPL code makes the combined binary as a whole GPL-3.0-only. MIT is GPL-compatible, so our own sources are unaffected and may still be reused under MIT on their own.
Full license texts for every crate linked into the binary are collected in
THIRD-PARTY-LICENSES.md. Regenerate it from the
current Cargo.lock whenever dependencies change:
cargo install cargo-about --features cli # once
sh scripts/gen-third-party-licenses.shThe bundled fonts live in the third_party/flipctl-fonts submodule and carry
their own licenses (HaxrCorp 4090 — CC BY-SA 3.0; Born2bSportyV2 — The Unlicense;
Busy9px — MIT); see the LICENSE file in each font's folder.