Files
dev-updater/README.md
T
iris 90082bd286 Each component builds on its own, and stops reporting when it is done
A project's components were decoupled everywhere except the one place it
showed: there was a single build slot per project, and a single card state
in the app keyed by project alone. So pressing Update on one client of a
two-client project disabled the other client's button for the length of a
build it shares nothing with, drew this one's progress bar and download
percentage under the other's row, and -- had the button been pressable --
would have been a silent no-op on the server, since a second request while
one was running returned without starting anything.

The slot is now per component. `Inner` has no `building` flag; a
component's own `ComponentRun` with an open `step` is the answer, and
`claim` writes that entry synchronously under the lock the route answers
from, so nothing can read a just-claimed component as idle -- which the
phone would take for a build that had already finished. A failure is
recorded against the component whose command it was rather than in the
project's one error slot, which two components building at once cannot
share.

A pull stays exclusive with everything, because there is one checkout and
it rewrites the files every component builds from. Releasing it and
claiming what it decided to build happen under one lock: a phone polling in
the gap would find a project neither pulling nor building and call the run
over.

The app mirrors the split -- `ProjectState` for the pull and the
project-wide Rebuild, `ComponentState` keyed by component for everything
one component is asked to do. Two hierarchies rather than one keyed by a
pair, so a download has nowhere project-wide to be stored. A component's
failure is drawn in its own row beside the Retry that acts on it, which is
also where a failed service action now reports.

And a finished component shows nothing at all: the elapsed times are gone
from both halves of the wire, and its button simply goes back to being
pressable. A bar, a count and a last line all describe something happening
now, and left up they sit there looking live next to a sibling that
genuinely is.

Verified on the emulator against test-projects/two-clients, which exists
for this: while `tablet` built, its row alone carried the bar and its
button alone was disabled, `phone` stayed pressable and silent, and both
returned to normal with no timing left behind.
2026-09-01 03:09:33 -04:00

528 lines
24 KiB
Markdown

# 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
```sh
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](#enrolling-a-phone). 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:
```sh
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:
```ron
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](#letting-a-project-carry-its-own-build-step).
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:
```ron
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:
```ron
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](https://github.com/ron-rs/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`.
```ron
// 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:
```ron
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.
```sh
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.