Every notification this app posts was on one IMPORTANCE_LOW channel, so all of them were silent -- including the row for a build that landed while nobody was looking, which is the one that carries the Install button and is the only thing here that is news rather than a report of something somebody just pressed a button for. A second channel rather than raising that one, because Android lets an app only ever lower an existing channel's importance: raising "work" in code would do nothing whatever on a phone that had already run this app, and the whole fix would be somebody finding it in Android's settings. The waiting row also has to undo the setOnlyAlertOnce every other row wants. It replaces the running row it grew out of -- same tag, same id -- and an update to a notification posted alert-once is silent however loud its channel is, which is exactly the arrival nobody is watching for. Measured on the API 36 emulator: pressing Update on a test project and going to the home screen posts the row on channel=ready, importance 3, with mSound resolved and mIsInterruptive=true, and the shade files it above its own "Silent" divider. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
641 lines
30 KiB
Markdown
641 lines
30 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.
|
|
|
|
While anything is building, downloading or installing, the app keeps an
|
|
ongoing notification saying what is running and how far along it is — one
|
|
per thing, grouped together, each with its own build's 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. Refusing the
|
|
notification permission costs the watching and not the work.
|
|
|
|
A build that finishes while you are somewhere else leaves its notification
|
|
up with an **Install** button on it, because Android will not let the app
|
|
put the installer on screen from the background. Pressing it installs, and
|
|
the notification goes away by itself — as the rest do when their work is
|
|
over. That one arrives with a sound, and the progress ones do not: they
|
|
are two channels, *Ready to install* and *Work in progress*, so either can
|
|
be turned down on its own.
|
|
|
|
### 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 `<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.
|
|
|
|
## An app's own log
|
|
|
|
A `Server` component's runtime log is what its service script reports. An
|
|
`Apk` runs on the phone instead, where Android forbids one app reading
|
|
another's `logcat` — so an app delivered here has no way to say what it
|
|
did to somebody holding the phone and nothing else. The way out is for the
|
|
app to keep a bounded copy of its own recent log and hand it over on the
|
|
device: the two are already on the same phone, so this needs no tunnel, no
|
|
token and no second enrolment.
|
|
|
|
That is a **contract any app served here can implement**, not a feature
|
|
for one project. Implement it and that component gets a **Runtime** tab
|
|
beside its build log, showing the same view a service's runtime log gets.
|
|
|
|
Expose a `ContentProvider`, exported and read-only:
|
|
|
|
| | |
|
|
|---|---|
|
|
| authority | `<applicationId>.devlog` |
|
|
| read permission | `dev.updater.permission.READ_DEVLOG` (`android:readPermission`) |
|
|
| `content://<authority>/lines?since=<seq>` | every held line with `seq >= since`, ascending: `seq INTEGER, t_ms INTEGER, level TEXT, target TEXT, message TEXT` |
|
|
| `content://<authority>/status` | one row: `held INTEGER, dropped INTEGER, newest_seq INTEGER` |
|
|
|
|
`insert`, `update` and `delete` throw — there is nothing for anyone else to
|
|
change. `t_ms` is unix milliseconds from the app's own clock, because a
|
|
line is timestamped when it happened rather than when it was read.
|
|
`dropped` is what the app's own bound discarded, counted rather than
|
|
inferred, so "the log starts here" and "the log was cut off here" can be
|
|
told apart. `newest_seq` is `-1` for a log nothing has been written to,
|
|
and it is also what makes a restart visible: an in-memory log starts again
|
|
at zero, and a reader whose stored cursor is now past the newest sequence
|
|
starts again from the beginning rather than silently skipping everything
|
|
since.
|
|
|
|
The authority is derived from the `applicationId` rather than written
|
|
down, so a project's debug and release builds — installed side by side
|
|
under different ids — each get their own and cannot read each other's.
|
|
|
|
The permission is declared by Dev Updater at `protectionLevel="normal"`.
|
|
`signature` is not available: the updater and the apps it delivers are
|
|
built on one machine but signed with *different* locally generated keys,
|
|
so a signature permission would be held by nothing at all. The cost of
|
|
`normal` is real and worth saying plainly — on a phone with Dev Updater
|
|
installed, any app that requests `dev.updater.permission.READ_DEVLOG` by
|
|
name can read another app's dev log. These are development builds on a
|
|
development phone, and the alternative was no log at all.
|
|
|
|
Dev Updater's side of it: while the Runtime tab is open it asks the
|
|
provider once a second for what it has not seen, and forwards those lines
|
|
to this server, into that component's runtime log. It is a poll rather
|
|
than a `ContentObserver` because the contract does not oblige a provider
|
|
to call `notifyChange` — implementing it should be cheap. Forwarding
|
|
rather than rendering straight from the provider is what makes the history
|
|
outlive the phone and what lets one tab render both kinds. The lines
|
|
arrive here already formatted, one string each; this server appends them
|
|
and parses nothing, exactly as it does with a service's stdout, so an app
|
|
can change its own log format without anything here being taught about it.
|
|
|
|
A component with no provider still gets the tab, and the tab says which of
|
|
the several reasons there is nothing to read — the app is not installed,
|
|
it exposes no devlog, its provider refused us, or it simply has not logged
|
|
anything yet.
|
|
|
|
The reference implementation is iris's
|
|
`iris/android-app/app/src/main/java/dev/iris/android/demo/DevLogProvider.java`
|
|
in `~/repos/ai-app-2` (the `rustify` branch), over a ring in Rust.
|
|
|
|
## 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.
|