# 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://: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. While anything is building, downloading or installing, the app keeps an ongoing notification saying what is running and how far along it is — one line per component, with each build's own count where the command reports one. It is there to be watched, and it is also what keeps the work alive: without it the app is an ordinary backgrounded process, and Android is free to reclaim it partway through a build. It goes away by itself when the last thing finishes. Refusing the notification permission costs the watching and not the work. ### 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. ### Files a build reads from somewhere else A component is considered out of date when the commits under its own directory move past the ones it was built from — `cwd`, or the project root for a component that names none. That is right about where a component's *files* are and wrong about what its build *reads*: a project whose clients each source one shared script, which locates their SDK and their signing key, has a file that changes what every build does and moves no component's directory. A commit to it rebuilds nothing, and every card correctly reports "current" while answering a narrower question than you were asking. A component can name the rest of what it reads: ```ron Apk( name: "app", cwd: "app-dioxus", build: "./build-android-arm.sh", alsoWatch: ["scripts"], ), ``` Those paths join its own in the same comparison — a commit touching any of them is a commit to this component, and an uncommitted change in one makes its freshness unknown, exactly as one in its own directory does. Declared rather than worked out, because which files a build reads is not something this server can know: guess too wide and every commit rebuilds everything, too narrow and you are back to the silence above. 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 `