Files
dev-updater/README.md
T
iris-aiandClaude Opus 5 bb65526230 Show an ongoing notification while the app is working
Everything this app asks for is minutes of the build machine's time
followed by a download, and all of it runs in the list screen's own
coroutines -- so with the app in the background the process is an
ordinary cached one, and Android is free to take it away halfway
through a build. A foreground service is the only way to say otherwise,
and its notification is both what that costs and what it is for: the
work is visible from the shade while it runs, and one tap comes back to
the card that started it.

The work itself stays where it is. WorkNoticeService runs nothing --
it collects a list of what is happening and reposts one notification,
stopping as soon as the list is empty -- and that list is *derived* by
workItems from the same projectStates/componentStates the cards are
drawn from, so there is nothing to forget to post and nothing to forget
to take back. The words are shared rather than written twice:
componentWorkLine and buildLine are now read by the card's own bars and
by the notification alike.

Measured on API 36 rather than assumed: the collapsed row drops the
content text the moment there is a progress bar, so one thing running
puts both the component and what is happening to it in the title
("Test Tablet / tablet: building  14/30"); several get a line each in
the expanded view and an indeterminate bar, there being no honest
single number for two builds at once. With the app at the home screen
the process sits at fg-service-act rather than cached, and the builds
it started landed while it was there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 19:36:03 -04:00

634 lines
29 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
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 `<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.