iris 3685ab107d Don't report a failure nobody was there to see
Leaving the app for a while and coming back to it greeted you with a
five-second read timeout. Work started before the screen went away keeps
running -- a build being followed, a download, the manifest poll -- and when
the device sleeps or the link drops, that work fails and reported itself.
The message said nothing about the server and there was nothing left to do
about it, because the thing it described was over before it was read.

Every catch now goes through one `failure(e)`, which answers null while the
screen is not resumed. Null means *clear*, never *leave*, at every site: a
dropped failure that left the card alone would leave a spinner up for an
operation that has already stopped. `setProject` joins `setComponent` so
both levels are written the same way and a caller can hand the answer over
whichever it is.

That is only safe because the resume re-read can now run from any state.
It was guarded on the list being loaded, which meant a failure ruled out
the one thing that would have replaced it -- a card that had gone red
stayed red until somebody found the Refresh button. Arriving and resuming
are also one event now rather than a LaunchedEffect beside a resume effect,
with `loadingList` claimed before the coroutine launches so the two cannot
stack two reads in one frame.

The other half of the same complaint is the read on the way back in: the
link may have been asleep as long as the app was, and the first request
across one still coming back times out at the five seconds every request
gets. The resume's read alone retries once. It costs nothing when the
server really is down, because a refused connection comes back at once
rather than waiting out a timeout -- and it is a retry, not a guess.

Checked all four ways on the emulator, including the two the change was not
written for. Killing the server while the app was backgrounded mid-poll and
returning: the list loads, no timeout. Pressing Refresh with the server
down: the failure is shown at once, in full. Returning while it is still
down: retried, then reported, no stuck spinner. Returning once it is back:
the list recovers, which the old guard would have prevented.
2026-09-01 03:39:00 -04:00

dev-updater

Keeps a phone's locally-built debug APKs current, without a browser and without leaving files in the Downloads folder to clean up by hand.

Two halves:

  • server/ — a small Rust (Axum) server that runs on the build machine. It is told about projects, finds the APKs built under them, and serves them over pinned TLS.
  • app/ — "Dev Updater", an Android app that lists what the server has, shows which builds are newer than what's installed, and hands downloads to the system package installer.

Adding an app is done from the phone: point it at a project directory and the server works out the rest (which APK, which package it replaces, what to call it). Nothing about any particular project is compiled in.

Getting started

cargo build --release --manifest-path server/Cargo.toml
./server/target/release/dev-updater --download   # generates its CA on first start
./app/build-apk.sh                               # embeds that CA

Run it from the repo root, as above: the server takes its own app project from the working directory, and started elsewhere its own card has no checkout to pull. Once it is working, ./start.sh does all of this and installs it as a service, which is the one command to run after a pull.

Then install the app once by hand — nothing else can install it before it exists. --download serves its APK over plain HTTP at http://<this machine>:8091, so a link typed into the phone's browser downloads it directly. After that first install the app updates itself the same way it updates everything else, over the pinned TLS port, and --download isn't needed again.

Open the app and scan the QR the server printed on first start — that is what enrolls the phone; see below. Then tap Add, put your repo directory in "Where to look", and the projects underneath it show up as one-tap suggestions — the ones built at least once, plus any carrying a .dev-updater.ron of their own, which can be added before their first build.

To develop the app itself against a local emulator instead:

cd app && ./run-android.sh

How an app gets found

The path you give is to a project, never to an APK. The server rediscovers the build underneath it on every request, so an ordinary rebuild needs no reconfiguration, and a build landing in a different variant directory is picked up on its own.

Discovery matches the handful of path shapes Android build tooling actually emits into (see server/src/discover.rs for the list — Gradle at one or two module levels, Flutter, and dioxus-cli's generated project). That is a deliberate choice over a general recursive search, which is far too slow to sit behind an interactive screen. Measured against one real 28 GB / 39k-file project, warm cache:

approach time
find -name '*.apk' 406 ms
the same, depth-limited 305 ms
the same, pruning .git/deps/.fingerprint/… 132 ms
the patterns actually used 6 ms

Depth limits buy nothing, because the breadth is in shallow Cargo/Gradle output directories. Supporting another build system means adding a pattern there.

When a project has several builds, the newest wins and the card offers a Variant menu to pin a specific one instead. That choice lives on the phone, not on the server, and travels with the download — two devices enrolled against one build machine each pick for themselves. The path is checked against the builds the server can actually see, so a pin left pointing at something a gradlew clean removed falls back to the newest rather than failing.

Configuration

config.ron (in $XDG_CONFIG_HOME/dev-updater/, created on first change, --config to move it) holds the repo roots, the added apps, and the hashes of the enrolled device tokens. It is written owner-only. It lives there rather than in the repo because it is per machine: paths in it resolve on the machine that wrote them, and a checkout shared between a machine and a VM would otherwise hand each the other's paths -- which showed up as every app reading "not built".

Point repoRoots at as many directories as you like, including a directory another machine's projects are mounted at; discovery treats them all the same. Everything in it is editable from the phone.

Pulling and building from the phone

An app whose project is a git checkout can be brought up to date from the phone, and needs no configuration to be pullable: the card shows the branch, and grows a Pull button when the remote actually has something this checkout doesn't. A button that is always there says nothing about whether pressing it would do anything, so its presence is the signal. Without a build command it pulls and stops, which is all it can honestly do — how a project builds is still never guessed.

Give it a command to have Pull build as well:

projects: [
    (
        key: "ai-app",
        projectPath: "~/repos/ai-app/app",
        components: [Apk(name: "app", build: "./build-apk.sh")],
    ),
],

Better still, let the project carry that itself — see below.

Set gitPull: false on a checkout that should never be moved from a phone. Paths accept ~, and are shown that way.

Updating this server itself

The Dev Updater card pulls and builds like any other, and is configured like any other: .dev-updater.ron in this repository declares its name and its two components — a Server (the binary) and an Apk (the phone app), built in that order. Its server row installs, starts, stops and reports like anyone else's.

It is a row in config.ron like any other too, so anything set per project — gitIpv4, say — has somewhere to live for this card as well. Everything else in that row is rewritten at each start from the declaration above and from the directory the server was started in, so edit those rather than the row.

What is compiled in is not whether it restarts but how. Its server component is this process, so restarting means exec-ing the binary just built rather than asking the service script to stop and start the code doing the asking — which would kill the build partway through, with nobody left to report how it went. The exec keeps the PID, so whatever supervises the server sees one continuous process rather than needing to be told to restart it. The Restart button takes the same route, and for the same reason: the script's restart would kill this process before it could answer, so a restart that worked would reach the phone as a failed request.

Two buttons on that row are worth knowing about before pressing them. This app reaches the server through the server, so Stop and Uninstall strand the phone until someone starts it again on the build machine; the app says so and asks first. They are offered rather than hidden because there are good reasons to want them, and a button that silently does nothing is worse than one that warns.

Its build step is the one that is never held for acceptance, because gating it would protect nothing: pulling this repository replaces the binary that would be doing the gating.

That closes the loop: new commits land, you press Pull, and the server and the APK it offers are both current. No other app can ask for the same: restarting to pick up an unrelated project's build would drop every other request for no reason.

Pull acts on the build machine — fetch, fast-forward, then run the command — while Update still means "install what's built onto this phone", so the two never mean each other.

While it runs, each component's own row says which step is happening and the last line that component printed. The command's output is read as it arrives rather than collected at the end, so a long build is visibly moving instead of being indistinguishable from a stuck one. When it finishes, all of that goes and the button becomes pressable again: what a row shows is work in progress, not a report on work that is over.

Components of one project build at the same time and are independent of each other, so updating one client of a project that builds two leaves the other's button live rather than making it wait. A pull is the exception — there is one checkout, so it waits for everything and everything waits for it.

Deliberate limits, because a phone is a bad place to resolve a mess:

  • Only a fast-forward. A branch that has diverged is reported, not merged or rebased.
  • A dirty working tree is refused outright, and left exactly as it was.
  • A branch tracking no upstream has nothing to pull, and says so.
  • Nothing is pulled without gitPull, and no build command is guessed.

Showing the list never changes your repositories. It asks each remote what it has (git ls-remote) and asks whether that commit is already an ancestor of what is checked out — no objects downloaded, no tracking refs moved, no FETCH_HEAD. The question is deliberately "would a pull move HEAD?" rather than "does the remote differ from origin/main?": the latter calls a checkout that has fetched but not merged up to date, and calls one carrying local commits behind. Fetching is Pull's job, which is why the card says "new commits" rather than a count: counting needs the objects, and downloading them is the thing the button is for.

Checks are started when the list loads — opening the app, resuming it, or pressing Refresh — and run concurrently, so several projects cost about one round trip rather than one each. There is no interval over which a previous answer is reused: one ls-remote is a fraction of a second, and rationing it meant a push made shortly after a check went unnoticed until the window expired. The only limit is that one checkout never has two checks running at once.

The list never waits for them. It answers from what is already known and says a check is still running; the phone looks again a moment later and the "new commits" badge appears on its own — about a second after the list, in practice. This is worth stating because the obvious design — wait for the answer, then reply — made every reopen of the app stall behind a round trip to the git host. What the list is actually about (which apps have builds, and whether this phone has them) is entirely local and takes milliseconds.

Those follow-up looks pass ?recheck=false, which collects the answer without asking again. Otherwise each look would start a fresh check, find it outstanding, and never stop.

Each check is still bounded by an SSH connect timeout and a hard stop, so an unreachable remote fails rather than hanging, and a failure keeps the last known answer instead of claiming there is nothing new. A pull marks its checkout current straight away, so a card stops offering what you just took.

On-demand builds

An app whose committed build output isn't what a phone needs can declare a build step, run automatically right before that app is downloaded:

projects: [
    (
        key: "app-dioxus",
        projectPath: "/home/you/repos/example/app-dioxus",
        components: [
            Apk(
                name: "app",
                build: "./build-android-arm.sh",
                staleWhen: (
                    path: "target/dx/app-dioxus/debug/android/app/app/src/main/jniLibs/arm64-v8a/libmain.so",
                    olderThan: "target/dx/app-dioxus/debug/android/app/app/src/main/jniLibs/x86_64/libmain.so",
                ),
            ),
        ],
    ),
],

The motivating case: an app with architecture-specific native code, iterated against an x86_64 emulator, leaves its arm64 slice stale. Rebuilding it on every local build would be wasted work, so it happens lazily at the one moment a real phone is about to be handed the APK — the app shows "Building for phone…" while it runs.

staleWhen compares two build outputs against each other rather than either against source, which is what makes it cheap and needs no knowledge of what the build reads. The consequence is that it only means anything once olderThan has itself been built at least once.

Paths are relative to projectPath (absolute ones are also accepted), and build is argv, run without a shell — written either as a line you would type or as explicit arguments; the string form splits on whitespace and has no quoting, so an argument containing a space needs the array.

Servers, and the service script

A Server component is a long-running process on the build machine, and it names one script that this server drives it through:

Server(
    name: "backend",
    build: "cargo build --release --manifest-path ../server/Cargo.toml",
    service: "../server/service",
),

That script is run as <script> <subcommand> for install, uninstall, start, stop, restart and status. Nothing about systemd or OpenRC is compiled in here — which init system is present, and how a unit gets written into it, is knowledge that belongs where the service does. See server/service in this repository for one that handles both; it is meant to be copied.

status has the only contract. It prints exactly one of:

running the service is up
stopped installed, not running, because somebody stopped it
failed installed, not running, because it fell over
not-installed no unit for it

and exits 0. Anything else it prints, or any non-zero exit, means it could not tell — which the card shows as "couldn't check" rather than as a service that is down.

failed earns its own word because both alternatives mislead. Reporting a crash as stopped sends you looking for who stopped it, and exiting non-zero reports "couldn't check" when the script found out perfectly well. Note that OpenRC does exit non-zero for a crashed service, so a script leaning on the exit status hits exactly that — read the word it prints instead. The card draws failed in red, beside the green it draws running in.

A service script must never prompt. It is run with stdin closed and no terminal, so a sudo password prompt would not fail — it would hang until the timeout with the card stuck mid-action. Anything needing root should exit with a message telling you to run it by hand once.

The card shows the state and offers only what fits it: Install when there is no unit, Start when it is stopped or failed, Stop/Restart when it is running. Uninstall asks first. State is read in the background on the same refresh that checks git remotes, never while the manifest is being built — asking a service manager costs a process spawn, and the manifest is fetched on every open and resume.

A successful build restarts a server that is already running, and leaves a stopped one stopped: you stopped it on purpose, and a build is no reason to overrule that.

Letting a project carry its own build step

Writing that block into this machine's config.ron works, but it puts project knowledge on the machine rather than in the project. A project can instead carry a .dev-updater.ron of its own, in the directory you would add — every key it understands, all of them optional.

These files are RON with one house rule: a file is the body of the config, so it has no outer parentheses and nothing in it is indented for them. Comments and trailing commas are fine, and an optional value is written as itself rather than wrapped in Some.

// What to call this project before there is a build to read a label from.
// A built APK wins: it is the authority on what will actually install.
label: "Thing",

// Offer a Pull button. On by default; set false for a checkout that should
// never be moved from a phone. Per project -- there is one checkout.
gitPull: true,

// What this project produces, in the order it should be produced. A
// component that fails stops the ones after it.
components: [
    // An APK, installed on the phone.
    Apk(
        name: "app",
        // The command, either as a line the way you would type it or as
        // explicit arguments. It is argv either way, run without a shell:
        // a program with a "/" in it resolves against the project
        // directory, anything else is a PATH lookup. The string form
        // splits on whitespace and has no quoting, so an argument
        // containing a space needs the array.
        build: "./build-android-arm.sh",
        // Working directory, relative to the project. Omit to run in it.
        cwd: ".",
        // Serve a debug-symbol-stripped copy. Off by default, and a
        // decision rather than something guessed from the APK's contents.
        strip: false,
        // Run the build before a download when the first path is older
        // than the second. Omit it and it runs when a pull brings commits,
        // or when nothing is built yet.
        staleWhen: (
            path: "…/arm64-v8a/libmain.so",
            olderThan: "…/x86_64/libmain.so",
        ),
    ),
    // A server on the build machine. Delivering one is restarting it, so a
    // successful build does that -- there is no flag asking whether to.
    // The script is what knows which init system is here; see above.
    Server(
        name: "backend",
        build: "cargo build --release",
        service: "../server/service",
    ),
],

Most projects need far less than that — a label and one component with a command is the usual whole of it:

label: "AI Sessions",

components: [Apk(name: "app", build: "./build-apk.sh")],

dev-updater configures its own entry this way too, in app/, rather than compiling the answer in — see that file for the self-update shape.

That file is a request, not an instruction. Nothing in it runs until you have read it on the app's card and pressed Accept build step — whereupon it is copied into config.ron, and from then on behaves exactly like a hand-written one. The card shows the request in full rather than a diff against the last one: these are a few lines, so reading the whole thing is quicker than reading a change to it, and doesn't depend on remembering the previous version. It is the same bargain an AUR helper offers when it shows you a PKGBUILD.

If the file later changes — which is to say, when a git pull brings a new one — it goes back to being unaccepted: the card shows the new request and the previously accepted command stops running until you accept again. Pulling still works while something is waiting, and stops after the fast-forward; that is how the new request arrives to be read in the first place.

The reason for the ceremony is that the two halves of this server carry very different risk. An APK it serves is sandboxed by Android and you already chose to install it. A component's build runs on the build machine as the user who started the server, so a checkout must never be able to change what that command is without anyone seeing it. Accepting is not a claim that the build script is safe — its contents live in the repository and change freely with every pull, which is exactly the trust anyone building the project already extends. It is the narrower claim that this project runs a build step at all, and that this is the one.

Enrolling a phone

Every request to the TLS port carries a bearer token. Pinning authenticates the server to the phone; the token is the other direction, and it is needed because these routes can repoint the scanner, enumerate what is on disk, add apps by path, and run a configured build command on the build machine.

The server generates one on first start and prints it as a QR code, which is the whole enrollment flow — open the app on an unenrolled phone, tap Scan QR code, and point it at the terminal. There is deliberately no manual-entry form: the token is 256 random bits, so typing it is not a thing anyone would do.

dev-updater --rotate-token   # a lost phone: invalidates every enrolled
                             # token and prints a fresh QR

Only the SHA-256 of the token is stored, in config.ron, so a leaked config doesn't leak the credential — which also means it is shown exactly once, at the moment it is generated. On the phone it is kept encrypted under an Android Keystore key. Rejections are logged with the peer address and never with the header, and they are delayed slightly so a port scanner shows up as a slow drip.

The bootstrap listener (--download) is deliberately not behind the token: it exists for a browser that has nothing to authenticate with yet, and it serves only this app's own APK.

Reachable only through WireGuard

Both listeners bind the wg0 address and nothing else, so neither is on the LAN. That is the outer of the two gates — the tunnel decides who can try, the token decides who is answered — and it is worth having on its own account: an unenrolled scanner never reaches the token check, and the bootstrap port's plain HTTP travels inside the tunnel's encryption. As a bonus the updater works away from home, which on a LAN address it never could.

It fails to start if wg0 is down rather than falling back. The escape hatch is explicit — --bind 0.0.0.0 restores the old LAN behaviour, still behind the token but with nothing in front of it — because this server is also how a stranded app gets reinstalled, and that recovery shouldn't depend on the tunnel being healthy.

Why TLS, and why plain HTTP for the bootstrap

Everything on the main port either is, or decides, what the app hands to REQUEST_INSTALL_PACKAGES next, so a MITM there could install arbitrary bytes. The app pins the dev CA in certs/ and trusts nothing else — not even the system trust store.

The --download bootstrap port is plain HTTP on purpose: a stock browser has nothing to pin against before the app is installed, so TLS there would only mean a trust-warning wall instead of a working link. It serves that one APK and nothing that can change server state.

The server generates its own CA on first start, into $XDG_CONFIG_HOME/dev-updater/certs — outside the repo, so a machine that can read the repo can't read the key that signs certificates this app trusts. The app embeds whatever ca.pem is there when app/build-apk.sh runs, so the pinned certificate follows the machine that built the APK and there is nothing to paste.

Regenerating the CA strands the installed app — it can only be updated through the pinned server — so recovery means rebuilding it and reinstalling over the bootstrap port.

Large APKs

An APK whose native libraries make up most of its size can be served as a debug-symbol-stripped, re-signed copy (<name>.slim.apk, cached against the original's mtime+size). One real case goes 224 MB → 53 MB. The untouched build stays on disk for local ndk-stack/logcat work; only the slim copy is served.

It is strip: true on the APK component, declared rather than detected. Whether the symbols are worth the transfer is a judgement about the project — a Compose app with a few small AndroidX .so files saves under 1% — and guessing it from the APK's contents was a guess that then needed an override anyway. It stays this server's business rather than the build script's because it is a property of this hop to a phone: a project built without dev-updater has no reason to strip.

Testing

The Kotlin half has no unit tests, but it does have two checks that should stay green: ./gradlew :androidApp:lintDebug and ktfmt (ktfmtCheck, or ktfmtFormat to fix). Formatting is plain kotlinLangStyle() with nothing configured, the same bargain cargo fmt makes on the Rust side.

./run-tests.sh (forwards arguments to cargo test). Covers the parts with logic worth testing: APK discovery, config round-tripping, staleness, the git read/pull/check split, token gating, certificate generation, the acceptance gate, and the self-restart predicate. The app is UI over the server's HTTP API and is verified by running it.

Versions

Kotlin 2.4.10 · Compose Multiplatform 1.12.0 · AGP 9.3.2 · Gradle 9.7.1 · JDK 21 · compileSdk/targetSdk 37. server/ targets Rust edition 2024.

Compose Multiplatform's material3 is on a separate release train and is pinned separately, at 1.9.0 — the newest material3 is still an alpha while runtime/foundation/ui are stable at 1.12.0.

S
Description
No description provided
Readme
5 MiB
0 Stars 1 Watchers 0 Forks
Languages
Rust 56.5%
Kotlin 38.7%
Shell 4.6%
Java 0.2%