dev-updater: build an app on the machine, install it on the phone
A Rust backend that discovers Android projects under configured roots, builds one on request, and serves the APK over pinned TLS on a WireGuard interface; an Android client that lists what is buildable, watches a build, and installs the result. Enrolment carries the token and the CA, so the phone trusts exactly the machine that issued it and nothing else. `AGENTS.md` is the working guide and `README.md` the configuration reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`, which ai-app uses too. History before this point was squashed away, and a stale `config.json` went with it: nothing had read that file since the config moved to RON outside the checkout, and what it still held was one machine's absolute paths and the names of projects on it.
This commit is contained in:
commit
b0e83059a3
82 files changed
+20372
No files matched your search
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"env": {
|
||||
"ANDROID_HOME": "/home/bob/Android/Sdk",
|
||||
"ANDROID_SDK_ROOT": "/home/bob/Android/Sdk",
|
||||
"PATH": "/home/bob/Android/Sdk/platform-tools:/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/bin/site_perl:/usr/bin/vendor_perl:/usr/bin/core_perl:/usr/lib/rustup/bin:/home/bob/.local/bin"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// Read by Dev Updater -- this project's entry in its own list. Structured
|
||||
// as the body of the config: no outer parentheses, so nothing here is
|
||||
// indented for the sake of a wrapper.
|
||||
|
||||
// What to call this project before there is a build to read a label from.
|
||||
label: "Dev Updater",
|
||||
|
||||
// Where this project's own resources live. Read off the request path and
|
||||
// cached, because the `Script` variant would otherwise put a process
|
||||
// spawn on the manifest -- see `server/src/resources.rs`.
|
||||
resources: Ron("resources.ron"),
|
||||
|
||||
// Walked in order, and the order is the point: the binary is built first
|
||||
// so that a failing APK build leaves the phone the APK it already had
|
||||
// rather than half of a matched pair.
|
||||
//
|
||||
// The server component is this process. That changes how it is *delivered*
|
||||
// -- restarting means exec-ing the binary just built, not asking the
|
||||
// script below to stop and start the code that is running (see
|
||||
// `restart.rs`) -- but nothing about how it is declared, installed or
|
||||
// reported on. The card is an ordinary one.
|
||||
components: [
|
||||
Server(
|
||||
name: "server",
|
||||
// No cwd: this component is built and run from the checkout root,
|
||||
// which is where the server looks for its own project. That is
|
||||
// also what the generated unit takes as its working directory --
|
||||
// started anywhere else this server finds no checkout of its own,
|
||||
// and its card silently loses the branch line, its commit count
|
||||
// and the Pull button.
|
||||
build: "cargo build --release --manifest-path server/Cargo.toml",
|
||||
// Managed, like anything else that just wants its binary kept
|
||||
// running. Nothing about restarting *this* server lives in the
|
||||
// script -- that is `restart.rs`, which defers the hand-over past
|
||||
// the reply and spawns it detached. So there was nothing left for
|
||||
// a script of its own to say.
|
||||
service: Managed("server/target/release/dev-updater"),
|
||||
),
|
||||
Apk(
|
||||
name: "app",
|
||||
// The command resolves against the project root and `cwd` says
|
||||
// where to run it -- two different things, which is why this is
|
||||
// not `./build-apk.sh`.
|
||||
build: "app/build-apk.sh",
|
||||
cwd: "app",
|
||||
),
|
||||
],
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
.gradle/
|
||||
build/
|
||||
app/androidApp/build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
*.iml
|
||||
.idea/
|
||||
.DS_Store
|
||||
server/target/
|
||||
server/dev-updater.log
|
||||
|
||||
# Private key material. The server keeps this in $XDG_CONFIG_HOME by
|
||||
# default; this catches a run pointed back into the repo with --certs.
|
||||
certs/
|
||||
|
||||
# The added-apps list and the enrolled token hashes: machine-local
|
||||
# absolute paths, and whatever this particular machine has been pointed
|
||||
# at. Kept in $XDG_CONFIG_HOME by default, same as above -- this catches
|
||||
# a run with --config. Nothing here is shareable.
|
||||
config.ron
|
||||
|
||||
# A test project fails its build while this exists; see test-projects/README.md.
|
||||
break-the-build
|
||||
|
||||
# Dead: the live list is config.ron, kept in $XDG_CONFIG_HOME. This one was
|
||||
# an older format nothing reads, carrying one machine's absolute paths.
|
||||
config.json
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/wg-app-link"]
|
||||
path = vendor/wg-app-link
|
||||
url = git@git.arirex.me:iris/wg-app-link.git
|
||||
@@ -0,0 +1,634 @@
|
||||
# dev-updater
|
||||
|
||||
Serves locally-built debug APKs to a phone, and an Android app that
|
||||
installs them. See `README.md` first for what this is and how to run it;
|
||||
this file is the working notes on top of that.
|
||||
|
||||
The central design point, worth not undoing by accident: **an app is a
|
||||
project path, not a file path.** Everything downstream — which APK, which
|
||||
package it replaces, its label, whether it's worth stripping — is derived
|
||||
from what's actually built under that path, at add time or per request.
|
||||
Nothing about any particular project is compiled in, and the app list is
|
||||
mutable at runtime from the phone.
|
||||
|
||||
## Layout
|
||||
|
||||
- `vendor/wg-app-link/` — a **submodule**, and the shared half of this and
|
||||
ai-app: the WireGuard binding, the CA the app pins, QR enrollment,
|
||||
owner-only file creation, and the RON house rules. A submodule rather
|
||||
than a published crate because it pins an exact commit, so the two
|
||||
servers cannot end up on versions of it that disagree. Clone with
|
||||
`--recurse-submodules`; `git::pull` runs `submodule update` after the
|
||||
merge, because a merge moves the recorded pointer without touching the
|
||||
submodule's working tree and the build would otherwise keep compiling
|
||||
the old contents.
|
||||
What stays here is what differs: this server's routes, registry, build
|
||||
walk and service contract, plus `auth.rs`, whose middleware is generic
|
||||
over each project's own state -- only the token functions underneath it
|
||||
are shared.
|
||||
- `server/` — Rust + Axum. `main.rs` is the bootstrap and the two
|
||||
listeners; `routes.rs` has the whole HTTP table in its module doc
|
||||
comment. `auth.rs` is the bearer token every TLS request carries,
|
||||
applied once around the whole router so a new route cannot forget it. `registry.rs` owns the live app list and every mutation of it
|
||||
(config writes funnel through `AppState::update`, so in-memory and
|
||||
on-disk state can't come apart; the one other write is
|
||||
`AppState::new`'s startup reconciliation, before anything can read the
|
||||
list). `discover.rs` is the scanner,
|
||||
`config.rs` the persisted schema and the RON both config files are in,
|
||||
`apkinfo.rs` the `aapt2` reads,
|
||||
`strip.rs` the slim-APK pipeline, `sdk.rs` the SDK/NDK tool lookups.
|
||||
- `app/` — Kotlin + Compose, a single `:androidApp` module. `UpdaterScreen.kt`
|
||||
is the list, `AddAppScreen.kt` the add/settings screen, `AppsApi.kt` the
|
||||
management calls, `UpdateManifest.kt` the read side, `ApkInstaller.kt` /
|
||||
`InstalledBuilds.kt` the download-and-install path, `DownloadServer.kt`
|
||||
the transport and `Link.kt` the two values it hands the shared library. `Theme.kt` is Catppuccin Mocha
|
||||
mapped onto Material's roles, plus the four `ActionTone`s buttons come in
|
||||
-- red takes something away, the scheme's mauve replaces it with the same
|
||||
thing, green brings it up, blue puts a new build on the phone. What a
|
||||
button does is said in colour rather than by which component it sits on,
|
||||
so the same consequence looks the same everywhere. The mapping that
|
||||
matters is the surface ladder: Mocha names its darks in order (Crust,
|
||||
Mantle, Base, Surface 0) and Material asks for the same thing under other
|
||||
names, so the page is Base, a component's outlined card stays Base beside
|
||||
it, and a project's card is Surface 0 -- one visible step, which is all
|
||||
the nesting has to say.
|
||||
`NerdIcons.kt` names the icon glyphs,
|
||||
which are drawn as *text* in a small Nerd Fonts subset rather than
|
||||
as vector assets -- an icon beside a line of text wants that line's size,
|
||||
colour and baseline, and a `Text` gets all three for free. The font is
|
||||
generated: add a codepoint in `NerdIcons.kt` **and** in
|
||||
`app/build-icon-font.sh`, then re-run the script, or the glyph silently
|
||||
isn't there.
|
||||
|
||||
## Checking your work
|
||||
|
||||
- Server, from `server/`: `cargo fmt`, `cargo clippy --all-targets`, and
|
||||
`../run-tests.sh`. All three every time, not just when a change looks
|
||||
big enough to warrant them. The build is warning-clean and
|
||||
`cargo fmt --check` passes; keep both true. Formatting is plain rustfmt
|
||||
defaults with no `rustfmt.toml` — layout is not something to decide per
|
||||
line, so take what it gives rather than hand-formatting against it.
|
||||
- App: from `app/`, `. ./android-env.sh && ./gradlew :androidApp:compileDebugKotlin`
|
||||
(or `:androidApp:assembleDebug`), plus `./gradlew ktfmtFormat
|
||||
ktfmtFormatScripts :androidApp:ktfmtFormat :androidApp:ktfmtFormatScripts`
|
||||
and `./gradlew :androidApp:lintDebug`. Both are clean; keep them that
|
||||
way. ktfmt is `kotlinLangStyle()` with nothing else configured, so
|
||||
formatting is never a thing to decide per line.
|
||||
- Neither replaces running it. `app/run-android.sh` builds, installs and
|
||||
launches on an emulator; screenshot with `adb shell screencap -p
|
||||
/sdcard/x.png && adb pull /sdcard/x.png <local>`. Package
|
||||
`com.example.devupdater`, activity `.MainActivity`.
|
||||
- **`test-projects/` is what to point it at**, rather than a real project.
|
||||
Four fake ones -- a plain APK, one building two variants, one whose build
|
||||
fails on demand, and a `Server` + `Apk` pair with a service and a
|
||||
`resources:` declaration. They exist because a real project only does what
|
||||
it happens to do, where these can be asked to fail, to be slow, or to
|
||||
declare nothing. That directory's own README says how they are reached
|
||||
(they are deliberately not scanned) and why each one builds into an
|
||||
`app/` subdirectory; the short version of the second is that two levels is
|
||||
what `find_apks` matches, so a test APK any shallower is offered as a
|
||||
build of *this* project.
|
||||
- The server is easy to exercise directly, which is usually faster than
|
||||
going through the UI — but it takes three things, and leaving any of
|
||||
them out looks like the server being broken:
|
||||
|
||||
```sh
|
||||
# The CA it actually presents is the one in its config dir, not the
|
||||
# stale certs/ left in the repo. The address is wg0's -- it binds that
|
||||
# and nothing else, so 127.0.0.1 refuses the connection unless the
|
||||
# server was started with --bind. And every route on this port needs
|
||||
# the enrolled bearer token.
|
||||
curl --cacert "${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/certs/ca.pem" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"https://$(ip -4 -o addr show wg0 | awk '{print $4}' | cut -d/ -f1):8090/manifest"
|
||||
```
|
||||
|
||||
Only the token's SHA-256 is stored, so there is no reading it back out
|
||||
of `config.ron`: either use one printed by `--rotate-token`, or run a
|
||||
throwaway server with `--config`/`--certs` pointed somewhere temporary
|
||||
and `--bind 127.0.0.1`, which prints a fresh token at startup.
|
||||
|
||||
## Things that have bitten
|
||||
|
||||
- **Don't add a general recursive search to `discover.rs`.** It was
|
||||
measured and rejected; the numbers are in that module's doc comment and
|
||||
in the README. Extend `APK_PATTERNS` instead.
|
||||
- **`aapt2` and the strip pipeline never run on the manifest path.** A
|
||||
process spawn or a zip walk there turns a 43 ms refresh into something a
|
||||
phone notices, and the manifest is fetched on every open, resume and
|
||||
Refresh. `aapt2` is add-time work cached in `config.ron`; stripping
|
||||
belongs to the download alone, which is why the manifest reports the
|
||||
size of whatever is on disk (`strip::serveable_now`) rather than
|
||||
producing the slim copy to measure it.
|
||||
- **Both config files are RON with two house rules**, and
|
||||
`wg_app_link::format` is the only place that knows them -- ai-app's files
|
||||
are in the same shape, which is why they are shared rather than described
|
||||
twice. `config.rs`'s own `format` module is now only the migration hook
|
||||
in front of it, and goes when that does. A file is the *body*
|
||||
of the struct -- no outer parentheses, so nothing is indented for a
|
||||
wrapper -- because RON has no implicit top-level struct; `parse` adds the
|
||||
paren and `render` strips it. And `IMPLICIT_SOME` is set on the
|
||||
deserializer rather than by a header each file would have to carry, which
|
||||
is why every optional field also needs `skip_serializing_if` so nothing
|
||||
is written back that nobody typed. The two halves only round-trip
|
||||
together: change one and every file on disk still loads while only
|
||||
looking wrong, which is why the config test asserts the written shape.
|
||||
- **A `Server` component is driven through one script, run as
|
||||
`<script> <subcommand>`.** `service.rs` knows the subcommands and the
|
||||
four words `status` may print (`running`, `stopped`, `failed`,
|
||||
`not-installed`); it knows nothing about systemd or OpenRC, and
|
||||
shouldn't. Scripts run with stdin closed, because one that prompts would
|
||||
hang rather than fail.
|
||||
**On OpenRC, `status` reads the exit code and never the text**
|
||||
(`0` started, `3` stopped, `32` crashed, anything else "could not find
|
||||
out"). `rc-service status` prints its status line to *stderr*, so the
|
||||
obvious check -- discard stderr, grep stdout for `crashed` -- throws away
|
||||
the word it is looking for and reports a crashed service as `stopped`,
|
||||
which is the exact lie the `failed` state exists to prevent. The codes
|
||||
also distinguish "could not find out", which the text cannot: an
|
||||
uninitialised user softlevel makes every call fail, and that is not a
|
||||
state the service is in. Assign the code through `|| code=$?`, or `set
|
||||
-e` kills the script before it can read one -- every answer but "running"
|
||||
is a non-zero exit. Measured on OpenRC 0.63.3, not read.
|
||||
**There is a guest to measure in**, at `~/vm/gentoo` on this machine --
|
||||
`./start.sh`, then `./shell.sh 'cmd'`, ssh on 127.0.0.1:2223 as `tester`
|
||||
with `guest_key`. Its own README is the reference. Everything in this
|
||||
section, and the restart behaviour below, was run there rather than
|
||||
reasoned about; do the same rather than trusting either the
|
||||
documentation or a systemd result. Detecting OpenRC is itself a trap:
|
||||
`rc-service --user --help` fails when `XDG_RUNTIME_DIR` is unset, so
|
||||
probing with it first reports "no service manager here" on a machine
|
||||
that has one.
|
||||
- **Which script is what `service:` says**, and it is a sum:
|
||||
`Script("path")` for a project that carries its own, `Managed("the
|
||||
binary")` for one that wants dev-updater's built-in. Two variants rather
|
||||
than two optional fields, because a component picks one and "both" and
|
||||
"neither" would be states the file could express that mean nothing. The
|
||||
built-in is `server/src/service-default.sh`, compiled in, written to
|
||||
`$XDG_DATA_HOME/dev-updater/service-default` at every startup so an
|
||||
updated dev-updater cannot be driving last version's copy, and handed
|
||||
its identity as arguments (`--name <key>-<component> --exec "..."`) so
|
||||
the subcommand the caller appends still lands last. `service::driver` is
|
||||
the single place the two variants become one command -- everything
|
||||
downstream takes a command and cannot tell which it was, which is what
|
||||
lets a project switch between them without anything else changing.
|
||||
It is deliberately a *script* rather than an in-process implementation:
|
||||
a built-in that ran through Rust would put systemd and OpenRC knowledge
|
||||
back in the one place it has been kept out of, and the contract would
|
||||
become a special case of itself. **dev-updater declares `Managed` like
|
||||
anything else**, and has no service script of its own: nothing about
|
||||
restarting this server lives in a script, it lives in `restart.rs`, so
|
||||
there was nothing left for one to say. Its component carries `cwd: ".."`
|
||||
because the unit's working directory has to be the checkout root -- that
|
||||
is where this server looks for its own app project, and started anywhere
|
||||
else its card silently loses the branch line, its commit count and the
|
||||
Pull button. `start.sh` knows none of this: it runs
|
||||
`dev-updater --service <subcommand>` on the binary it has just built,
|
||||
which derives the name and the script from the same declaration the
|
||||
running server reads. One place decides how this service is named and
|
||||
invoked, which is the only reason a bootstrap script cannot drift away
|
||||
from `service::driver`.
|
||||
A managed service is named `<key>-<component>`, because a service
|
||||
manager's names are one flat namespace across every project on the
|
||||
machine. Switching a project from its own script to `Managed` therefore
|
||||
changes the unit's name: **uninstall the old one first**, or its unit is
|
||||
orphaned with nothing left pointing at it.
|
||||
- **Service state is checked off the request path**, in `ServiceChecks`
|
||||
beside `git::RemoteChecks` and for the same reason: a process spawn per
|
||||
server component on `/manifest` is exactly what that path must not do.
|
||||
Which means it is subject to the same rule as a remote check: it lands
|
||||
*after* the response that started it, so `checksPending` counts both, and
|
||||
the phone waits on both (`ManifestEntry.checksOutstanding` for one card).
|
||||
And the answering starts at startup rather than on the first request
|
||||
(`main.rs`), so the window in which a component has no state is this
|
||||
server's own start rather than something a phone has to sit through --
|
||||
restarting is how this server is updated, which makes it the moment
|
||||
somebody is most likely to be looking at the list.
|
||||
Counting only remotes was a real bug and an invisible one: the state is
|
||||
unknown only until the first answer of a server's life, remote checks
|
||||
ordinarily outlast service ones, and the symptom is a component row with
|
||||
no buttons -- so it showed up as "the buttons vanish after a restart if I
|
||||
come back to the app too quickly", and only on whichever cards lost the
|
||||
race. Nothing looked again until somebody hit Refresh.
|
||||
- **dev-updater declares itself as an ordinary `Server` + `Apk` pair**, but
|
||||
its server component *is this process*, and that changes one thing only:
|
||||
*when* it restarts. Running the script's `restart` inside the walk would
|
||||
kill it before the APK is built and before anyone is told how it went, so
|
||||
the restart is deferred past `finish`; the Restart route defers for the
|
||||
same reason, since a script that killed this process before it could
|
||||
answer would make a restart that worked read as a failed request. Once
|
||||
deferred, both go through the one function (`restart::deferred`), which
|
||||
asks the manager when the service is up and only otherwise execs the new
|
||||
binary itself. Going through the manager is what makes a refreshed unit
|
||||
apply -- an exec inherits the unit this process was started with, so a
|
||||
pull that changed the service script would otherwise land only at the
|
||||
next restart by hand. The button used to exec unconditionally, on the grounds
|
||||
that exec keeps the PID; that made it the one restart a rewritten unit
|
||||
never applied to, which is the kind of surprise a special case buys.
|
||||
Deciding it in one place is what stops the two drifting again. `BuildState::is_self` is the flag, and it is passed in
|
||||
rather than declared -- a file that could claim it is a file that can
|
||||
make this server exec an arbitrary binary. A build that changed nothing
|
||||
skips the restart altogether (`restart::binary_unchanged`): exec-ing into
|
||||
an identical binary drops the phone's connection to deliver the build it
|
||||
already had. It is a `stat` of the binary against what it was at startup
|
||||
rather than a hash -- cargo installs a new binary by renaming over the
|
||||
old one, so the inode alone answers it, and reading tens of megabytes to
|
||||
learn the same thing would cost more than the restart it avoids. It says
|
||||
"changed" whenever it cannot tell, because a needless restart costs a
|
||||
second while a skipped one leaves the old build serving and reads as the
|
||||
update having silently failed. The Restart *button* is unconditional --
|
||||
somebody asked. Stop and Uninstall do go
|
||||
through the script, and do strand the phone; the app confirms them
|
||||
rather than hiding them.
|
||||
- **A remote git failure is shortened and explained in one place**
|
||||
(`git::remote_failure`). Git's stderr runs to a paragraph of generic
|
||||
advice, so the card gets the first line and the log gets all of it; and
|
||||
when the failure was authentication, what this process can see of the
|
||||
ssh agent is appended, because "works in my terminal, fails from the
|
||||
service" is almost always `SSH_AUTH_SOCK` not being inherited by a
|
||||
daemon, and that is invisible from a phone. Asked rather than assumed --
|
||||
the note can say the agent is fine, which is what stops it being blamed.
|
||||
- **A branch with no upstream is not a broken one.** `can_pull` requires an
|
||||
upstream as well as a checkout, which is what stops the card reporting
|
||||
the same fact twice -- once as its own note and once as the check's
|
||||
complaint about it -- and stops it offering a Pull that `git::pull`
|
||||
would refuse. It is also not worth *saying*: plenty of checkouts have no
|
||||
remote deliberately, and a card that mentions it every time is nagging
|
||||
about a choice somebody made.
|
||||
|
||||
- **The branch line is how a failed remote check is visible at all.** With
|
||||
it gone, `newCommits` stays false and the card reads as an app with no
|
||||
updates -- the same silent failure the self-entry note below describes.
|
||||
It was deleted once by accident in a refactor and nothing failed; only
|
||||
the display went quiet.
|
||||
|
||||
- **Forcing git onto IPv4 is done in `GIT_SSH_COMMAND`, not on the git
|
||||
subcommand.** `git fetch` takes `-4`; `git ls-remote` does not, and
|
||||
answers "unknown switch `4'". `ls-remote` is what the new-commits check
|
||||
runs, so splicing the flag onto every remote command turned the setting
|
||||
into a switch that broke every card's commit count -- which is how it
|
||||
was first written, and what `git::ssh_command` and
|
||||
`forcing_ipv4_does_not_break_the_check` now hold in place. `fetch` keeps
|
||||
the flag as well, since that is what carries the preference to an https
|
||||
remote; ssh is covered either way.
|
||||
|
||||
- **This server's own project is a row in `config.projects` like every
|
||||
other, and everything in that row except `gitIpv4` is re-derived at
|
||||
startup** by `registry::reconcile_self`. The row is what gives a
|
||||
per-project setting one home -- it used to have to be keyed in a
|
||||
separate `config.settings` list, because the one card that is always in
|
||||
the list was the one card the setting could not be a field on. What is
|
||||
derived stays derived: the path is the working directory (see below),
|
||||
and the label and components come from the checkout's own
|
||||
`.dev-updater.ron`, so a re-clone or a moved repo fixes itself at the
|
||||
next start instead of leaving the row naming a directory nobody pulls.
|
||||
Only what somebody chose is carried across. The declaration is accepted
|
||||
by construction there, which is why `AppEntry::pending_declaration`
|
||||
answers nothing for it: between a pull that rewrites the declaration and
|
||||
the restart that follows, the row and the file can differ without that
|
||||
meaning anything is waiting to be accepted. A config still carrying the
|
||||
old keyed `settings` list loses it silently -- `Config` ignores fields it
|
||||
does not know -- so a machine that skips straight past the version that
|
||||
migrated one has its `gitIpv4` to set again.
|
||||
|
||||
- **A component has two kinds of log, and the kind is an axis of its
|
||||
own.** The build log is what this server captured while building it; the
|
||||
runtime log is what the component wrote while running, reported by its
|
||||
service script. They are asked for one kind at a time (`?kind=`), and the
|
||||
modal gives each a tab -- opening on runtime, since what a service is
|
||||
doing now is the usual question, and on build for the component a build
|
||||
stopped at (`build_failed`). They were one list indexed by generation
|
||||
once, and because build logs came first the runtime one sat at an index
|
||||
nothing ever asked for: unreachable, and silent about it. ANSI escapes
|
||||
are rendered rather than stripped (`AnsiLog.kt`) -- the colour is how a
|
||||
process marked its own errors -- and sequences with no meaning on a
|
||||
phone are consumed rather than printed, so a cursor movement cannot
|
||||
arrive looking like a corrupted log.
|
||||
- **There are three sizes of refresh, and using the wrong one is what
|
||||
makes the list feel like it has a mind of its own.** `refresh()` drops
|
||||
the list to a spinner and asks every remote -- it belongs to arriving,
|
||||
resuming, and the Refresh button, which are the moments somebody asked
|
||||
to be shown the current state. Pull-to-refresh is the same ask without
|
||||
the spinner, since the gesture brings its own indicator and taking the
|
||||
list away underneath it would say the same thing twice. `applyOne` reads `GET /apps/{key}`
|
||||
and puts that one entry back, so an action on one card cannot move or
|
||||
change any other; every card action uses it, including adding one --
|
||||
the Add screen hands back the key and the list fetches just that app.
|
||||
`awaitCheck` is the middle one: ask a single checkout's remote and wait
|
||||
on *that* answer, which the card's own refresh control and a freshly
|
||||
added app both use. Waiting on the whole list to settle would make one
|
||||
card's refresh sit behind another card's.
|
||||
|
||||
- **The Add screen is drawn over the list, not in place of it.** Swapped
|
||||
out, the list is composed again from nothing on the way back -- and
|
||||
rebuilding it means fetching it, which is the whole-list refresh that
|
||||
adding an app has no business causing. `describe` in `routes.rs` is the
|
||||
one place a card is built, so `/manifest` and `GET /apps/{key}` cannot
|
||||
come to describe the same app differently.
|
||||
|
||||
- **A progress bar is drawn from counts the build reports, never from an
|
||||
estimate**, and the knowledge of how to get those counts belongs to this
|
||||
server rather than to each project. Two ways in, both ending as
|
||||
`@@progress done/total` on the build's output:
|
||||
cargo reports its own once `CARGO_TERM_PROGRESS_WHEN=always` is set,
|
||||
which `spawn` sets for every build command, so a cargo build needs
|
||||
nothing and must not be wrapped. Gradle cannot -- an init script is
|
||||
refused by the configuration cache, and its rich console reports a
|
||||
percentage only as a full-screen redraw that would make the output
|
||||
unreadable -- so `server/src/build-progress.sh` does the `--dry-run`
|
||||
count and the `> Task` tally, shipped by this server (`crate::shipped`)
|
||||
and offered to build scripts as `$DEV_UPDATER_PROGRESS`. A script tests
|
||||
for the variable rather than depending on it, so building by hand still
|
||||
works. This is what stops each project carrying its own copy of the
|
||||
same twenty-five lines.
|
||||
Reading the output means splitting on carriage returns as well as
|
||||
newlines: a tool redrawing a counter in place puts several updates and
|
||||
then real output inside one `\n`-delimited line.
|
||||
- **Discovery must stay side-effect free.** It runs on every manifest
|
||||
request and every suggestion scan.
|
||||
- **An app in the list is a *project*, and what it produces is its
|
||||
components.** Ordinarily one `Apk`; a project that also runs a server on
|
||||
the build machine declares a `Server` beside it. They are a sum, not one
|
||||
struct with both halves' fields, and the common fields are repeated per
|
||||
variant so the file reads `Apk(name: ...)` with nothing nested inside a
|
||||
`kind`. `Component::same_declaration` destructures exhaustively, so
|
||||
adding a field anywhere fails to compile until someone has said whether a
|
||||
project declares it or this server measures it.
|
||||
- **A build reinstalls every server component's unit, not just changed
|
||||
ones.** The unit is generated from a script in the repository, so a pull
|
||||
can change how a service is *defined* rather than what it runs -- and
|
||||
that has to land even when the build produced a byte-identical binary,
|
||||
which is the ordinary case for a pull that only touched the script.
|
||||
Installing is safe on a running service (it writes the unit and reloads;
|
||||
it stops nothing), which is why `start.sh` has always done it every run.
|
||||
It is deliberately not gated on `restart::binary_unchanged`: that answers
|
||||
whether a *restart* is worth the interruption, and unit staleness is a
|
||||
different question. Wiring the two together left the unit stale exactly
|
||||
when the build had nothing to do.
|
||||
- **A build runs every component at once, and each reports for itself.**
|
||||
They are independent -- a Rust build and a Gradle build share nothing but
|
||||
the machine -- and measured here, together takes about three quarters of
|
||||
the time one after the other does. The saving only appears when more than
|
||||
one has work, which is what a pull produces.
|
||||
The status is per component (`ComponentStatus`), so a card draws each
|
||||
component's bar, timing and last line inside that component's own row;
|
||||
the project's own area at the bottom keeps only what belongs to the whole
|
||||
project, which is fetching and pulling. A bar under the card could only
|
||||
ever say that *something* was happening, and with everything building at
|
||||
once that is exactly what the reader is trying to find out.
|
||||
A failure no longer stops the others -- they are already running -- so
|
||||
the first failure in declaration order is the one reported, which is what
|
||||
a walk in that order would have said. The phone draws every component in
|
||||
a card of its own, including a project with only one: the flat layout it
|
||||
used to get meant two shapes to keep in step, and put that APK's size up
|
||||
beside the card's corner controls where it read as belonging to them.
|
||||
- **A project's own `.dev-updater.ron` is a request, never an
|
||||
instruction.** It only runs once accepted from the phone, which copies
|
||||
it into `config.ron`; `AppEntry::pending_declaration` is the whole gate.
|
||||
Evaluate it *fresh* rather than caching it on the entry — the file
|
||||
changes on a pull, and a pull writes no config, so nothing rebuilds the
|
||||
entry list. Caching it meant a pull could swap the command out from
|
||||
under a previous acceptance and nothing noticed until an unrelated
|
||||
config write. Enforce it wherever a command would actually run, not
|
||||
only where the list is built.
|
||||
Two things follow that both went wrong the moment `resources:` joined
|
||||
the gate. **Whatever `Declaration::matches_accepted` compares,
|
||||
`AppState::approve_declaration` has to write** -- it compared the
|
||||
resources declaration and wrote only the components, so accepting stored
|
||||
half of what was being read back and the gate could never clear. The
|
||||
press succeeded, so there was no error either; the card simply kept
|
||||
asking. Adding a field to one without the other is the shape to watch
|
||||
for, and the tests are what let it through: they built an "accepted"
|
||||
config by hand instead of calling `approve_declaration`, so they
|
||||
asserted a copy of the rule rather than the rule.
|
||||
And **on the pull path the gate is asked after the fast-forward, never
|
||||
before it** (`build_state::pull_and_build` takes a closure for exactly
|
||||
this reason). The declaration lives in the checkout, so the pull is the
|
||||
one thing that changes the answer -- a bool computed at the call site is
|
||||
the answer for the commit being replaced, which made a pull that changed
|
||||
the declaration build anyway. Pulling stays allowed while a request is
|
||||
unaccepted, since taking commits runs git rather than the project's
|
||||
command, and it is how the new request arrives to be read.
|
||||
- **Which build variant to serve is the phone's choice, not the server's.**
|
||||
It arrives as `?variant=` on the download and is validated against the
|
||||
discovered builds -- an unvalidated path would let a phone name any file
|
||||
on disk to be served. Storing it server-side meant one enrolled device
|
||||
silently changing what another was offered.
|
||||
- **The self entry's project is the working directory itself**, so this
|
||||
server has to be started from the root of its own checkout -- which is
|
||||
where everything else here is driven from, and what the service unit
|
||||
sets (`WorkingDirectory=$REPO_ROOT`, not `server/`). Its components then
|
||||
say where they live from there, exactly as any other project's do; there
|
||||
is nothing special about this one's layout, which is the point of it not
|
||||
being `app/` any more. It used to be
|
||||
`CARGO_MANIFEST_DIR`, fixed when the binary was *compiled*, so a
|
||||
re-clone or a move left the card watching a directory nobody pulls. The
|
||||
symptom is why this is worth remembering: the entry keeps working and
|
||||
keeps serving an APK, but silently loses its branch line, its Pull
|
||||
button and its commit count, so it reads as an app that simply never has
|
||||
an update. It took a "why can't my server see the new commits?" to find.
|
||||
Startup warns when there is no checkout at the resolved path, which is
|
||||
the only cheap moment to notice.
|
||||
- **A configured project pointing at this server's own project is a second
|
||||
card for it.** `add_app` refuses it (the self entry is in the list it
|
||||
checks against, and `self_project` is canonicalized so a symlink can't
|
||||
make one directory look like two), so this only survives from a config
|
||||
written while the self entry resolved somewhere else. It is not merely
|
||||
redundant: it is not the self entry, so `is_self` is false and a build
|
||||
through it would restart this server partway through its own build. `build_entries` warns rather than dropping
|
||||
it -- dropping it would leave a config entry with no card and so no way
|
||||
to remove it from the phone. The built-in card has no Remove button, so
|
||||
the duplicate is the one that does.
|
||||
- **A project says where its own state is; nothing infers it.** The
|
||||
`resources:` declaration points at a RON file, a script that prints the
|
||||
same RON, or an inline struct, and it holds what a project would
|
||||
otherwise write down in several places -- its `name`, and its `data` and
|
||||
`config` directories when those are not `$XDG_*_HOME/<name>`. **The file
|
||||
is the project's, not this server's**: its own code is meant to read the
|
||||
same one, which is why `ResourceFacts` ignores unknown keys where
|
||||
`Declaration` sets `deny_unknown_fields`, and why it goes through
|
||||
`wg_app_link::format` like every other RON here.
|
||||
A project that says nothing gets nothing -- the dialog reports not
|
||||
knowing rather than filling in a directory. Two guesses were tried and
|
||||
both rejected: the config key (`updater`, `app`, which resolve to
|
||||
`~/.config/updater` and `~/.local/share/app`, neither of which exists)
|
||||
and the checkout's directory name (right for both projects here, and
|
||||
still an inference presented as a fact). Iris's call, and the reason is
|
||||
the failure mode: a wrong path does not error, it reads as "this
|
||||
component keeps nothing here".
|
||||
Read **off the request path**, through `crate::checks` beside the git
|
||||
and service checks, because `Script` spawns a process and `/manifest` is
|
||||
fetched on every open, resume and Refresh. It is in `checksPending` with
|
||||
the other two -- counting only some of them is the bug that once left
|
||||
components with no buttons after a restart. And it is part of the
|
||||
acceptance gate, since a `Script` runs: comparing the whole declaration
|
||||
rather than the variant means changing which file is read is a change
|
||||
somebody is asked about.
|
||||
|
||||
- **Uninstall can take three things away, and the path on screen is the
|
||||
only guard.** The dialog offers logs, data and config as separate
|
||||
toggles -- only logs on by default, and ticking data forces logs on,
|
||||
since a service that writes its log inside its own data directory would
|
||||
lose it either way. `purge.rs` is the whole of it. Where the data and
|
||||
config are comes from the project's `resources:` declaration, above; a
|
||||
project that does not say gets two disabled toggles saying so, and the
|
||||
four reasons a toggle is disabled are distinguishable on screen because
|
||||
"couldn't read the resources" is a fault to fix while "doesn't say" is
|
||||
an ordinary project.
|
||||
A path is removed **wherever it points**, with no check that it
|
||||
sits under the XDG directories -- Iris's call, over the alternative of
|
||||
refusing anything outside them. What replaces that check is the dialog
|
||||
showing each resolved path before the button can be pressed, so the
|
||||
phone never asks for a path nobody saw. Anything that stops the path
|
||||
being displayed removes the only guard there is.
|
||||
Log paths are collected *before* the uninstall runs, because the script
|
||||
that reports where a service's log lives is the thing being removed.
|
||||
|
||||
- **The self entry can't be removable.** The app can only be updated
|
||||
through this server, so an updater that can drop itself from its own list
|
||||
strands the installed copy (recovery is a manual reinstall over the
|
||||
bootstrap port).
|
||||
- **`/self` and `/self/apk` are a frozen contract, and the only rescue the
|
||||
app has.** Every other route is reachable only by an app new enough to
|
||||
understand it: change what `/manifest` says and an older app cannot read
|
||||
the list, which is where the button that would replace it lives. So the
|
||||
path that fetches a newer app depends on nothing likely to change --
|
||||
two numbers, no nesting, no variants, no query parameters -- and the app
|
||||
asks it on every launch and again after building its own project, which
|
||||
is the moment the newer copy exists and the server it must keep talking
|
||||
to has just changed. Add fields at your peril and never rename one;
|
||||
anything richer belongs on a route an old app never calls. It does not
|
||||
survive a changed CA, port or token, since those break the connection
|
||||
before any route is reached: those stay one-way doors and `--download`
|
||||
stays their answer.
|
||||
- **There is deliberately no general migration mechanism.** Iris's call:
|
||||
each app decides its own, and the updater's job is only to be able to
|
||||
get both halves of *itself* to the next version. A project that renames
|
||||
or restructures its service is uninstalled and reinstalled from the card,
|
||||
which works because dev-updater is not the thing being stopped.
|
||||
- **The bootstrap link (`--download`) serves the APK already on disk and
|
||||
builds nothing.** Shipping a stale one there is the expensive mistake,
|
||||
because a fresh install is not enrolled and everything that would replace
|
||||
it -- Update, and the QR scan that enrols a device at all -- lives in the
|
||||
copy being installed, so the only way out is another trip through
|
||||
`--download`. It cost a round of "the scanner fix never landed" when the
|
||||
fix was in the checkout the whole time. The startup line names the
|
||||
variant and the build's age for that reason; run `./app/build-apk.sh`
|
||||
first.
|
||||
- **Changing the pinned CA is a one-way door for any installed copy.** Same
|
||||
reason. `wg_app_link::certs` deliberately won't regenerate an existing
|
||||
CA; only the leaf is reissued, on every start.
|
||||
- **State is per machine, not in the repo**:
|
||||
`$XDG_CONFIG_HOME/dev-updater/{config.ron,certs}`, owner-only. This
|
||||
repo is shared with a VM at a different path, so a shared config hands
|
||||
each machine the other's project paths, and a CA key in it is one that VM
|
||||
could sign with. The APK pins the CA of whatever machine builds it
|
||||
(`app/build-apk.sh`, `DEV_UPDATER_CA` overrides).
|
||||
- Freshness is the APK's **mtime** versus `PackageInfo.lastUpdateTime`, not
|
||||
a version code — these are ad hoc rebuilds with nothing bumping a
|
||||
version. It follows that a build and an install landing in the same
|
||||
second can briefly read as "update available"; that's inherent, not a
|
||||
bug to chase.
|
||||
|
||||
## Running the server for real
|
||||
|
||||
`./start.sh` is the shortest way back to a working state: it builds both
|
||||
halves, rewrites the service unit (so a pull that changes the service script
|
||||
takes effect) and starts or restarts it. Use it after a pull, or when the
|
||||
running server and the checkout have drifted.
|
||||
|
||||
To run one by hand instead, launch it **from the repo root** -- that is
|
||||
where it looks for its own app project (`app/`), and started anywhere else
|
||||
its own card has no checkout -- and **fully detached from the calling
|
||||
shell**, not via an agent's background-task tracking, which ties its
|
||||
lifetime to the session:
|
||||
|
||||
```sh
|
||||
setsid nohup /path/to/dev-updater </dev/null >server/dev-updater.log 2>&1 & disown -h
|
||||
```
|
||||
|
||||
Verify with `ps -o pid,ppid,pgid,sid,tty,comm -p <pid>` — detached means
|
||||
`PPID 1`, its own `SID`, `TT ?`. Check for a stale instance first
|
||||
(`pgrep -af "[d]ev-updater" || true`): a second instance fails to bind and
|
||||
silently leaves the old one answering. Stop with
|
||||
`pkill -f "[d]ev-updater" || true`.
|
||||
|
||||
**Run `pgrep -f`/`pkill -f` as a command of its own, with nothing else on
|
||||
the line, and bracket the first character of the pattern**
|
||||
(`"[d]ev-updater"`). Both also exit nonzero when nothing matches, which is
|
||||
the normal case; `|| true` that.
|
||||
|
||||
Why the pattern alone isn't enough, since this keeps catching people: each
|
||||
command runs as `bash -c '<the whole command text>'`, so the wrapper's own
|
||||
argv contains every word you wrote. An unbracketed pattern therefore
|
||||
matches the shell running it — `pgrep` reports phantom matches and `pkill`
|
||||
kills that shell outright (exit 144, truncated output, reads exactly like
|
||||
the thing under test having crashed). Bracketing the pattern fixes only
|
||||
*that* word: any **other** mention of the same string on the line re-arms
|
||||
it. And you usually can't fix the other mention, because it is the real
|
||||
command —
|
||||
|
||||
```sh
|
||||
# Kills its own shell: the pattern is bracketed, but the path below
|
||||
# still puts the plain string "target/debug/dev-updater" in the argv,
|
||||
# and bracketing *that* would change which file gets executed.
|
||||
pkill -f "[t]arget/debug/dev-updater" || true; setsid nohup ./target/debug/dev-updater …
|
||||
```
|
||||
|
||||
so the only rule that always works is to keep them in separate commands:
|
||||
one to stop, one to start.
|
||||
|
||||
One more way this bites, which bracketing does nothing about: `-f` matches
|
||||
the **whole command line**, so it also catches processes that merely have
|
||||
the string somewhere in a path. An agent's scratchpad directory is named
|
||||
after the repository, so anything launched with a path through it -- the
|
||||
Android emulator, say -- matches `[d]ev-updater` and gets killed along
|
||||
with the server. Match the binary rather than the project when stopping
|
||||
one: `pkill -f "[t]arget/debug/dev-updater"`.
|
||||
|
||||
## Environment notes
|
||||
|
||||
The machine itself — where the Android SDK is, that each command runs in a
|
||||
fresh shell so exports have to be chained, and each repo having its own
|
||||
emulator — is described once in `~/.claude/MACHINE.md`, which every
|
||||
session reads. What follows is what that means here.
|
||||
|
||||
- `app/android-env.sh` applies the SDK override unconditionally, and
|
||||
`.claude/settings.json` puts `platform-tools` alone on `PATH` so `adb`
|
||||
works without sourcing anything. Everything else needs the env script.
|
||||
- **This repo has its own AVD, named `dev-updater`**, which is what
|
||||
`app/run-android.sh` and `app/enroll-emulator.sh` default to; every
|
||||
Android project on this machine has one named after it, and none of them
|
||||
share. Two sessions in two repos on one emulator each silently replace
|
||||
the app the other just installed, which reads as a build that never
|
||||
landed rather than as interference.
|
||||
- **To exercise installing, add a throwaway test app**, not another repo's
|
||||
real one. This server's whole job is putting *other* projects' builds on a
|
||||
device, so trying that out needs something to install -- and a scratch
|
||||
project you wrote is better for it than a real app, because you can make
|
||||
it do whatever the case under test needs: fail its build, produce two
|
||||
variants, change its label, bump nothing at all. A real app only does what
|
||||
it happens to do. It goes in the list like any other project, so nothing
|
||||
here needs to know it is a test.
|
||||
- **To actually drive the app on the emulator, use
|
||||
`app/enroll-emulator.sh`** rather than working enrolment out again. There
|
||||
is no camera to scan the QR with, so it fires the `devupdater://enroll`
|
||||
intent the app already accepts, generating a token once and adding it to
|
||||
`config.ron` beside any real phone's. Two things it exists to stop you
|
||||
rediscovering: `adb shell am start -d` loses everything after the first
|
||||
unescaped `&`, because the URI reaches the *device's* shell; and the
|
||||
server must be started with `--bind 0.0.0.0` for the emulator to reach
|
||||
it at all, since it otherwise binds wg0 and the emulator has no route
|
||||
there. The script's header has the detail.
|
||||
**It names the device rather than assuming there is only one**, which
|
||||
matters more now that every repo has its own AVD: a second emulator
|
||||
running beside this one makes every bare `adb` call exit 1 with "more
|
||||
than one device/emulator", and the version that ran `adb get-state` read
|
||||
that as *no emulator running* -- the opposite of what had happened, which
|
||||
sends you off to start a third. It resolves the serial by AVD name the
|
||||
way `run-android.sh` always has, defaults to `dev-updater`, takes
|
||||
`--avd NAME` or `AVD_NAME=`, and when it cannot find that one it lists
|
||||
what *is* attached instead of guessing which of the three situations it
|
||||
is in.
|
||||
- The server needs `aapt2` (SDK build-tools) to add an app, and
|
||||
`llvm-strip` (NDK) only for apps that need stripping.
|
||||
@@ -0,0 +1,521 @@
|
||||
# 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, the card says which step is happening, how long it has
|
||||
taken, what the finished steps took, and the last line the build 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 — and when it *is* slow, the phase timings say whether
|
||||
the time went to the network or the compiler.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Ryan L McIntyre
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/sh
|
||||
# Android SDK environment for this app's Gradle build: locates the SDK and
|
||||
# exports the PATH/env vars the build needs. Pure Kotlin/Gradle, so nothing
|
||||
# Rust/NDK-specific belongs here.
|
||||
#
|
||||
# Source this directly for one-off commands instead of going through the
|
||||
# full run-android.sh (which also creates/boots the emulator, builds,
|
||||
# installs, and launches):
|
||||
#
|
||||
# . ./android-env.sh
|
||||
# ./gradlew :androidApp:assembleDebug
|
||||
# adb devices
|
||||
#
|
||||
# Safe to source repeatedly. Intentionally does NOT `set -e`/`set -u`: this
|
||||
# file is meant to be sourced into whatever shell is already running --
|
||||
# including a long-lived one a session reuses for unrelated commands -- and
|
||||
# changing that shell's error-handling options as a side effect of sourcing
|
||||
# would be surprising. run-android.sh, which does want strict mode, sets its
|
||||
# own `set -eu` before sourcing this.
|
||||
|
||||
# Hardcoded (not derived from an inherited ANDROID_HOME) so this doesn't
|
||||
# silently follow whatever that happens to be set to elsewhere -- e.g. this
|
||||
# sandbox's own profile exports ANDROID_HOME=/opt/android-sdk system-wide, a
|
||||
# root-owned install this user can't write to. Everything needed lives under
|
||||
# the path below instead, matching Android Studio's own default SDK location
|
||||
# convention on Linux.
|
||||
SDK_ROOT="$HOME/Android/Sdk"
|
||||
ANDROID_HOME="$SDK_ROOT"
|
||||
ANDROID_SDK_ROOT="$SDK_ROOT"
|
||||
# ~/.local/bin is where the `android` CLI itself installs to (see its own
|
||||
# installer); adding it here too means sourcing this script guarantees a
|
||||
# working `android` command even in a shell that hasn't picked up
|
||||
# ~/.profile yet.
|
||||
PATH="$HOME/.local/bin:$SDK_ROOT/cmdline-tools/latest/bin:$SDK_ROOT/platform-tools:$SDK_ROOT/emulator:$PATH"
|
||||
# Pin the AVD directory explicitly so avdmanager (creation) and the emulator
|
||||
# binary (lookup at start time) are guaranteed to agree on where the AVD
|
||||
# lives -- left to their own defaults they can resolve different locations
|
||||
# and disagree on whether it exists.
|
||||
ANDROID_AVD_HOME="${ANDROID_AVD_HOME:-$HOME/.android/avd}"
|
||||
mkdir -p "$ANDROID_AVD_HOME"
|
||||
export ANDROID_HOME ANDROID_SDK_ROOT ANDROID_AVD_HOME PATH
|
||||
|
||||
echo "==> Ensuring required SDK packages are installed in $SDK_ROOT"
|
||||
# $SDK_ROOT is user-owned (unlike /opt/android-sdk), so this genuinely
|
||||
# installs anything missing rather than just probing for it -- still
|
||||
# best-effort (`|| echo`) so a transient network hiccup doesn't abort a
|
||||
# script sourcing this under `set -e`.
|
||||
#
|
||||
# build-tools is needed twice over: by Gradle for this app's own build, and
|
||||
# by ../server at runtime for `aapt2` (reading a discovered APK's package
|
||||
# name) and `llvm-strip`/`apksigner` (the slim-APK pipeline).
|
||||
android sdk install "cmdline-tools/latest" "platform-tools" "emulator" \
|
||||
"platforms/android-37.0" "build-tools/37.0.0" \
|
||||
"system-images/android-36/google_apis/x86_64" \
|
||||
|| echo " (non-fatal: see above)"
|
||||
@@ -0,0 +1,126 @@
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.composeCompiler)
|
||||
alias(libs.plugins.ktfmt)
|
||||
}
|
||||
|
||||
// See the root build script for why this style and not ktfmt's default.
|
||||
ktfmt { kotlinLangStyle() }
|
||||
|
||||
// The CA this app pins is baked in at build time from the certificates on
|
||||
// the machine doing the build -- `$XDG_CONFIG_HOME/dev-updater/certs/ca.pem`,
|
||||
// which the server generates on first start. DEV_UPDATER_CA overrides it.
|
||||
//
|
||||
// Reading it rather than keeping a pasted copy in the source means the
|
||||
// trust anchor follows the build machine, the CA's private key never has
|
||||
// to exist anywhere near this repo, and regenerating a CA needs a rebuild
|
||||
// instead of a paste -- so a stale constant can't quietly disagree with
|
||||
// the server the app is trying to reach.
|
||||
val pinnedCaPath: String =
|
||||
System.getenv("DEV_UPDATER_CA")
|
||||
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
||||
"/dev-updater/certs/ca.pem"
|
||||
|
||||
abstract class GeneratePinnedCert : DefaultTask() {
|
||||
/** Where the certificate is looked for, reported in failures. */
|
||||
@get:Input abstract val caPath: Property<String>
|
||||
|
||||
/**
|
||||
* The certificate itself, set only when it exists -- so a missing one produces this task's own
|
||||
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
|
||||
*/
|
||||
@get:InputFile
|
||||
@get:Optional
|
||||
@get:PathSensitive(PathSensitivity.NONE)
|
||||
abstract val caCertificate: RegularFileProperty
|
||||
|
||||
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
||||
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val path = caPath.get()
|
||||
val ca = File(path)
|
||||
if (!ca.isFile) {
|
||||
throw GradleException(
|
||||
"No CA certificate at $path.\n" +
|
||||
"Start the server once on this machine first -- it generates the CA the " +
|
||||
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
||||
"Set DEV_UPDATER_CA=/path/to/ca.pem to build against a different one."
|
||||
)
|
||||
}
|
||||
val pem = ca.readText().trim()
|
||||
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
||||
throw GradleException("$path is not a PEM certificate.")
|
||||
}
|
||||
// The PEM must start immediately after the opening quotes: a
|
||||
// leading newline costs Android's CertificateFactory its
|
||||
// "-----BEGIN" sniff, so it tries DER instead and fails at runtime
|
||||
// with an ASN.1 decode error, nowhere near this file.
|
||||
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText(
|
||||
"""
|
||||
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
||||
|package com.example.devupdater
|
||||
|
|
||||
|const val PINNED_CA_PEM = ""${'"'}$pem
|
||||
|""${'"'}
|
||||
|
|
||||
"""
|
||||
.trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val generatePinnedCert =
|
||||
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
||||
val ca = file(pinnedCaPath)
|
||||
caPath.set(pinnedCaPath)
|
||||
if (ca.isFile) {
|
||||
caCertificate.set(ca)
|
||||
}
|
||||
}
|
||||
|
||||
// AGP 9 wants generated sources registered through the variant API rather
|
||||
// than added to a source set, so the task dependency is carried properly.
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
variant.sources.java?.addGeneratedSourceDirectory(
|
||||
generatePinnedCert,
|
||||
GeneratePinnedCert::outputDir,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.devupdater"
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.devupdater"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
|
||||
buildTypes { getByName("release") { isMinifyEnabled = false } }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.zxing.embedded)
|
||||
// The half of this app that is the same as ai-app's: pinned TLS, the
|
||||
// enrollment store, and the scanner.
|
||||
implementation(project(":link"))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- Lets this app hand a downloaded .apk to the system installer via
|
||||
an ACTION_VIEW intent; without it the intent silently fails on
|
||||
Android 8+ (see ApkInstaller.kt's canRequestInstall()). -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<!-- Android 17 (API 37) made Local Network Protection mandatory: an app
|
||||
targeting 37+ needs this runtime permission to reach *any* local
|
||||
network address, including a plain socket to a LAN IP literal with
|
||||
no discovery involved. Below 37 it doesn't exist and INTERNET
|
||||
implicitly covers LAN access, which is why the other two apps here
|
||||
(both still targeting 36) never needed it. Without it the traffic
|
||||
is silently dropped, surfacing only as a connect timeout. See
|
||||
MainActivity.kt's runtime request. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
|
||||
|
||||
<!-- Package visibility (API 30+): without this, PackageManager.getPackageInfo()
|
||||
for another app throws NameNotFoundException even when it's
|
||||
installed, see InstalledBuilds.kt, which queries lastUpdateTime to
|
||||
tell "already have this build" apart from "update available." The
|
||||
normal fix is a static queries allowlist, but that means a
|
||||
manifest edit and a rebuild of this app every time a new app is
|
||||
added to serve_apk.py's /manifest, which defeats the point of
|
||||
driving the app list from that manifest in the first place. This
|
||||
is a Play Store *policy* restriction (apps requesting it without
|
||||
an approved use case get rejected from the Store), not something
|
||||
the OS itself enforces, so it's free to declare here since this
|
||||
app is never distributed through Play. F-Droid, a comparable
|
||||
sideloaded app store/updater, does the same for the same reason. -->
|
||||
<!-- Suppressed on this one permission only, never on the file or the
|
||||
project. Lint is right that a queries declaration is normally the
|
||||
answer, and the paragraph above is why it cannot be one here: the
|
||||
packages to ask about are whatever the server's manifest lists at
|
||||
runtime, which nothing declared at compile time can name. Left
|
||||
unsuppressed this is the only error in an otherwise clean lint
|
||||
run, and a check that always fails is a check nobody runs. -->
|
||||
<uses-permission
|
||||
android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<!-- Self-lookups are always visible regardless of the above; no
|
||||
queries entry needed for this app's own package. -->
|
||||
|
||||
<application
|
||||
android:label="Dev Updater"
|
||||
android:allowBackup="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- Enrollment: the server prints its devupdater://enroll QR to
|
||||
the terminal. This intent filter is the fallback path for a
|
||||
camera app that redirects a scanned devupdater:// URI here
|
||||
directly; the "Scan QR code" button on the not-enrolled
|
||||
screen (zxing-android-embedded) is the primary path and
|
||||
needs no filter, since it decodes the QR itself and hands
|
||||
the URI to parseEnrollmentUri in-process. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="devupdater" android:host="enroll" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- The scanner behind the not-enrolled screen's "Scan QR code".
|
||||
Declared here so it can drop the library CaptureActivity's
|
||||
landscape pin: the code being scanned is usually on a monitor
|
||||
in front of someone holding the phone upright.
|
||||
zxing_CaptureTheme is the library's own fullscreen theme,
|
||||
which is all the activity needs. -->
|
||||
<activity
|
||||
android:name="com.example.wgapplink.EnrollmentScanActivity"
|
||||
android:clearTaskOnLaunch="true"
|
||||
android:screenOrientation="fullSensor"
|
||||
android:stateNotNeeded="true"
|
||||
android:theme="@style/zxing_CaptureTheme"
|
||||
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||
|
||||
<!-- Exposes downloaded APKs (private app storage, see
|
||||
ApkInstaller.kt's downloadApk()) to the system package
|
||||
installer without making them world-readable. -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="com.example.devupdater.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,448 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Adds an app to the list, by project path.
|
||||
*
|
||||
* Two ways in, because typing an absolute path on a phone keyboard is the real cost here: the
|
||||
* suggestion list (what the server found under the configured repo roots) covers the normal case in
|
||||
* one tap, and the free-text field below covers anything living outside those roots.
|
||||
*
|
||||
* The path is always to the *project*, never to an APK: the server rediscovers the build underneath
|
||||
* it on every request, so a rebuild -- or one that lands in a different variant directory -- needs
|
||||
* no reconfiguration here.
|
||||
*
|
||||
* Outcomes -- both the server's rejection messages and confirmations -- are reported through a
|
||||
* snackbar rather than text on the screen: the triggering control can be anywhere in a long
|
||||
* scrolling list, and by the time an "add" comes back the banner position is often scrolled out of
|
||||
* view, so a message there would go unseen exactly when it matters.
|
||||
*
|
||||
* @param onChanged invoked after any successful change, so the list screen behind this one can
|
||||
* refetch rather than guessing at the new state.
|
||||
*/
|
||||
@Composable
|
||||
fun AddAppScreen(onAdded: (key: String) -> Unit, onBack: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
var suggestions by remember { mutableStateOf<Suggestions?>(null) }
|
||||
// Only the initial load reports failure on the screen itself: there is
|
||||
// nothing else to show at that point, and it usually means the server
|
||||
// is unreachable rather than one action having been refused.
|
||||
var loadError by remember { mutableStateOf<String?>(null) }
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
// The server is walking the repo roots. Shown rather than left silent
|
||||
// because a rescan runs after every change here, and until it lands
|
||||
// the list below is the state from *before* that change.
|
||||
var scanning by remember { mutableStateOf(false) }
|
||||
var newRoot by remember { mutableStateOf("") }
|
||||
var pathText by remember { mutableStateOf("") }
|
||||
|
||||
fun loadSuggestions() {
|
||||
scanning = true
|
||||
scope.launch {
|
||||
try {
|
||||
val loaded = withContext(Dispatchers.IO) { fetchSuggestions() }
|
||||
suggestions = loaded
|
||||
loadError = null
|
||||
} catch (e: DownloadServerException) {
|
||||
loadError = e.message
|
||||
} finally {
|
||||
scanning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one management call, then refetches so what's shown is the server's state, not a guess.
|
||||
*
|
||||
* [onSuccess] runs after [action] on the calling (main) dispatcher -- for UI state such as
|
||||
* clearing a text field, which [action] itself must not touch since it runs on
|
||||
* [Dispatchers.IO].
|
||||
*/
|
||||
fun run(describe: String? = null, onSuccess: () -> Unit = {}, action: () -> Unit) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
scope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { action() }
|
||||
onSuccess()
|
||||
loadSuggestions()
|
||||
// Only where nothing on screen would otherwise show that
|
||||
// it worked. Adding an app needs no announcement: the card
|
||||
// it was added from says "Already added" the moment the
|
||||
// rescan lands, which is the same fact sooner and without
|
||||
// covering the list to say it.
|
||||
describe?.let { snackbar.showSnackbar(it) }
|
||||
} catch (e: DownloadServerException) {
|
||||
// The server's message explains what to do about it ("point
|
||||
// this at the app's project directory and build it once
|
||||
// first"), so it's shown verbatim and kept up until
|
||||
// dismissed rather than timing out mid-read.
|
||||
snackbar.showSnackbar(
|
||||
e.message ?: "Failed",
|
||||
withDismissAction = true,
|
||||
duration = androidx.compose.material3.SnackbarDuration.Indefinite,
|
||||
)
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { loadSuggestions() }
|
||||
|
||||
Scaffold(
|
||||
// imePadding so the snackbar rides above the on-screen keyboard,
|
||||
// which is otherwise up whenever a text field is in use -- exactly
|
||||
// when a message is most likely to arrive.
|
||||
modifier = Modifier.imePadding(),
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
) { insets ->
|
||||
Column(Modifier.fillMaxSize().padding(insets).padding(16.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Add an app", style = MaterialTheme.typography.headlineSmall)
|
||||
// An add, a remove or a roots change is in flight. The
|
||||
// controls go disabled anyway, but that says "not now"
|
||||
// rather than "working on it".
|
||||
if (busy) {
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Working()
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
loadError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.weight(1f)) {
|
||||
item {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Found projects", style = MaterialTheme.typography.titleSmall)
|
||||
// Only once there is a list to be stale: before
|
||||
// that, the spinner below stands in for the whole
|
||||
// section rather than sitting beside a heading with
|
||||
// nothing under it.
|
||||
if (scanning && suggestions != null) {
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Working()
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
val found = suggestions
|
||||
// What is already in the list isn't a suggestion: this
|
||||
// section is for choosing something to add, and a row that
|
||||
// can only tell you it has been added is one more thing to
|
||||
// read past every time.
|
||||
val addable = found?.projects.orEmpty().filterNot { it.added }
|
||||
if (found == null) {
|
||||
item { CircularProgressIndicator() }
|
||||
} else if (addable.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
when {
|
||||
found.roots.isEmpty() ->
|
||||
"No directories to scan yet -- add one under \"Scan " +
|
||||
"directories\" below."
|
||||
// Told apart deliberately: "nothing here"
|
||||
// and "you already have all of it" are
|
||||
// different answers, and only one of them
|
||||
// is a reason to go looking at the paths.
|
||||
found.projects.isEmpty() ->
|
||||
"Nothing found under the directories being scanned. A " +
|
||||
"project shows up here once it has been built at " +
|
||||
"least once, or as soon as it carries a " +
|
||||
".dev-updater.ron of its own."
|
||||
else ->
|
||||
"Every project found under the directories being scanned " +
|
||||
"has been added already."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(addable, key = { it.path }) { project ->
|
||||
SuggestionCard(
|
||||
project = project,
|
||||
busy = busy,
|
||||
onAdd = {
|
||||
// The key comes back from the add so the
|
||||
// list can fetch that one app rather than
|
||||
// reloading itself to find it.
|
||||
var key: String? = null
|
||||
run(onSuccess = { key?.let(onAdded) }) {
|
||||
key = addApp(project.path)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text("Or add by path", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// The button beside the field rather than under it, so
|
||||
// the pair reads as one control and the section is one
|
||||
// row tall instead of three.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = pathText,
|
||||
onValueChange = { pathText = it },
|
||||
label = { Text("Project directory") },
|
||||
placeholder = { Text("/path/to/project") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RowGlyphButton(
|
||||
glyph = PLUS_GLYPH,
|
||||
label = "Add this path",
|
||||
enabled = !busy && pathText.isNotBlank(),
|
||||
tone = ActionTone.Go,
|
||||
onClick = {
|
||||
val path = pathText.trim()
|
||||
var key: String? = null
|
||||
run(
|
||||
onSuccess = {
|
||||
pathText = ""
|
||||
key?.let(onAdded)
|
||||
}
|
||||
) {
|
||||
key = addApp(path)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Last: this is set once and then rarely touched, unlike
|
||||
// the two sections above it.
|
||||
item {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text("Scan directories", style = MaterialTheme.typography.titleSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
val roots = suggestions?.roots.orEmpty()
|
||||
roots.forEach { root ->
|
||||
// Editable in place: correcting a typo in a path
|
||||
// otherwise meant deleting the row and typing the
|
||||
// whole thing again. Keyed on what the server last
|
||||
// said, so a saved edit is replaced by what was
|
||||
// actually stored rather than left as typed.
|
||||
var edited by remember(root) { mutableStateOf(root) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = edited,
|
||||
onValueChange = { edited = it },
|
||||
enabled = !busy,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
val wanted = edited.trim()
|
||||
if (wanted.isNotEmpty() && wanted != root) {
|
||||
run {
|
||||
setRepoRoots(
|
||||
roots.map { if (it == root) wanted else it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RowGlyphButton(
|
||||
glyph = TRASH_GLYPH,
|
||||
label = "Stop scanning $root",
|
||||
enabled = !busy,
|
||||
tone = ActionTone.Destructive,
|
||||
onClick = { run { setRepoRoots(roots.filterNot { it == root }) } },
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Adding is the text field plus the button beside it,
|
||||
// so there is no separate save step to forget: the
|
||||
// server rescans as part of the same call.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newRoot,
|
||||
onValueChange = { newRoot = it },
|
||||
label = { Text("Add a directory") },
|
||||
placeholder = { Text("~/repos") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
RowGlyphButton(
|
||||
glyph = PLUS_GLYPH,
|
||||
label = "Scan this directory",
|
||||
enabled = !busy && newRoot.isNotBlank(),
|
||||
tone = ActionTone.Go,
|
||||
onClick = {
|
||||
val added = newRoot.trim()
|
||||
run(onSuccess = { newRoot = "" }) { setRepoRoots(roots + added) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Its own row, outside the scrolling list, so the way out is in
|
||||
// the same place however far down somebody has got -- a long
|
||||
// list must never put it below the fold.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Button(onClick = onBack) { Text("Done") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The control at the end of one of this screen's rows: a plus, or a trash can.
|
||||
*
|
||||
* All of them are the same width, so every text field beside them ends at the same place -- rows of
|
||||
* fields that stop at different points read as accidental, and these three are the same kind of
|
||||
* row.
|
||||
*/
|
||||
@Composable
|
||||
private fun RowGlyphButton(
|
||||
glyph: String,
|
||||
label: String,
|
||||
enabled: Boolean,
|
||||
tone: ActionTone,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
colors = tone.colors(),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.width(ROW_CONTROL_WIDTH).semantics { contentDescription = label },
|
||||
) {
|
||||
Text(glyph, fontFamily = NerdIcons, fontSize = 22.sp)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by every trailing control here, so the fields all end level. */
|
||||
private val ROW_CONTROL_WIDTH = 56.dp
|
||||
|
||||
@Composable
|
||||
private fun SuggestionCard(project: ProjectSuggestion, busy: Boolean, onAdd: () -> Unit) {
|
||||
// Darker than the sheet it sits on, and outlined -- the same step the
|
||||
// component cards make inside a project card. Filled, it was a shade
|
||||
// off the surface behind it and read as part of it.
|
||||
OutlinedCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
// The button is placed over the card rather than in a row with the
|
||||
// build count. A Button is a good deal taller than the line beside
|
||||
// it, so sharing a row made that row the button's height and left
|
||||
// the count floating in the middle of it -- a card offering Add
|
||||
// then didn't line up with the one above it saying "Already
|
||||
// added". Out of the column's flow, the card reads the same
|
||||
// whatever is in its corner.
|
||||
Box(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Column {
|
||||
Text(project.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
project.path,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
// Zero happens for a project that declares itself in a
|
||||
// .dev-updater.ron without having been built yet --
|
||||
// usually one whose build step is what produces the
|
||||
// first APK.
|
||||
when (project.apkCount) {
|
||||
0 -> "Not built yet"
|
||||
1 -> "1 build"
|
||||
else -> "${project.apkCount} builds"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Button(
|
||||
enabled = !busy,
|
||||
onClick = onAdd,
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
) {
|
||||
Text("Add")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
|
||||
/**
|
||||
* Renders a log's ANSI escape sequences instead of showing them.
|
||||
*
|
||||
* Anything that writes to a terminal writes colour, and a service's output is a file only because
|
||||
* something redirected it -- so the escapes arrive here whether or not anybody wanted them. Drawn
|
||||
* rather than deleted, because the colour is information: it is how the process itself marked which
|
||||
* lines are errors, and dropping it throws that away at the moment somebody is reading the log to
|
||||
* find exactly those lines.
|
||||
*
|
||||
* The escapes are understood here rather than removed at the build machine because this is the end
|
||||
* that knows what a colour should look like. The wire stays plain text, and [AnnotatedString.text]
|
||||
* is the log without any of it -- which is what the Copy button puts on the clipboard, since escape
|
||||
* codes are not what anyone wants to paste.
|
||||
*
|
||||
* **Sequences this does not understand are dropped, never printed.** Cursor movement, erase-line
|
||||
* and the rest are meaningless without a terminal to act on, and leaving them in as text would be
|
||||
* worse than the colours were: they would look like corruption in the log rather than like
|
||||
* something the viewer chose not to do. So every escape is consumed; only the ones below have an
|
||||
* effect.
|
||||
*/
|
||||
fun ansiAnnotated(text: String, base: Color): AnnotatedString {
|
||||
val runs = parse(text, base)
|
||||
return buildAnnotatedString {
|
||||
for (run in runs) {
|
||||
withStyle(run.style) { append(run.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val ESC = ''
|
||||
|
||||
/** The final byte of a CSI sequence is in this range; everything before it is parameters. */
|
||||
private val CSI_END = '@'..'~'
|
||||
|
||||
private class Run(val text: String, val style: SpanStyle)
|
||||
|
||||
/**
|
||||
* The state a terminal carries between escapes.
|
||||
*
|
||||
* Held as fields rather than as a [SpanStyle] so that "no colour set" stays distinguishable from
|
||||
* "the colour happens to be the default one" -- 39 (default foreground) has to be able to undo a
|
||||
* previous 31 without knowing what 31 was.
|
||||
*/
|
||||
private data class Sgr(
|
||||
val bold: Boolean = false,
|
||||
val dim: Boolean = false,
|
||||
val italic: Boolean = false,
|
||||
val underline: Boolean = false,
|
||||
val fg: Color? = null,
|
||||
val bg: Color? = null,
|
||||
) {
|
||||
/**
|
||||
* Dim has no weight of its own in Compose, so it is drawn as reduced opacity on whatever the
|
||||
* colour would otherwise be -- including the body colour, which is why [base] is needed here
|
||||
* rather than left to the caller.
|
||||
*/
|
||||
fun style(base: Color): SpanStyle {
|
||||
val colour = fg ?: base
|
||||
return SpanStyle(
|
||||
color = if (dim) colour.copy(alpha = DIM_ALPHA) else colour,
|
||||
background = bg ?: Color.Unspecified,
|
||||
fontWeight = if (bold) FontWeight.Bold else null,
|
||||
fontStyle = if (italic) FontStyle.Italic else null,
|
||||
textDecoration = if (underline) TextDecoration.Underline else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Enough to read as quieter than the text beside it, while staying legible. */
|
||||
private const val DIM_ALPHA = 0.65f
|
||||
|
||||
private fun parse(text: String, base: Color): List<Run> {
|
||||
val runs = mutableListOf<Run>()
|
||||
val pending = StringBuilder()
|
||||
var sgr = Sgr()
|
||||
var index = 0
|
||||
|
||||
fun flush() {
|
||||
if (pending.isNotEmpty()) {
|
||||
runs.add(Run(pending.toString(), sgr.style(base)))
|
||||
pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
while (index < text.length) {
|
||||
val char = text[index]
|
||||
if (char != ESC) {
|
||||
pending.append(char)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
val next = text.getOrNull(index + 1)
|
||||
when (next) {
|
||||
'[' -> {
|
||||
var end = index + 2
|
||||
while (end < text.length && text[end] !in CSI_END) end++
|
||||
if (end >= text.length) {
|
||||
// Cut off mid-sequence, which is what reading the tail
|
||||
// of a file does to whatever the first line was. There
|
||||
// is nothing after it to draw, so there is nothing to
|
||||
// decide.
|
||||
index = text.length
|
||||
} else {
|
||||
if (text[end] == 'm') {
|
||||
flush()
|
||||
sgr = sgr.apply(text.substring(index + 2, end))
|
||||
}
|
||||
index = end + 1
|
||||
}
|
||||
}
|
||||
// OSC: runs until BEL or the two-character string terminator.
|
||||
// Consumed whole, since its payload is a window title or a
|
||||
// hyperlink target rather than anything to show.
|
||||
']' -> {
|
||||
var end = index + 2
|
||||
while (end < text.length && text[end] != '') {
|
||||
if (text[end] == ESC && text.getOrNull(end + 1) == '\\') break
|
||||
end++
|
||||
}
|
||||
index = if (end >= text.length) text.length else end + 1
|
||||
}
|
||||
null -> index = text.length
|
||||
// A two-character escape. Nothing here acts on one, so it goes.
|
||||
else -> index += 2
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return runs
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one SGR sequence's parameters.
|
||||
*
|
||||
* An empty parameter list means 0 (reset), which is what a bare `ESC[m` is; a code this does not
|
||||
* know is skipped without disturbing the rest, so one unrecognised attribute cannot take the
|
||||
* colours with it.
|
||||
*/
|
||||
private fun Sgr.apply(parameters: String): Sgr {
|
||||
val codes = parameters.split(';').map { it.trim().toIntOrNull() ?: 0 }
|
||||
var state = this
|
||||
var index = 0
|
||||
while (index < codes.size) {
|
||||
when (val code = codes[index]) {
|
||||
0 -> state = Sgr()
|
||||
1 -> state = state.copy(bold = true)
|
||||
2 -> state = state.copy(dim = true)
|
||||
3 -> state = state.copy(italic = true)
|
||||
4 -> state = state.copy(underline = true)
|
||||
22 -> state = state.copy(bold = false, dim = false)
|
||||
23 -> state = state.copy(italic = false)
|
||||
24 -> state = state.copy(underline = false)
|
||||
39 -> state = state.copy(fg = null)
|
||||
49 -> state = state.copy(bg = null)
|
||||
in 30..37 -> state = state.copy(fg = AnsiColors[code - 30])
|
||||
in 90..97 -> state = state.copy(fg = AnsiColors[code - 90 + 8])
|
||||
in 40..47 -> state = state.copy(bg = AnsiColors[code - 40])
|
||||
in 100..107 -> state = state.copy(bg = AnsiColors[code - 100 + 8])
|
||||
38,
|
||||
48 -> {
|
||||
val extended = extendedColour(codes, index)
|
||||
if (extended == null) {
|
||||
// Malformed: the rest of this sequence cannot be
|
||||
// trusted to be parameters, so stop reading it rather
|
||||
// than treat a colour component as a code of its own.
|
||||
return state
|
||||
}
|
||||
state =
|
||||
if (code == 38) state.copy(fg = extended.first)
|
||||
else state.copy(bg = extended.first)
|
||||
index = extended.second
|
||||
}
|
||||
else -> {} // Not understood, and so not applied.
|
||||
}
|
||||
index++
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a `5;n` (256-colour) or `2;r;g;b` (24-bit) argument that follows a 38 or 48.
|
||||
*
|
||||
* Returns the colour and the index of its last parameter, or null if the sequence is too short to
|
||||
* be either.
|
||||
*/
|
||||
private fun extendedColour(codes: List<Int>, at: Int): Pair<Color, Int>? =
|
||||
when (codes.getOrNull(at + 1)) {
|
||||
5 -> codes.getOrNull(at + 2)?.let { Pair(paletteColour(it), at + 2) }
|
||||
2 -> {
|
||||
val red = codes.getOrNull(at + 2)
|
||||
val green = codes.getOrNull(at + 3)
|
||||
val blue = codes.getOrNull(at + 4)
|
||||
if (red == null || green == null || blue == null) null
|
||||
else
|
||||
Pair(
|
||||
Color(red.coerceIn(0, 255), green.coerceIn(0, 255), blue.coerceIn(0, 255)),
|
||||
at + 4,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* One of the 256 palette colours.
|
||||
*
|
||||
* The first sixteen are the named ones, and so come from [AnsiColors] for the same reason those do.
|
||||
* The rest are defined by the standard as arithmetic -- a 6x6x6 cube and a 24-step grey ramp -- so
|
||||
* they are computed rather than mapped: they are already exact values rather than names, and there
|
||||
* is nothing to translate.
|
||||
*/
|
||||
private fun paletteColour(index: Int): Color =
|
||||
when (index) {
|
||||
in 0..15 -> AnsiColors[index]
|
||||
in 16..231 -> {
|
||||
val offset = index - 16
|
||||
val steps = intArrayOf(0, 95, 135, 175, 215, 255)
|
||||
Color(steps[offset / 36], steps[(offset / 6) % 6], steps[offset % 6])
|
||||
}
|
||||
in 232..255 -> {
|
||||
val grey = 8 + (index - 232) * 10
|
||||
Color(grey, grey, grey)
|
||||
}
|
||||
else -> AnsiColors[7]
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
import java.net.URLEncoder
|
||||
|
||||
private const val DOWNLOAD_READ_TIMEOUT_MS = 15000
|
||||
|
||||
// Downloads into this app's own private storage (`filesDir/apks/`, never
|
||||
// the shared Downloads directory) so there's nothing left behind to clean
|
||||
// up by hand -- each key's file is simply overwritten in place on the next
|
||||
// update, and a partial download (`.part`) never gets handed to the
|
||||
// installer.
|
||||
//
|
||||
// Never how a *first* install of this app happens: that goes over the
|
||||
// server's separate plain-HTTP bootstrap port, which is the one thing this
|
||||
// app's own code never talks to.
|
||||
fun downloadApk(
|
||||
context: Context,
|
||||
entry: ManifestEntry,
|
||||
onProgress: (bytesRead: Long, total: Long) -> Unit,
|
||||
): File {
|
||||
// The chosen build travels with the request rather than being
|
||||
// stored on the server: it is this device's preference, and a
|
||||
// second phone must not have its download changed by it. The
|
||||
// server checks the path against the builds it can see, so a stale
|
||||
// one falls back to the newest rather than naming a file.
|
||||
val route =
|
||||
when (val variant = chosenVariant(context, entry.key)) {
|
||||
null -> entry.route
|
||||
else -> "${entry.route}?variant=${URLEncoder.encode(variant, "UTF-8")}"
|
||||
}
|
||||
return downloadFromRoute(context, route, entry.key, onProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one APK from [route] into private storage, named [name], and returns the file.
|
||||
*
|
||||
* Split from [downloadApk] because the app's own rescue path
|
||||
* ([com.example.devupdater.SELF_APK_ROUTE]) must not need a [ManifestEntry] to reach it -- the
|
||||
* manifest is exactly what an app too old to read it cannot use. One implementation so both get the
|
||||
* same partial-file handling and the same progress reporting.
|
||||
*/
|
||||
fun downloadFromRoute(
|
||||
context: Context,
|
||||
route: String,
|
||||
name: String,
|
||||
onProgress: (bytesRead: Long, total: Long) -> Unit,
|
||||
): File {
|
||||
val dir = File(context.filesDir, "apks").apply { mkdirs() }
|
||||
val dest = File(dir, "$name.apk")
|
||||
val tmp = File(dir, "$name.apk.part")
|
||||
|
||||
try {
|
||||
requestFromServer(route, readTimeoutMs = DOWNLOAD_READ_TIMEOUT_MS) { connection ->
|
||||
val total = connection.contentLengthLong
|
||||
connection.inputStream.use { input ->
|
||||
tmp.outputStream().use { output ->
|
||||
val buffer = ByteArray(65536)
|
||||
var readTotal = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
readTotal += read
|
||||
onProgress(readTotal, total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: DownloadServerException) {
|
||||
// The half-written file is worthless and would otherwise sit in
|
||||
// private storage until the next successful download overwrote it.
|
||||
tmp.delete()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (!tmp.renameTo(dest)) {
|
||||
tmp.copyTo(dest, overwrite = true)
|
||||
tmp.delete()
|
||||
}
|
||||
return dest
|
||||
}
|
||||
|
||||
// Android 8+ requires this app to hold "install unknown apps" for itself
|
||||
// specifically before the install intent below will do anything but bounce
|
||||
// back to a settings prompt -- checked explicitly up front so the caller
|
||||
// can send the user straight to that settings screen with a clear reason,
|
||||
// instead of a silent no-op tap.
|
||||
/**
|
||||
* Asks the system to remove [packageName], which shows its own confirmation dialog before anything
|
||||
* happens.
|
||||
*
|
||||
* ACTION_DELETE rather than PackageInstaller.uninstall(): it needs no permission at all, where the
|
||||
* newer call wants REQUEST_DELETE_PACKAGES to put up the same dialog. Removing someone's app is not
|
||||
* a thing to do quietly on their behalf, so the dialog is the point rather than a limitation being
|
||||
* worked around.
|
||||
*/
|
||||
fun uninstallIntent(packageName: String): Intent =
|
||||
Intent(Intent.ACTION_DELETE, Uri.parse("package:$packageName"))
|
||||
|
||||
fun canRequestInstall(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls()
|
||||
|
||||
fun requestInstallPermissionIntent(context: Context): Intent =
|
||||
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
|
||||
|
||||
fun installApkIntent(context: Context, apkFile: File): Intent {
|
||||
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
// The routes behind the Add screen -- everything that *changes* which apps
|
||||
// this server serves, as opposed to reading the current list (see
|
||||
// UpdateManifest.kt) or downloading one (ApkInstaller.kt).
|
||||
//
|
||||
// All blocking network calls -- invoke from a background dispatcher. Each
|
||||
// throws DownloadServerException on failure, carrying the server's own
|
||||
// explanation ("no built APK found under ...", "already added as ...")
|
||||
// rather than a status code, since that message is written to be read here
|
||||
// and this screen is usually the only place it can be seen.
|
||||
|
||||
// A project the server found under one of the configured repo roots and
|
||||
// can offer to add: one with a build in it, or one declaring itself in a
|
||||
// .dev-updater.ron, which can be added before its first build.
|
||||
data class ProjectSuggestion(
|
||||
val path: String,
|
||||
// The project directory's own name. The real label is read out of the
|
||||
// APK when it's actually added, which is why it can differ from what
|
||||
// the card ends up showing.
|
||||
val name: String,
|
||||
val apkCount: Int,
|
||||
val mtime: Double,
|
||||
// Already in the app list -- shown, but not offered again.
|
||||
val added: Boolean,
|
||||
)
|
||||
|
||||
data class Suggestions(
|
||||
val roots: List<String>,
|
||||
val projects: List<ProjectSuggestion>,
|
||||
)
|
||||
|
||||
fun fetchSuggestions(): Suggestions =
|
||||
requestFromServer("/suggestions") { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val roots = body.getJSONArray("roots")
|
||||
val found = body.getJSONArray("suggestions")
|
||||
Suggestions(
|
||||
roots = (0 until roots.length()).map { roots.getString(it) },
|
||||
projects =
|
||||
(0 until found.length()).map { i ->
|
||||
val item = found.getJSONObject(i)
|
||||
ProjectSuggestion(
|
||||
path = item.getString("path"),
|
||||
name = item.getString("name"),
|
||||
apkCount = item.getInt("apkCount"),
|
||||
mtime = item.getDouble("mtime"),
|
||||
added = item.getBoolean("added"),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds the project at [path], answering with the key it was given. */
|
||||
fun addApp(path: String): String =
|
||||
requestFromServer("/apps", method = "POST", jsonBody = jsonOf("path" to path)) { connection ->
|
||||
JSONObject(connection.inputStream.bufferedReader().readText()).getString("key")
|
||||
}
|
||||
|
||||
fun removeApp(key: String) {
|
||||
requestFromServer("/apps/$key", method = "DELETE") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts the build step [key]'s project asks for in its own `.dev-updater.ron`, after it has been
|
||||
* shown on the card.
|
||||
*
|
||||
* Carries no body: the server re-reads the project's file and stores what it finds there, so this
|
||||
* can only ever accept what the project actually asks for and never a command composed here.
|
||||
*/
|
||||
fun approveDeclaration(key: String) {
|
||||
requestFromServer("/apps/$key/approve", method = "POST") {}
|
||||
}
|
||||
|
||||
/** Replaces the directories the server scans for suggestions. */
|
||||
fun setRepoRoots(roots: List<String>) {
|
||||
val body = JSONObject().put("roots", JSONArray(roots)).toString()
|
||||
requestFromServer("/roots", method = "PUT", jsonBody = body) {}
|
||||
}
|
||||
|
||||
// Built through JSONObject rather than string interpolation so a path
|
||||
// containing a quote or backslash can't produce a malformed request.
|
||||
private fun jsonOf(vararg pairs: Pair<String, Any>): String =
|
||||
JSONObject().apply { pairs.forEach { (key, value) -> put(key, value) } }.toString()
|
||||
|
||||
/**
|
||||
* Asks a server component's script to do something, on the *build machine* -- these are the one set
|
||||
* of buttons on this screen that don't act on this phone.
|
||||
*
|
||||
* Answers with the state the component ended up in, so the card can show the result of what was
|
||||
* just done rather than waiting for the next background check to notice.
|
||||
*/
|
||||
fun serviceAction(
|
||||
key: String,
|
||||
component: String,
|
||||
action: String,
|
||||
purge: Purge = Purge(),
|
||||
): ServiceActionResult {
|
||||
val query =
|
||||
if (purge.nothing) "" else "?logs=${purge.logs}&data=${purge.data}&config=${purge.config}"
|
||||
return requestFromServer("/apps/$key/components/$component/$action$query", "POST") { connection
|
||||
->
|
||||
val answer = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val left = answer.optJSONArray("leftBehind")
|
||||
ServiceActionResult(
|
||||
state = answer.getString("state"),
|
||||
leftBehind = (0 until (left?.length() ?: 0)).map { left!!.getString(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What Uninstall should take away besides the service itself.
|
||||
*
|
||||
* All three default to off here, so a caller that says nothing gets what uninstalling has always
|
||||
* done. The dialog's own defaults are its business, and they are not these -- logs start ticked
|
||||
* there, because a record of what already happened is the cheap one to lose.
|
||||
*/
|
||||
data class Purge(
|
||||
val logs: Boolean = false,
|
||||
val data: Boolean = false,
|
||||
val config: Boolean = false,
|
||||
) {
|
||||
val nothing: Boolean
|
||||
get() = !logs && !data && !config
|
||||
}
|
||||
|
||||
/**
|
||||
* What a service action ended in.
|
||||
*
|
||||
* [leftBehind] is what Uninstall was asked to remove and could not, one line each, and it is empty
|
||||
* for every other action. Not an error: the service is gone by then, so the request succeeded --
|
||||
* what is reported is that something is still on disk, which is the thing the person would
|
||||
* otherwise have to go to the build machine to find out.
|
||||
*/
|
||||
data class ServiceActionResult(val state: String, val leftBehind: List<String>)
|
||||
|
||||
/**
|
||||
* Ask the server to check this one project's remote and services again.
|
||||
*
|
||||
* Returns as soon as the checks are started, not when they answer -- they run off the request path
|
||||
* on the server, exactly as the ones a manifest fetch starts do, so the answer arrives through a
|
||||
* later read of the card ([fetchApp]) or of the whole list.
|
||||
*/
|
||||
fun recheckApp(key: String) {
|
||||
requestFromServer("/apps/$key/recheck", method = "POST") {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces this machine's preferences for one project.
|
||||
*
|
||||
* The whole set every time, not one field: the screen that sends this has just shown every setting
|
||||
* there is, so it knows the complete answer, and a partial update would need a rule for what a
|
||||
* missing field means.
|
||||
*/
|
||||
fun setAppSettings(key: String, gitIpv4: Boolean) {
|
||||
requestFromServer(
|
||||
"/apps/$key/settings",
|
||||
method = "PUT",
|
||||
jsonBody = jsonOf("gitIpv4" to gitIpv4),
|
||||
) {}
|
||||
}
|
||||
|
||||
/** One component's log, as the modal shows it. */
|
||||
/**
|
||||
* Which of a component's two logs to read.
|
||||
*
|
||||
* Two kinds rather than one list. They answer different questions -- [Build] is what the build
|
||||
* machine captured while building this component, [Runtime] is what the component itself wrote
|
||||
* while running -- and a single list indexed by generation could only ever reach the first of them.
|
||||
*/
|
||||
enum class LogKind(val wire: String) {
|
||||
Build("build"),
|
||||
Runtime("runtime"),
|
||||
}
|
||||
|
||||
data class ComponentLog(
|
||||
/** Where it came from, so somebody at the build machine can open the whole file. */
|
||||
val path: String,
|
||||
val text: String,
|
||||
/**
|
||||
* There is more than this. Said rather than implied: a silently shortened log reads as a
|
||||
* complete one that simply fails to explain the crash.
|
||||
*/
|
||||
val truncated: Boolean,
|
||||
/** How many generations exist, so the modal knows whether to offer a previous one at all. */
|
||||
val generations: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* The tail of one component's log, from the build machine.
|
||||
*
|
||||
* [lines] counts back from the end, which is where anything interesting is; 0 asks for as much as
|
||||
* the server is willing to read, and it says so through [ComponentLog.truncated] when that bites.
|
||||
* [generation] is 0 for the current run and 1 for the one before it -- an index rather than a
|
||||
* "previous" flag, because the script reports a list and this should not assume its length. It
|
||||
* counts within a [kind]: the build log's previous generation is not the runtime log's, which is
|
||||
* why switching kinds starts again at the current one.
|
||||
*
|
||||
* Given the manifest's timeout rather than the default: a large log is a real request that takes
|
||||
* real time, and timing it out would report the server as unreachable when it is merely reading.
|
||||
*/
|
||||
fun componentLog(
|
||||
key: String,
|
||||
component: String,
|
||||
lines: Int,
|
||||
generation: Int,
|
||||
kind: LogKind,
|
||||
): ComponentLog =
|
||||
requestFromServer(
|
||||
"/apps/$key/components/$component/logs" +
|
||||
"?lines=$lines&generation=$generation&kind=${kind.wire}",
|
||||
readTimeoutMs = MANIFEST_READ_TIMEOUT_MS,
|
||||
) { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
ComponentLog(
|
||||
path = body.getString("path"),
|
||||
text = body.getString("text"),
|
||||
truncated = body.optBoolean("truncated", false),
|
||||
generations = body.optInt("generations", 1),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The rescue contract.
|
||||
//
|
||||
// These two routes are how this app replaces itself, and the server treats
|
||||
// their shape as frozen -- see routes.rs. Everything else in this file
|
||||
// reads the manifest, which is the thing that changes; an app too old to
|
||||
// parse a new manifest cannot reach the button that would replace it, and
|
||||
// the only way back from that is a reinstall over the plain-HTTP bootstrap
|
||||
// port, by hand, at the machine.
|
||||
//
|
||||
// So nothing here may grow a dependency on the manifest, on a variant, or
|
||||
// on any field that might be added later. Two numbers and some bytes.
|
||||
|
||||
/** Where the app's own build lives. A constant, because the whole point is that it never moves. */
|
||||
const val SELF_APK_ROUTE = "/self/apk"
|
||||
|
||||
/** What the build machine has of this app: when it was built, and how big it is. */
|
||||
data class SelfBuild(val mtimeMillis: Long, val sizeBytes: Long)
|
||||
|
||||
/**
|
||||
* Asks whether the build machine has a copy of this app, and how new it is.
|
||||
*
|
||||
* Compared against `PackageInfo.lastUpdateTime` by the caller, which is the same freshness rule the
|
||||
* list uses -- these are ad hoc rebuilds with nothing bumping a version code, so the timestamp is
|
||||
* all there is.
|
||||
*/
|
||||
fun selfBuild(): SelfBuild =
|
||||
requestFromServer("/self") { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
SelfBuild(
|
||||
// Seconds on the wire, because that is what the server's other
|
||||
// timestamps use; milliseconds here, because that is what
|
||||
// Android reports for an installed package.
|
||||
mtimeMillis = (body.getDouble("mtime") * 1000).toLong(),
|
||||
sizeBytes = body.getLong("size"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
// The three routes that act on the *build machine* rather than this
|
||||
// phone, all answering the same shape:
|
||||
//
|
||||
// POST /apps/{key}/pull fetch, fast-forward the branch, then build
|
||||
// POST /apps/{key}/prepare build if the configured staleness rule says so
|
||||
// POST /apps/{key}/build build whether or not anything looks stale
|
||||
// GET /apps/{key}/status poll either of the above
|
||||
//
|
||||
// Only an entry with a configured build step has them; the others answer
|
||||
// 404, which is why the screen offers them only when the manifest says so
|
||||
// (needsBuild / canPull).
|
||||
data class BuildStatus(
|
||||
val stale: Boolean,
|
||||
val building: Boolean,
|
||||
val error: String?,
|
||||
// What the whole *project* is doing ("fetching", "pulling"), and how
|
||||
// long this run has taken. Work belonging to one component is in
|
||||
// [components] instead, because that is where it is drawn.
|
||||
val phase: String?,
|
||||
val elapsedMs: Long,
|
||||
val components: List<ComponentBuild>,
|
||||
) {
|
||||
/** This component's part of the run, if it has reached it yet. */
|
||||
fun component(name: String): ComponentBuild? = components.firstOrNull { it.name == name }
|
||||
}
|
||||
|
||||
/**
|
||||
* One component's part of a build.
|
||||
*
|
||||
* Drawn inside that component's own row rather than under the project, because a bar under the
|
||||
* whole card could only say that *something* was happening — and with every component building at
|
||||
* once, that is exactly the question the reader has.
|
||||
*/
|
||||
data class ComponentBuild(
|
||||
val name: String,
|
||||
// What it is doing now ("building", "installing", "restarting"), or
|
||||
// null once it has finished.
|
||||
val step: String?,
|
||||
val elapsedMs: Long,
|
||||
val progress: BuildProgressCount?,
|
||||
val log: List<String>,
|
||||
val error: String?,
|
||||
) {
|
||||
/** The last thing it printed, if anything yet. */
|
||||
fun lastLine(): String? = log.lastOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
val running: Boolean
|
||||
get() = step != null
|
||||
}
|
||||
|
||||
data class BuildProgressCount(val done: Long, val total: Long)
|
||||
|
||||
// Blocking network calls -- invoke from a background dispatcher. The server
|
||||
// answers both routes immediately either way -- a rebuild it kicks off runs
|
||||
// in a background thread there, not inline with the request -- so neither
|
||||
// needs the multi-minute read timeout an actual build would.
|
||||
private fun requestBuildStatus(path: String, method: String): BuildStatus =
|
||||
requestFromServer(path, method) { connection ->
|
||||
val json = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val components = json.optJSONArray("components")
|
||||
BuildStatus(
|
||||
stale = json.getBoolean("stale"),
|
||||
building = json.getBoolean("building"),
|
||||
error = if (json.isNull("error")) null else json.getString("error"),
|
||||
phase = if (json.isNull("phase")) null else json.optString("phase").ifEmpty { null },
|
||||
elapsedMs = json.optLong("elapsedMs", 0),
|
||||
components =
|
||||
(0 until (components?.length() ?: 0)).map { index ->
|
||||
val component = components!!.getJSONObject(index)
|
||||
val log = component.optJSONArray("log")
|
||||
ComponentBuild(
|
||||
name = component.getString("name"),
|
||||
step =
|
||||
if (component.isNull("step")) null
|
||||
else component.optString("step").ifEmpty { null },
|
||||
elapsedMs = component.optLong("elapsedMs", 0),
|
||||
progress =
|
||||
component.optJSONObject("progress")?.let {
|
||||
BuildProgressCount(
|
||||
done = it.getLong("done"),
|
||||
total = it.getLong("total"),
|
||||
)
|
||||
},
|
||||
log = (0 until (log?.length() ?: 0)).map { line -> log!!.getString(line) },
|
||||
error =
|
||||
if (component.isNull("error")) null else component.getString("error"),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun pullAndBuild(key: String): BuildStatus = requestBuildStatus("/apps/$key/pull", "POST")
|
||||
|
||||
fun prepareBuild(key: String): BuildStatus = requestBuildStatus("/apps/$key/prepare", "POST")
|
||||
|
||||
/**
|
||||
* Builds because the person asked, not because anything looked stale.
|
||||
*
|
||||
* The staleness rules keep a download from rebuilding the world; they have no business overruling a
|
||||
* button. This is also the only way a project already current with its checkout ever records what
|
||||
* it was built from, which is what the "out of date" signal is compared against.
|
||||
*/
|
||||
fun buildNow(key: String): BuildStatus = requestBuildStatus("/apps/$key/build", "POST")
|
||||
|
||||
fun buildStatus(key: String): BuildStatus = requestBuildStatus("/apps/$key/status", "GET")
|
||||
@@ -0,0 +1,363 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** What the line field means when it is empty or zero: as much as the server will read. */
|
||||
private const val ALL_LINES = 0
|
||||
|
||||
/** Enough to carry a stack trace and its cause, without being a file viewer. */
|
||||
private const val DEFAULT_LINES = 100
|
||||
|
||||
/** Enough to hold a stack trace without being the whole screen. */
|
||||
private val LOG_HEIGHT = 360.dp
|
||||
|
||||
/**
|
||||
* The foot has four things in it -- a field, a generation toggle and two buttons -- and a dialog is
|
||||
* narrow. Trimmed padding rather than smaller text, which would make these labels a different size
|
||||
* from every other button in the app for a reason the reader cannot see.
|
||||
*/
|
||||
private val FOOT_BUTTON_PADDING = PaddingValues(horizontal = 8.dp)
|
||||
|
||||
/** Wide enough for four digits, which is more lines than anybody asks for by hand. */
|
||||
private val LINES_FIELD_WIDTH = 76.dp
|
||||
|
||||
/**
|
||||
* The log's own panel, a step *down* the surface ladder rather than up.
|
||||
*
|
||||
* Everything else in the app nests by getting lighter -- page at Base, a project's card at Surface
|
||||
* 0 -- but a log is not another card. It is a slab of somebody else's output quoted inside this
|
||||
* app, and the thing that says so in one glance is the darkness a terminal has. Crust
|
||||
* (`surfaceContainerLowest`) against the dialog's `surfaceContainerHigh` is the widest step this
|
||||
* palette offers, which is what makes the boundary legible without a border drawing it.
|
||||
*
|
||||
* It also keeps [AnsiColors] doing what it was chosen for: those are Mocha accents picked to sit
|
||||
* against a dark Mocha surface, so a log's own colours land on the background they were matched to.
|
||||
*/
|
||||
private val LOG_PANEL_SHAPE = RoundedCornerShape(4.dp)
|
||||
|
||||
/** Keeps the first column off the panel's edge, at both ends of a horizontal scroll. */
|
||||
private val LOG_PANEL_PADDING = 8.dp
|
||||
|
||||
/**
|
||||
* One component's log, in full rather than as a snippet on the card.
|
||||
*
|
||||
* A modal because a log is something you go and read, not something a card should carry: a crash is
|
||||
* usually a stack trace, which is unreadable in the three lines a card could spare and would push
|
||||
* everything else off screen. The card says *that* it failed; this says what.
|
||||
*
|
||||
* Reachable whenever a component reports logs, not only after a failure — a running service's log
|
||||
* is the thing you want while working out why it is behaving oddly, which is exactly when nothing
|
||||
* has failed yet.
|
||||
*/
|
||||
@Composable
|
||||
fun ComponentLogDialog(
|
||||
entryKey: String,
|
||||
component: ProjectComponent,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var lines by remember { mutableStateOf(DEFAULT_LINES.toString()) }
|
||||
// What is actually being asked for, which is not what is being typed.
|
||||
// Re-reading on every keystroke means "20" fetches 2 lines on the way
|
||||
// to 20, and a slow read is started and abandoned for each digit --
|
||||
// so the field is applied when it is submitted or left, not as it
|
||||
// changes.
|
||||
var requestedLines by remember { mutableStateOf(DEFAULT_LINES) }
|
||||
val submitLines = { requestedLines = lines.toIntOrNull() ?: ALL_LINES }
|
||||
var generation by remember { mutableStateOf(0) }
|
||||
// Only a server runs here, so only a server can have a runtime log to
|
||||
// switch to. An APK gets no tab row rather than a row of one, which
|
||||
// would be a control that cannot do anything.
|
||||
val hasBothKinds = component.kind == "server"
|
||||
// What it is doing now is the usual question, so the runtime log is
|
||||
// the default -- except for the component a build stopped at, where
|
||||
// the thing worth reading is why it stopped, and for an APK, which
|
||||
// has no runtime here and so no tab to escape to.
|
||||
var kind by remember {
|
||||
mutableStateOf(
|
||||
if (!hasBothKinds || component.buildFailed) LogKind.Build else LogKind.Runtime
|
||||
)
|
||||
}
|
||||
var log by remember { mutableStateOf<ComponentLog?>(null) }
|
||||
// Rendered once per read rather than per recomposition, and kept out
|
||||
// here so Copy can reach it: what goes on the clipboard is
|
||||
// AnnotatedString's own plain text, which is the log with every escape
|
||||
// already gone.
|
||||
val bodyColour = MaterialTheme.colorScheme.onSurface
|
||||
val rendered = remember(log, bodyColour) { log?.let { ansiAnnotated(it.text, bodyColour) } }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
val available =
|
||||
when (kind) {
|
||||
LogKind.Build -> component.hasBuildLogs
|
||||
LogKind.Runtime -> component.hasRuntimeLogs
|
||||
}
|
||||
|
||||
// Re-read whenever any control moves. Keyed rather than driven by a
|
||||
// callback so the two cannot disagree about what is on screen.
|
||||
LaunchedEffect(entryKey, component.name, requestedLines, generation, kind) {
|
||||
failure = null
|
||||
// Nothing to fetch, and asking anyway would come back as a failure
|
||||
// in red -- which is the wrong thing to say about a log that
|
||||
// simply does not exist. The body explains it instead.
|
||||
if (!available) {
|
||||
log = null
|
||||
loading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
log =
|
||||
withContext(Dispatchers.IO) {
|
||||
componentLog(
|
||||
entryKey,
|
||||
component.name,
|
||||
requestedLines,
|
||||
generation,
|
||||
kind,
|
||||
)
|
||||
}
|
||||
} catch (e: DownloadServerException) {
|
||||
log = null
|
||||
failure = e.message ?: "Couldn't read the log"
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("${component.name} · log") },
|
||||
text = {
|
||||
Column {
|
||||
if (hasBothKinds) {
|
||||
// Primary rather than the plain `TabRow`, which is
|
||||
// deprecated in favour of the two that say where they
|
||||
// sit -- these are this dialog's top-level
|
||||
// destinations. Matches what ai-app uses, so a tab row
|
||||
// means the same thing in both apps.
|
||||
//
|
||||
// Transparent, because a tab row is not a surface of
|
||||
// its own: the default paints `surface` behind the
|
||||
// tabs, which inside a dialog at
|
||||
// `surfaceContainerHigh` reads as a band of different
|
||||
// background under the controls, saying a change of
|
||||
// level that isn't there. The indicator and the label
|
||||
// carry the selection; the background has no part in
|
||||
// it.
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = if (kind == LogKind.Build) 0 else 1,
|
||||
containerColor = Color.Transparent,
|
||||
) {
|
||||
// A generation is counted within a kind, so the
|
||||
// build log's previous run is not the runtime
|
||||
// log's. Switching kinds therefore starts again at
|
||||
// the current one rather than carrying an index
|
||||
// across to where it means something else.
|
||||
Tab(
|
||||
selected = kind == LogKind.Build,
|
||||
onClick = {
|
||||
kind = LogKind.Build
|
||||
generation = 0
|
||||
},
|
||||
text = { Text("Build") },
|
||||
)
|
||||
Tab(
|
||||
selected = kind == LogKind.Runtime,
|
||||
onClick = {
|
||||
kind = LogKind.Runtime
|
||||
generation = 0
|
||||
},
|
||||
text = { Text("Runtime") },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Indeterminate, because the server does not report how
|
||||
// far through a read it is -- and a bar drawn from a
|
||||
// guess is worse than one that only spins.
|
||||
if (loading) {
|
||||
ProgressBar()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
failure?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
// Not an error, and not drawn like one: there is simply no
|
||||
// such log. Which of the two reasons it is stays unsaid
|
||||
// because the build machine cannot tell them apart either
|
||||
// -- a script with no log yet and a script that does not
|
||||
// offer them both answer by reporting nothing.
|
||||
if (!available) {
|
||||
Text(
|
||||
when (kind) {
|
||||
LogKind.Build ->
|
||||
"No build log yet — this component hasn't been built from here."
|
||||
LogKind.Runtime -> "This component reports no runtime log."
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
log?.let { loaded ->
|
||||
if (loaded.truncated) {
|
||||
Text(
|
||||
"Showing the end of the log; there is more above it.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
loaded.path,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
// A path is identified by its tail.
|
||||
overflow = TextOverflow.StartEllipsis,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
rendered.takeIf { loaded.text.isNotEmpty() }
|
||||
?: AnnotatedString("(nothing in this log yet)"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
// Both directions: a log wraps badly and a stack
|
||||
// trace is wide, so it scrolls rather than being
|
||||
// reflowed into something harder to read.
|
||||
//
|
||||
// The panel is painted outside the two scrolls, so
|
||||
// it is the window rather than the content: filled
|
||||
// inside the scroll it would be the width of the
|
||||
// longest line and slide away as the log was
|
||||
// scrolled, leaving the dark behind the text
|
||||
// rather than behind the area. `fillMaxWidth` for
|
||||
// the same reason -- a short log would otherwise
|
||||
// give a panel the width of its longest line, and
|
||||
// the block would change shape as the reader
|
||||
// paged through it.
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.heightIn(max = LOG_HEIGHT)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||
LOG_PANEL_SHAPE,
|
||||
)
|
||||
.padding(LOG_PANEL_PADDING)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
// The whole foot is one row rather than the dialog's confirm and
|
||||
// dismiss slots. Given two slots, Material stacks them the moment
|
||||
// they do not fit, which put the controls *under* the buttons --
|
||||
// so the row is built here and the dialog is handed one thing.
|
||||
confirmButton = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = lines,
|
||||
onValueChange = { entered -> lines = entered.filter { it.isDigit() } },
|
||||
label = { Text("Lines") },
|
||||
singleLine = true,
|
||||
keyboardOptions =
|
||||
KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { submitLines() }),
|
||||
modifier =
|
||||
Modifier.width(LINES_FIELD_WIDTH)
|
||||
// Leaving the field counts as submitting it:
|
||||
// otherwise a number typed and then tapped
|
||||
// away from sits there looking applied and
|
||||
// isn't.
|
||||
.onFocusChanged { focus -> if (!focus.isFocused) submitLines() },
|
||||
)
|
||||
// Only when there is one to switch to. A toggle that does
|
||||
// nothing teaches the reader that toggles here do nothing.
|
||||
if ((log?.generations ?: 1) > 1) {
|
||||
TextButton(
|
||||
onClick = { generation = if (generation == 0) 1 else 0 },
|
||||
colors = ActionTone.Caution.colors(),
|
||||
contentPadding = FOOT_BUTTON_PADDING,
|
||||
) {
|
||||
Text(if (generation == 0) "Current" else "Previous")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
enabled = rendered != null,
|
||||
onClick = {
|
||||
rendered?.let { copyToClipboard(context, component.name, it.text) }
|
||||
},
|
||||
contentPadding = FOOT_BUTTON_PADDING,
|
||||
) {
|
||||
Text("Copy")
|
||||
}
|
||||
TextButton(onClick = onDismiss, contentPadding = FOOT_BUTTON_PADDING) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the log on the clipboard.
|
||||
*
|
||||
* No confirmation: Android shows its own on the versions that do not, and saying it again would be
|
||||
* announcing what the screen already told them.
|
||||
*/
|
||||
private fun copyToClipboard(context: Context, label: String, text: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, text))
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import com.example.wgapplink.ServerSettings
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
// Where the server is and how this device authenticates to it, from the
|
||||
// enrollment scan (see Link.kt). Held here because every call this
|
||||
// app makes goes through requestFromServer below -- the manifest, the
|
||||
// management routes behind the Add screen, the on-demand build routes, and
|
||||
// every APK download including this app's own self-update -- and the
|
||||
// screens would otherwise thread the same value through every one.
|
||||
//
|
||||
// All of it is HTTPS pinned against PINNED_CA_PEM (see Link.kt),
|
||||
// because everything here either is, or decides, what this app hands to the
|
||||
// system installer next. The server binds only the WireGuard interface, so
|
||||
// reaching it at all requires being an enrolled tunnel peer; the token is
|
||||
// what distinguishes this phone from anything else that is. The server's
|
||||
// *other* port (8091) is a separate, plain-HTTP bootstrap link for a
|
||||
// human's browser to install this app in the first place -- this app's own
|
||||
// code never talks to that one, and it carries no token.
|
||||
@Volatile private var settings: ServerSettings? = null
|
||||
|
||||
fun useServer(chosen: ServerSettings) {
|
||||
settings = chosen
|
||||
}
|
||||
|
||||
/** The configured server, or null before this device is enrolled. */
|
||||
fun serverSettings(): ServerSettings? = settings
|
||||
|
||||
private fun requireServer(): ServerSettings =
|
||||
settings
|
||||
?: throw DownloadServerException(
|
||||
"This device isn't enrolled yet. Scan the QR the server prints on startup."
|
||||
)
|
||||
|
||||
private const val CONNECT_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Anything that went wrong talking to the download server, whichever route it was. One type rather
|
||||
* than one per route: no caller ever needs to tell a failed `/manifest` fetch from a failed
|
||||
* download by *type* -- each catches around the one call it made -- and the message already says
|
||||
* which route and what happened.
|
||||
*/
|
||||
class DownloadServerException(message: String, cause: Throwable? = null) : Exception(message, cause)
|
||||
|
||||
/**
|
||||
* Runs one request against the download server, with the pinned-TLS setup and the failure
|
||||
* translation every route here needs. [readBody] gets the connected, already-status-checked
|
||||
* connection to read from.
|
||||
*
|
||||
* Blocking -- invoke from a background dispatcher.
|
||||
*
|
||||
* @param jsonBody a request body to send, for the routes that change server state. Set separately
|
||||
* from [method] because a method alone doesn't imply one -- DELETE here carries no body.
|
||||
* @param readTimeoutMs how long to wait on the response body; a download's is necessarily longer
|
||||
* than a JSON route's.
|
||||
*/
|
||||
fun <T> requestFromServer(
|
||||
path: String,
|
||||
method: String = "GET",
|
||||
jsonBody: String? = null,
|
||||
readTimeoutMs: Int = 5000,
|
||||
readBody: (HttpURLConnection) -> T,
|
||||
): T {
|
||||
val server = requireServer()
|
||||
val connection = URL("${server.baseUrl}$path").openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.applyPinnedTls()
|
||||
connection.setRequestProperty("Authorization", "Bearer ${server.token}")
|
||||
connection.requestMethod = method
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
connection.readTimeout = readTimeoutMs
|
||||
if (jsonBody != null) {
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
|
||||
}
|
||||
// The management routes answer 204 with no body, and the server
|
||||
// reports a rejected request (a path with nothing built under it,
|
||||
// an app that's already added) as a 4xx whose *body* is the
|
||||
// explanation written for this screen -- so read it rather than
|
||||
// reporting a bare status code the user can do nothing with.
|
||||
if (connection.responseCode !in 200..299) {
|
||||
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
|
||||
throw DownloadServerException(
|
||||
when {
|
||||
connection.responseCode == 401 ->
|
||||
"The server rejected this device's token. Re-enroll by scanning the " +
|
||||
"QR it prints (or rotate with --rotate-token and scan the new one)."
|
||||
detail.isNullOrEmpty() ->
|
||||
"Server returned HTTP ${connection.responseCode} for $path"
|
||||
else -> detail
|
||||
}
|
||||
)
|
||||
}
|
||||
return readBody(connection)
|
||||
} catch (e: DownloadServerException) {
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
// Covers refused/timed-out connections as well as
|
||||
// UnknownServiceException, which is what a cleartext-blocked
|
||||
// request throws -- surfacing the real exception here (rather than
|
||||
// a single canned message for every failure mode) is what actually
|
||||
// lets this be diagnosed on a device with no logcat access.
|
||||
throw DownloadServerException(
|
||||
"Couldn't reach the server at ${server.baseUrl}$path " +
|
||||
"(${e::class.simpleName}: ${e.message}) -- is dev-updater running, " +
|
||||
"and is this device able to reach that address?",
|
||||
e,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
throw DownloadServerException(
|
||||
"Reached ${server.baseUrl}$path but couldn't read its response " +
|
||||
"(${e::class.simpleName}: ${e.message})",
|
||||
e,
|
||||
)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
|
||||
// These are ad hoc local rebuilds with no CI bumping a version code, so it
|
||||
// can't tell "already have this build" apart from "update available" --
|
||||
// during active development the version code routinely stays put across
|
||||
// many rebuilds. PackageManager tracks something better for this purpose
|
||||
// regardless: `lastUpdateTime`, the epoch millis of when the currently
|
||||
// installed copy was actually installed, maintained by the OS itself on
|
||||
// every install (including a plain `adb install -r`, unlike a
|
||||
// download-tracked-in-SharedPreferences approach, which would only learn
|
||||
// about installs that went through this app's own download button).
|
||||
// Compared directly against `/manifest`'s build-mtime epoch (see
|
||||
// UpdateManifest.kt) -- both are wall-clock timestamps, so as long as the
|
||||
// device and dev machine roughly agree on the time (true for an emulator
|
||||
// or a phone on the same LAN), "installed after the currently-served build
|
||||
// was produced" is a reliable proxy for "already have that build."
|
||||
//
|
||||
// Querying another app's PackageInfo needs package visibility on API 30+,
|
||||
// normally granted per-package via this app's own <queries> in
|
||||
// AndroidManifest.xml -- but that would mean a manifest edit (and a
|
||||
// rebuild) every time a new app is added to the server's /manifest. This
|
||||
// app instead holds QUERY_ALL_PACKAGES, which lets it query any installed
|
||||
// package by name with no such declaration. That permission is a Play
|
||||
// Store *policy* restriction, not something the OS itself enforces, so
|
||||
// it's free to use here since this app is never distributed through Play (F-Droid
|
||||
// takes the same approach for the same reason -- see AndroidManifest.xml).
|
||||
fun installedLastUpdateTimeMillis(context: Context, packageName: String): Long? =
|
||||
try {
|
||||
context.packageManager.getPackageInfo(packageName, 0).lastUpdateTime
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
// Whether this package is on this device at all -- same query, asked as
|
||||
// the question the caller actually has.
|
||||
fun isInstalled(context: Context, packageName: String): Boolean =
|
||||
installedLastUpdateTimeMillis(context, packageName) != null
|
||||
|
||||
// The installed APK's own file size, for showing "old size -> new size" next
|
||||
// to an available update -- same package-visibility caveat as above.
|
||||
fun installedApkSizeBytes(context: Context, packageName: String): Long? =
|
||||
try {
|
||||
val sourceDir =
|
||||
context.packageManager.getPackageInfo(packageName, 0).applicationInfo?.sourceDir
|
||||
sourceDir?.let { File(it).length() }
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
|
||||
// PACKAGE_ADDED/PACKAGE_REPLACED are protected system broadcasts -- only
|
||||
// the OS can send them -- fired the moment PackageManager finishes
|
||||
// registering an install, which happens before the installer's own "App
|
||||
// installed" confirmation screen appears. That makes this a strictly
|
||||
// earlier and more precise signal than polling or waiting for this app's
|
||||
// activity to next resume (the latter only happens once the user backs out
|
||||
// of that confirmation screen). Context-registered rather than
|
||||
// manifest-declared since this app only cares about it while some screen
|
||||
// is actually observing install state, not for the whole time it's
|
||||
// installed -- see the paired unregisterReceiver call at the caller's
|
||||
// DisposableEffect.
|
||||
//
|
||||
// RECEIVER_NOT_EXPORTED is correct, not just required (API 33+ rejects a
|
||||
// context-registered receiver with neither flag): nothing but the system
|
||||
// can send this broadcast regardless, so there's no legitimate case for
|
||||
// another app to inject it here.
|
||||
fun registerPackageChangeReceiver(
|
||||
context: Context,
|
||||
onPackageChanged: (packageName: String) -> Unit,
|
||||
): BroadcastReceiver {
|
||||
val receiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val packageName = intent.data?.schemeSpecificPart ?: return
|
||||
onPackageChanged(packageName)
|
||||
}
|
||||
}
|
||||
val filter =
|
||||
IntentFilter().apply {
|
||||
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||
addAction(Intent.ACTION_PACKAGE_REPLACED)
|
||||
addDataScheme("package")
|
||||
}
|
||||
ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
return receiver
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import com.example.wgapplink.PinnedTls
|
||||
import com.example.wgapplink.ServerStore
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
/**
|
||||
* This app's two parameters to the shared link, and the objects built from them.
|
||||
*
|
||||
* Everything about reaching the build machine -- pinned TLS, the enrollment store, the scanner --
|
||||
* is `wg-app-link`, shared with ai-app so a fix lands in both. What is left here is the two values
|
||||
* that are genuinely per-app, in one place so nothing can disagree about them.
|
||||
*/
|
||||
private const val ENROLL_SCHEME = "devupdater"
|
||||
|
||||
/**
|
||||
* The Android Keystore key the token is sealed under.
|
||||
*
|
||||
* **Persisted on the device, so this string is not free to change.** A new value means the app
|
||||
* cannot unseal the token it already stored, and an enrolled phone silently reads as not enrolled
|
||||
* with nothing on screen to say why -- unlike the scheme, where a mismatch shows up immediately as
|
||||
* a scanned code doing nothing. Carried over unchanged from before the link was shared, which is
|
||||
* the only reason enrolled devices survived that change.
|
||||
*/
|
||||
private const val TOKEN_KEY_ALIAS = "dev-updater-token-key"
|
||||
|
||||
/** Where this device's enrollment lives. Stateless, so one instance is all anything needs. */
|
||||
val serverStore = ServerStore(ENROLL_SCHEME, TOKEN_KEY_ALIAS)
|
||||
|
||||
/**
|
||||
* Built once and reused: the socket factory behind it is lazy, and every reconnect would otherwise
|
||||
* redo the KeyStore and TrustManager setup.
|
||||
*/
|
||||
private val pinnedTls = PinnedTls(PINNED_CA_PEM)
|
||||
|
||||
/**
|
||||
* Trusts this machine's CA and nothing else, including the system store -- so a genuine certificate
|
||||
* issued for another host is refused exactly as firmly as a self-signed one.
|
||||
*
|
||||
* An extension rather than a call at each site, so no request can be made without it by forgetting
|
||||
* a line.
|
||||
*/
|
||||
fun HttpURLConnection.applyPinnedTls() {
|
||||
pinnedTls.applyTo(this)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
// Bumped whenever enrollment lands via a devupdater:// intent, so the
|
||||
// composition below re-reads the stored settings.
|
||||
private var settingsVersion by mutableStateOf(0)
|
||||
|
||||
// Registered up front since permission launchers must be registered
|
||||
// before the activity reaches STARTED.
|
||||
private val requestLocalNetworkPermission =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Window.setStatusBarColor is deprecated and a flat no-op on
|
||||
// Android 15+ (status bar is always transparent there); relying on
|
||||
// it left the strip behind the status bar showing this legacy
|
||||
// platform theme's default -- Theme.Material's teal colorPrimaryDark
|
||||
// -- on older versions instead of the app's actual background.
|
||||
// enableEdgeToEdge() makes the status bar transparent on every
|
||||
// supported version instead, so the Surface below (now filling the
|
||||
// true full screen, with no inset) paints straight through
|
||||
// underneath it and the two can never mismatch.
|
||||
enableEdgeToEdge()
|
||||
|
||||
// The app is dark-only (DevUpdaterColors), so the status bar icons
|
||||
// are forced light -- Android doesn't infer icon colour from the
|
||||
// background it ends up over, and the Surface below paints straight
|
||||
// through underneath the bar.
|
||||
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
|
||||
false
|
||||
|
||||
// Mandatory from Android 17 (API 37) on for anything targeting 37+,
|
||||
// see the AndroidManifest declaration. Requested up front rather
|
||||
// than lazily on first fetch because a denial is invisible at the
|
||||
// socket layer: the OS just drops the traffic, so the app would
|
||||
// otherwise report an ordinary-looking connect timeout with no hint
|
||||
// that a permission is what's missing.
|
||||
//
|
||||
// **Still required when the server is reached over WireGuard**,
|
||||
// which is the only way this app reaches it. Worth stating because
|
||||
// the platform's own Local Network Definition says a local network
|
||||
// "excludes cellular (WWAN) or VPN connections", which reads as
|
||||
// exempting a tunnelled 10.66.0.1 -- and on the phone this is
|
||||
// installed on, it does not. Measured against a real tunnel after
|
||||
// that reading suggested the declaration could be dropped; it
|
||||
// cannot.
|
||||
//
|
||||
// That phone runs GrapheneOS, so it is possible stock Android
|
||||
// matches its own documentation here and this is a hardened-OS
|
||||
// difference. Untested either way, and it does not change the
|
||||
// answer: the permission stays, because the device it has to work
|
||||
// on is the one it was measured on. Nothing local can check it --
|
||||
// the emulator this is developed against is API 36, where the
|
||||
// permission is not enforced at all.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
|
||||
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
|
||||
}
|
||||
|
||||
// Before the first composition, so the screens can call the server
|
||||
// straight away rather than racing a load.
|
||||
serverStore.load(this)?.let(::useServer)
|
||||
handleEnrollment(intent)
|
||||
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = DevUpdaterColors) {
|
||||
// Fills the true full screen (behind the status bar too, per
|
||||
// enableEdgeToEdge() above) so this Surface's own background
|
||||
// is what shows there. The inset is applied one level in
|
||||
// instead, via statusBarsPadding() on the Box wrapping
|
||||
// UpdaterScreen() -- it reads the actual system inset rather
|
||||
// than assuming a fixed height, so it stays correct across
|
||||
// devices/orientations/font scales -- so it's only the
|
||||
// *content* that starts below the status bar, not the
|
||||
// background underneath it.
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Box(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
|
||||
UpdaterScreen(settingsVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launchMode="singleTop": an enrollment scan while the app is open
|
||||
// lands here rather than in a second activity instance.
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleEnrollment(intent)
|
||||
}
|
||||
|
||||
private fun handleEnrollment(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
val settings = serverStore.parseEnrollmentUri(uri)
|
||||
if (settings == null) {
|
||||
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
serverStore.save(this, settings)
|
||||
useServer(settings)
|
||||
settingsVersion++
|
||||
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
|
||||
/**
|
||||
* The icons the app draws, as glyphs in a Nerd Fonts subset rather than as vector assets.
|
||||
*
|
||||
* Drawing them as *text* is what makes them cheap: an icon beside a line of text wants that line's
|
||||
* size, colour and baseline, and a `Text` gets all three for free where an `Icon` needs each one
|
||||
* set and kept in step by hand.
|
||||
*
|
||||
* Ordinary Unicode won't do it -- there is no character for a git branch, and the ones that exist
|
||||
* for the rest aren't reliably in an Android system font, so they arrive as tofu boxes on
|
||||
* somebody's phone. The font here is `app/build-icon-font.sh`'s output: eight glyphs, 2 KB, from
|
||||
* the 2.5 MB symbols font. Adding one means adding its codepoint in *both* places -- a codepoint
|
||||
* here that the script didn't subset is a glyph that silently isn't there.
|
||||
*
|
||||
* All Material Design Icons bar one, so they read as one family; the exception is noted where it is
|
||||
* declared.
|
||||
*/
|
||||
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
|
||||
|
||||
/** Nerd Fonts puts these in plane 15, so each is a surrogate pair. */
|
||||
private fun glyph(codePoint: Int) = String(Character.toChars(codePoint))
|
||||
|
||||
/** `md-folder` -- a project's directory on the build machine. */
|
||||
val FOLDER_GLYPH = glyph(0xF024B)
|
||||
|
||||
/** `md-source_branch` -- the branch that directory is on. */
|
||||
val BRANCH_GLYPH = glyph(0xF062C)
|
||||
|
||||
/**
|
||||
* `fa-server` -- a server component, running on the build machine.
|
||||
*
|
||||
* Font Awesome's rather than Material's: the `md-server` stack of three shelves is fussy at the
|
||||
* size a component row draws it.
|
||||
*/
|
||||
val SERVER_GLYPH = glyph(0xF233)
|
||||
|
||||
/** `md-cog` -- a card's own settings. */
|
||||
val SETTINGS_GLYPH = glyph(0xF0493)
|
||||
|
||||
/** `md-plus` -- add a project to the list. */
|
||||
val PLUS_GLYPH = glyph(0xF0415)
|
||||
|
||||
/** `md-refresh` -- re-read the manifest. */
|
||||
val REFRESH_GLYPH = glyph(0xF0450)
|
||||
|
||||
/**
|
||||
* `fa-book` -- what a component wrote.
|
||||
*
|
||||
* Font Awesome's, like the server glyph: Material's book icons are open-book shapes that read as
|
||||
* "read this" rather than "a record of what happened".
|
||||
*/
|
||||
val LOG_GLYPH = glyph(0xF02D)
|
||||
|
||||
/** `md-trash_can_outline` -- remove a scan directory. */
|
||||
val TRASH_GLYPH = glyph(0xF0A7A)
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Whether the build machine has a newer copy of *this app* than the one running.
|
||||
*
|
||||
* The only check in the app that goes nowhere near the manifest. That is the point: when this
|
||||
* server changes what the manifest says, an app too old to read it loses the list — and the list is
|
||||
* where the button that would replace it lives. Recovering from that means a reinstall over the
|
||||
* plain-HTTP bootstrap port, by hand, at the machine. This path keeps working because there is
|
||||
* almost nothing in it to break.
|
||||
*
|
||||
* It does not survive a changed CA, port or token, which break the connection before any route is
|
||||
* reached. Those stay one-way doors.
|
||||
*
|
||||
* Answers null when there is nothing to offer, including when the server cannot be reached — a
|
||||
* failed check is not an update, and this is not the screen that reports the server being down.
|
||||
*/
|
||||
suspend fun selfUpdateAvailable(context: Context): SelfBuild? =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val build = selfBuild()
|
||||
val installed = installedLastUpdateTimeMillis(context, context.packageName)
|
||||
// Newer than what is installed, by the same rule the list
|
||||
// uses. Equal counts as current: a build and an install
|
||||
// landing in the same second is not an update.
|
||||
if (installed == null || build.mtimeMillis > installed) build else null
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers the newer build of this app, and installs it.
|
||||
*
|
||||
* A screen of its own rather than a row on the list, because the moment it matters most is the one
|
||||
* where the list may not render at all. It asks for nothing but the two numbers
|
||||
* [selfUpdateAvailable] already fetched.
|
||||
*/
|
||||
@Composable
|
||||
fun SelfUpdateScreen(build: SelfBuild, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var downloading by remember { mutableStateOf(false) }
|
||||
var progress by remember { mutableFloatStateOf(0f) }
|
||||
var failure by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
Text("Update Dev Updater", style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"The build machine has a newer build of this app (${formatSize(build.sizeBytes)}).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"Taking it now keeps this app able to talk to the server after the server changes. " +
|
||||
"This check does not use the app list, so it keeps working when the list does not.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
failure?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
// Determinate, because the size is known before the first byte
|
||||
// -- it came back with the check.
|
||||
ProgressBar(fraction = { progress })
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(enabled = !downloading, onClick = onDismiss) { Text("Not now") }
|
||||
TextButton(
|
||||
enabled = !downloading,
|
||||
colors = ActionTone.Primary.colors(),
|
||||
onClick = {
|
||||
downloading = true
|
||||
failure = null
|
||||
scope.launch {
|
||||
try {
|
||||
val file =
|
||||
withContext(Dispatchers.IO) {
|
||||
downloadFromRoute(context, SELF_APK_ROUTE, "self") { read, total
|
||||
->
|
||||
progress = if (total > 0) read.toFloat() / total else 0f
|
||||
}
|
||||
}
|
||||
// Checked rather than attempted, the same way
|
||||
// the list does it: without the permission the
|
||||
// installer bounces back a generic "not allowed
|
||||
// to install unknown apps" dialog that names
|
||||
// neither what was blocked nor what to do, and
|
||||
// the settings screen it means is one intent
|
||||
// away. Kept in step with `install` in
|
||||
// UpdaterScreen.kt, which makes the same check
|
||||
// for the same reason.
|
||||
if (canRequestInstall(context)) {
|
||||
// The system installer takes it from here
|
||||
// and asks for its own confirmation; this
|
||||
// app is replaced rather than told about it.
|
||||
context.startActivity(installApkIntent(context, file))
|
||||
} else {
|
||||
failure =
|
||||
"Android needs permission to install apps from Dev Updater. " +
|
||||
"The settings screen for it is open now; allow it and " +
|
||||
"press Update again."
|
||||
context.startActivity(requestInstallPermissionIntent(context))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
failure = e.message ?: "Couldn't download the update"
|
||||
}
|
||||
downloading = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Update")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.ButtonColors
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
||||
*
|
||||
* Named rather than used as literals at the point of need, so the mapping below reads as the
|
||||
* decision it is -- "a card is Surface 0" -- and so a value can be checked against the upstream
|
||||
* palette without reading the layout that uses it.
|
||||
*/
|
||||
private object Mocha {
|
||||
val Rosewater = Color(0xFFF5E0DC)
|
||||
val Mauve = Color(0xFFCBA6F7)
|
||||
val Red = Color(0xFFF38BA8)
|
||||
val Peach = Color(0xFFFAB387)
|
||||
val Yellow = Color(0xFFF9E2AF)
|
||||
val Green = Color(0xFFA6E3A1)
|
||||
val Teal = Color(0xFF94E2D5)
|
||||
val Sky = Color(0xFF89DCEB)
|
||||
val Blue = Color(0xFF89B4FA)
|
||||
val Lavender = Color(0xFFB4BEFE)
|
||||
val Text = Color(0xFFCDD6F4)
|
||||
val Subtext0 = Color(0xFFA6ADC8)
|
||||
val Overlay0 = Color(0xFF6C7086)
|
||||
val Surface2 = Color(0xFF585B70)
|
||||
val Surface1 = Color(0xFF45475A)
|
||||
val Surface0 = Color(0xFF313244)
|
||||
val Base = Color(0xFF1E1E2E)
|
||||
val Mantle = Color(0xFF181825)
|
||||
val Crust = Color(0xFF11111B)
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's colour scheme: Catppuccin Mocha mapped onto Material's roles.
|
||||
*
|
||||
* The mapping that matters is the surface ladder. Mocha names its darks in order -- Crust, Mantle,
|
||||
* Base, Surface 0, Surface 1 -- and Material asks for the same thing under different names, so the
|
||||
* page is Base, a component's outlined card stays Base beside it, and a project's card is Surface
|
||||
* 0: one visible step up, which is the whole of what the nesting has to say.
|
||||
*
|
||||
* Accents on this palette are light, so anything filled with one takes Crust for its text rather
|
||||
* than the near-white the roles default to.
|
||||
*/
|
||||
val DevUpdaterColors =
|
||||
darkColorScheme(
|
||||
primary = Mocha.Mauve,
|
||||
onPrimary = Mocha.Crust,
|
||||
primaryContainer = Mocha.Surface1,
|
||||
onPrimaryContainer = Mocha.Mauve,
|
||||
secondary = Mocha.Lavender,
|
||||
onSecondary = Mocha.Crust,
|
||||
secondaryContainer = Mocha.Surface1,
|
||||
onSecondaryContainer = Mocha.Lavender,
|
||||
tertiary = Mocha.Rosewater,
|
||||
onTertiary = Mocha.Crust,
|
||||
background = Mocha.Base,
|
||||
onBackground = Mocha.Text,
|
||||
surface = Mocha.Base,
|
||||
onSurface = Mocha.Text,
|
||||
surfaceVariant = Mocha.Surface0,
|
||||
onSurfaceVariant = Mocha.Subtext0,
|
||||
surfaceContainerLowest = Mocha.Crust,
|
||||
surfaceContainerLow = Mocha.Mantle,
|
||||
surfaceContainer = Mocha.Base,
|
||||
surfaceContainerHigh = Mocha.Surface0,
|
||||
surfaceContainerHighest = Mocha.Surface0,
|
||||
inverseSurface = Mocha.Text,
|
||||
inverseOnSurface = Mocha.Base,
|
||||
inversePrimary = Mocha.Mauve,
|
||||
outline = Mocha.Overlay0,
|
||||
outlineVariant = Mocha.Surface2,
|
||||
error = Mocha.Red,
|
||||
onError = Mocha.Crust,
|
||||
errorContainer = Mocha.Surface1,
|
||||
onErrorContainer = Mocha.Red,
|
||||
scrim = Mocha.Crust,
|
||||
)
|
||||
|
||||
/**
|
||||
* What pressing a button will do, said in colour.
|
||||
*
|
||||
* By consequence rather than by which component it sits on, so that the same consequence looks the
|
||||
* same everywhere: Uninstall is the same red whether it takes away a service or an app.
|
||||
*
|
||||
* Each is a Mocha accent, which is the point of using a palette rather than picking shades: they
|
||||
* were chosen to sit at one weight against a Mocha background, so no tone shouts over the others.
|
||||
* They are all far too light to fill a button with, which is the thing to remember if one is ever
|
||||
* reused as a container.
|
||||
*/
|
||||
enum class ActionTone {
|
||||
/** Stop, Uninstall, Remove: takes something away. */
|
||||
Destructive,
|
||||
|
||||
/** Restart, Reinstall: replaces what is there with the same thing. */
|
||||
Caution,
|
||||
|
||||
/** Start, Install: brings up something that wasn't there. */
|
||||
Go,
|
||||
|
||||
/** Update, Build, Pull: brings something new in. */
|
||||
Primary,
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable read rather than a constant on the enum, so [Caution] can be the scheme's own accent
|
||||
* -- the colour the corner controls already use -- instead of a copy of it that drifts the first
|
||||
* time the scheme changes.
|
||||
*/
|
||||
val ActionTone.color: Color
|
||||
@Composable
|
||||
get() =
|
||||
when (this) {
|
||||
ActionTone.Destructive -> Mocha.Red
|
||||
ActionTone.Caution -> MaterialTheme.colorScheme.primary
|
||||
ActionTone.Go -> Mocha.Green
|
||||
ActionTone.Primary -> Mocha.Blue
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ActionTone.colors(): ButtonColors = ButtonDefaults.textButtonColors(contentColor = color)
|
||||
|
||||
/**
|
||||
* "There is something here": a service that is up. A branch with commits waiting takes
|
||||
* [ActionTone.Primary] instead, because what it is really saying is that Pull would do something --
|
||||
* so it is that button's colour.
|
||||
*/
|
||||
val runningColor: Color
|
||||
@Composable get() = ActionTone.Go.color
|
||||
|
||||
/**
|
||||
* "This went wrong on its own": a service that fell over.
|
||||
*
|
||||
* The scheme's error colour rather than [ActionTone.Destructive], which happens to be the same red.
|
||||
* They are the same red for different reasons, and a state is not an action -- Destructive means
|
||||
* *this button takes something away*, and nothing here is a button.
|
||||
*/
|
||||
val failedColor: Color
|
||||
@Composable get() = MaterialTheme.colorScheme.error
|
||||
|
||||
/**
|
||||
* The sixteen ANSI colours, in their standard order, as this palette's nearest members.
|
||||
*
|
||||
* A log carries colour as an index rather than a value -- "red", not a hex triple -- so what red
|
||||
* *is* remains a decision for whoever draws it. Answering with Mocha's red rather than the VGA one
|
||||
* keeps a log looking like part of the app instead of a terminal pasted into it, and keeps every
|
||||
* colour legible against this background, which raw ANSI black on a dark surface is not.
|
||||
*
|
||||
* Indices 0-7 are the normal set and 8-15 the bright one. Mocha has no bright/normal pairs, so a
|
||||
* bright colour is the same hue: the distinction exists in terminals to buy contrast this palette
|
||||
* already has, and inventing a second shade for it would be inventing a difference the log does not
|
||||
* mean.
|
||||
*/
|
||||
val AnsiColors: List<Color> =
|
||||
listOf(
|
||||
Mocha.Overlay0, // black -- not actual black, which would be invisible here
|
||||
Mocha.Red,
|
||||
Mocha.Green,
|
||||
Mocha.Yellow,
|
||||
Mocha.Blue,
|
||||
Mocha.Mauve, // magenta
|
||||
Mocha.Teal, // cyan
|
||||
Mocha.Subtext0, // white
|
||||
Mocha.Surface2, // bright black
|
||||
Mocha.Red,
|
||||
Mocha.Green,
|
||||
Mocha.Peach, // bright yellow, warmed so it is not the same swatch twice
|
||||
Mocha.Sky,
|
||||
Mocha.Mauve,
|
||||
Mocha.Teal,
|
||||
Mocha.Text, // bright white
|
||||
)
|
||||
|
||||
/**
|
||||
* The progress bar, everywhere this app draws one.
|
||||
*
|
||||
* Blue, which is [ActionTone.Primary] -- the colour of bringing something new in, and that is what
|
||||
* every bar here is waiting on: a build, a download, a log being read. Consequence rather than
|
||||
* location, the same rule the buttons follow, so a bar means the same thing wherever it appears.
|
||||
*
|
||||
* One composable because six places draw one, and six copies of a colour is five chances to
|
||||
* disagree.
|
||||
*
|
||||
* [fraction] is null when there is nothing honest to draw a proportion from, which is most of the
|
||||
* time: an estimated bar looks exactly like a measured one and the person watching cannot tell them
|
||||
* apart, so a command that reports no count gets a bar that claims none.
|
||||
*/
|
||||
@Composable
|
||||
fun ProgressBar(modifier: Modifier = Modifier, fraction: (() -> Float)? = null) {
|
||||
if (fraction == null) {
|
||||
LinearProgressIndicator(
|
||||
color = ActionTone.Primary.color,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
progress = fraction,
|
||||
color = ActionTone.Primary.color,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
data class ManifestEntry(
|
||||
val key: String,
|
||||
// Server-provided display name -- this app renders whatever /manifest
|
||||
// sends rather than keeping its own hardcoded per-app label list, which
|
||||
// is what lets the app list be edited at runtime from the Add screen
|
||||
// with no rebuild here.
|
||||
val label: String,
|
||||
val filename: String,
|
||||
val route: String,
|
||||
// Null until this project has been built at least once -- there is no
|
||||
// APK to read an identity out of before then, and inventing one would
|
||||
// make the installed-version check compare against nothing.
|
||||
val packageName: String?,
|
||||
// What this project's APK used to install over, when it has been
|
||||
// renamed. Android treats a renamed applicationId as an unrelated app,
|
||||
// so that one is still installed and nothing will ever replace it --
|
||||
// the card offers to remove it, but only while it is actually there.
|
||||
val previousPackageName: String?,
|
||||
// The project directory this app was added by. Shown on the card so
|
||||
// it's possible to tell two similarly-named apps apart, and to spot an
|
||||
// entry pointing somewhere unexpected.
|
||||
val projectPath: String,
|
||||
// Epoch seconds of the raw build's mtime, straight from the server --
|
||||
// these are ad hoc local rebuilds with no CI bumping a version, so
|
||||
// build freshness is the only meaningful signal, not a version code.
|
||||
val mtime: Double,
|
||||
val size: Long,
|
||||
// True for an entry with an on-demand build step (see BuildStatus.kt)
|
||||
// -- only then does this app call that entry's prepare/status routes,
|
||||
// which 404 for an entry without one.
|
||||
val needsBuild: Boolean,
|
||||
// True for the server's own updater app, which can't be removed: it's
|
||||
// the only route by which this app can ever replace itself.
|
||||
val builtIn: Boolean,
|
||||
// Force git's remote commands onto IPv4 for this project -- this
|
||||
// machine's choice, editable from the card's settings.
|
||||
val gitIpv4: Boolean,
|
||||
// False when the project has no APK yet (never built, or cleaned).
|
||||
// Such an entry is still listed rather than silently dropped -- it was
|
||||
// added deliberately, so saying so beats it disappearing.
|
||||
val built: Boolean,
|
||||
// Every build discovered under the project, so a different one can be
|
||||
// selected without another round trip.
|
||||
val variants: List<ApkVariant>,
|
||||
// What this project produces, in build order. One is the ordinary case
|
||||
// and the card stays flat; more than one is drawn as a nested list, so
|
||||
// a project that also runs a server says so without every single-app
|
||||
// card growing a level of nesting to hold one thing.
|
||||
val components: List<ProjectComponent>,
|
||||
// The project's checkout, when it is in a git repository at all.
|
||||
val git: GitStatus?,
|
||||
// True when this app offers a Pull button (gitPull on the server, and
|
||||
// a branch that tracks something). Pull acts on the build machine;
|
||||
// Update acts on this phone.
|
||||
val canPull: Boolean,
|
||||
// Whether the remote has something this checkout doesn't, as of the
|
||||
// server's last check. Not a count: the server asks what the remote
|
||||
// has without downloading it, and counting needs the objects.
|
||||
val newCommits: Boolean,
|
||||
// This checkout's remote is being asked right now, so newCommits is
|
||||
// the previous answer. Per app, so the card that is actually waiting
|
||||
// is the one that says so.
|
||||
val checkPending: Boolean,
|
||||
// Why the last check produced no answer, when it produced none: the
|
||||
// server's reason for a check that failed, or this app's own for one
|
||||
// it stopped waiting on (see checksUnfinished). Null is the ordinary
|
||||
// case. Kept apart from newCommits because "we don't know" and "the
|
||||
// remote had nothing" are different answers, and only one of them is
|
||||
// safe to show as an unremarkable branch name.
|
||||
val checkError: String? = null,
|
||||
// The build step this project asks for in its own .dev-updater.ron,
|
||||
// which nobody has accepted yet -- RON, the form it was written in,
|
||||
// shown verbatim for a person to read before it is allowed to run.
|
||||
// Null once accepted, or for a project that asks for nothing. While it
|
||||
// is set the server runs no build step for this app at all.
|
||||
val pendingDeclaration: String?,
|
||||
)
|
||||
|
||||
// A project's checkout, all read locally on the server.
|
||||
data class GitStatus(
|
||||
val branch: String,
|
||||
val dirty: Boolean,
|
||||
val upstream: String?,
|
||||
// The top of the checkout, already tilde-contracted by the server.
|
||||
// What a person calls the project, and what Pull acts on -- the
|
||||
// project path is a directory somewhere inside this.
|
||||
val root: String,
|
||||
)
|
||||
|
||||
// One thing a project produces. [kind] is "apk" (installed on this phone)
|
||||
// or "server" (installed and run on the build machine).
|
||||
//
|
||||
// [state] is only ever set for a server, and only once its script has been
|
||||
// asked: "running", "stopped", "failed", "notInstalled". Null with [checking] true
|
||||
// means the answer is still coming; null with [error] set means the script
|
||||
// could not say, which is a different thing from a service being down.
|
||||
data class ProjectComponent(
|
||||
val name: String,
|
||||
val kind: String,
|
||||
val state: String?,
|
||||
val checking: Boolean,
|
||||
val error: String?,
|
||||
// Whether what is built is current with the checkout: "current",
|
||||
// "behind", or "unknown". Unknown is a real answer, not a fallback --
|
||||
// never built here, no checkout to compare against, or uncommitted
|
||||
// work in this component's directory, which makes the comparison
|
||||
// unreliable rather than negative.
|
||||
val freshness: String,
|
||||
// There is at least one log of either kind, so the card offers the
|
||||
// button that opens them.
|
||||
val hasLogs: Boolean,
|
||||
// The build machine wrote a build log for this component. False until
|
||||
// it has been built there at least once.
|
||||
val hasBuildLogs: Boolean,
|
||||
// This component's script reports a runtime log. False for an APK,
|
||||
// which does not run there, and false for a script that does not
|
||||
// implement `logs` -- which is a first-class answer, not a failure,
|
||||
// and is why the modal says which of the two it is rather than
|
||||
// showing an empty log either way.
|
||||
val hasRuntimeLogs: Boolean,
|
||||
// The last build stopped at this component, which is the one case
|
||||
// where its build log matters more than what it is doing now.
|
||||
val buildFailed: Boolean,
|
||||
// Where Uninstall's "remove data" and "remove config" would delete,
|
||||
// and whether anything is there. Null for an APK, which keeps nothing
|
||||
// on the build machine.
|
||||
//
|
||||
// The path is shown beside its toggle rather than kept for the log:
|
||||
// the server removes a declared path wherever it points, with no
|
||||
// check that it sits under the XDG directories, so this display is
|
||||
// the only thing between an accepted declaration and the wrong
|
||||
// directory. Do not reduce it to "data" and "config".
|
||||
val dataPath: String?,
|
||||
val configPath: String?,
|
||||
val dataPresent: Boolean,
|
||||
val configPresent: Boolean,
|
||||
// A null path has three causes and they are different things to do
|
||||
// about it, so the server says which: still reading the project's
|
||||
// resources, could not read them, or the project simply does not say.
|
||||
// The last is the ordinary case and not a fault.
|
||||
val resourcesChecking: Boolean,
|
||||
val resourcesError: String?,
|
||||
) {
|
||||
// Only "behind" is worth saying. "Current" is what a card already
|
||||
// implies, and "unknown" said out loud would be on most rows most of
|
||||
// the time, which is how a mark stops meaning anything.
|
||||
val isBehind: Boolean
|
||||
get() = freshness == "behind"
|
||||
|
||||
val isServer: Boolean
|
||||
get() = kind == "server"
|
||||
|
||||
val isInstalled: Boolean
|
||||
get() = state != null && state != "notInstalled"
|
||||
|
||||
val isRunning: Boolean
|
||||
get() = state == "running"
|
||||
|
||||
// Fell over rather than being stopped by anyone. Installed, so it
|
||||
// still offers Start and Uninstall -- what changes is what the row
|
||||
// says, not what it lets you do.
|
||||
val isFailed: Boolean
|
||||
get() = state == "failed"
|
||||
}
|
||||
|
||||
// One discovered build of an app. `variant` is the Gradle-style build
|
||||
// variant name ("debug", "freeRelease") taken from the output directory.
|
||||
data class ApkVariant(
|
||||
val path: String,
|
||||
val variant: String,
|
||||
val mtime: Double,
|
||||
)
|
||||
|
||||
// The whole /manifest response. The repo roots ride along with the app list
|
||||
// rather than needing their own fetch, since the Add screen shows both and
|
||||
// they change together.
|
||||
data class Manifest(
|
||||
val entries: List<ManifestEntry>,
|
||||
val repoRoots: List<String>,
|
||||
/**
|
||||
* The server is still asking the git remotes what they have, so [ManifestEntry.newCommits] may
|
||||
* change shortly. The list deliberately does not wait for that answer -- it would put a round
|
||||
* trip in front of every reopen -- so this is the cue to look once more.
|
||||
*/
|
||||
val checksPending: Boolean,
|
||||
)
|
||||
|
||||
// In the units PackageInfo.lastUpdateTime reports, which is what this is
|
||||
// ever compared against (see InstalledBuilds.kt).
|
||||
fun ManifestEntry.mtimeMillis(): Long = (mtime * 1000).toLong()
|
||||
|
||||
// When this device has pinned a build, that build's timestamp is the one
|
||||
// freshness is about -- the newest build being newer than the installed
|
||||
// copy says nothing when the newest is not what would be installed. Falls
|
||||
// back to the entry's own when the pinned one is gone, which is the same
|
||||
// build the server would fall back to serving.
|
||||
fun ManifestEntry.mtimeMillisFor(chosenVariantPath: String?): Long =
|
||||
variants.firstOrNull { it.path == chosenVariantPath }?.let { (it.mtime * 1000).toLong() }
|
||||
?: mtimeMillis()
|
||||
|
||||
// Whether anything about this card is still being worked out on the build
|
||||
// machine: its remote, or a service being asked what it is doing. Both
|
||||
// land after the response that started them, so a screen waiting on this
|
||||
// card waits on either -- stopping at the remote alone leaves a component
|
||||
// with no state, which is drawn as a row with no buttons, since which
|
||||
// buttons to offer is what the answer decides.
|
||||
val ManifestEntry.checksOutstanding: Boolean
|
||||
get() = checkPending || components.any { it.checking }
|
||||
|
||||
// The same list with every outstanding check marked as unfinished, for a
|
||||
// screen that has stopped waiting for the answers. The server may well
|
||||
// still be asking -- the next refresh or resume collects whatever it
|
||||
// landed on -- so this says only that nothing here is listening any more,
|
||||
// and the cards say "couldn't check" rather than falling back to the
|
||||
// blank line that means the remote answered and had nothing.
|
||||
fun Manifest.checksUnfinished(): Manifest =
|
||||
copy(checksPending = false, entries = entries.map { it.checkUnfinished() })
|
||||
|
||||
// The same for one entry, for a caller that was only ever waiting on one.
|
||||
//
|
||||
// A component being asked what it is doing is one of these too: its
|
||||
// spinner has to come down with the polling that fed it, or it turns for
|
||||
// ever promising an answer nothing is collecting -- and a row left with
|
||||
// no state and no explanation is a row with no buttons for a reason the
|
||||
// reader cannot see.
|
||||
fun ManifestEntry.checkUnfinished(): ManifestEntry =
|
||||
copy(
|
||||
checkPending = false,
|
||||
checkError = if (checkPending) STOPPED_WAITING else checkError,
|
||||
components =
|
||||
components.map { component ->
|
||||
if (component.checking) {
|
||||
component.copy(checking = false, error = STOPPED_WAITING)
|
||||
} else {
|
||||
component
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private const val STOPPED_WAITING = "the check was still running when this app stopped waiting"
|
||||
|
||||
// Longer than the other calls: answering asks the filesystem about every
|
||||
// app, and for one served as a stripped copy that can mean rebuilding the
|
||||
// slim APK first. Timing that out would report "couldn't reach the server"
|
||||
// about a server that is working.
|
||||
// Also used by a log read, which is slow for the same kind of reason: real
|
||||
// work on the build machine, not an unreachable server.
|
||||
internal const val MANIFEST_READ_TIMEOUT_MS = 15000
|
||||
|
||||
// Blocking network call -- invoke from a background dispatcher. Returns a
|
||||
// List so the cards render in the order the server sent them.
|
||||
//
|
||||
// [recheck] false collects an answer already being worked on without
|
||||
// asking the git remotes again -- what a poll wants. Asking again on every
|
||||
// poll would leave an answer outstanding forever, so the loop would never
|
||||
// end (see UpdaterScreen's load()).
|
||||
fun fetchManifest(recheck: Boolean = true): Manifest =
|
||||
requestFromServer(
|
||||
if (recheck) "/manifest" else "/manifest?recheck=false",
|
||||
readTimeoutMs = MANIFEST_READ_TIMEOUT_MS,
|
||||
) { connection ->
|
||||
val body = JSONObject(connection.inputStream.bufferedReader().readText())
|
||||
val apps = body.getJSONArray("apps")
|
||||
Manifest(
|
||||
entries = (0 until apps.length()).map { i -> readEntry(apps.getJSONObject(i)) },
|
||||
checksPending = body.optBoolean("checksPending", false),
|
||||
repoRoots =
|
||||
body.getJSONArray("repoRoots").let { roots ->
|
||||
(0 until roots.length()).map { roots.getString(it) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One app, for a card that has just acted and wants only itself back.
|
||||
*
|
||||
* The whole manifest would do -- and did, before the server grew this route -- but fetching every
|
||||
* app to use one of them asks the build machine to stat every APK it serves to answer a question
|
||||
* about one.
|
||||
*/
|
||||
fun fetchApp(key: String): ManifestEntry =
|
||||
requestFromServer("/apps/$key", readTimeoutMs = MANIFEST_READ_TIMEOUT_MS) { connection ->
|
||||
readEntry(JSONObject(connection.inputStream.bufferedReader().readText()))
|
||||
}
|
||||
|
||||
/** One app as the server describes it, shared by both reads above. */
|
||||
private fun readEntry(entry: JSONObject): ManifestEntry {
|
||||
val components = entry.optJSONArray("components")
|
||||
val variants = entry.getJSONArray("variants")
|
||||
return ManifestEntry(
|
||||
key = entry.getString("key"),
|
||||
label = entry.getString("label"),
|
||||
filename = entry.getString("filename"),
|
||||
route = entry.getString("route"),
|
||||
packageName = entry.optString("package").ifEmpty { null },
|
||||
previousPackageName = entry.optString("previousPackage").ifEmpty { null },
|
||||
projectPath = entry.getString("projectPath"),
|
||||
mtime = entry.getDouble("mtime"),
|
||||
size = entry.getLong("size"),
|
||||
needsBuild = entry.getBoolean("needsBuild"),
|
||||
builtIn = entry.getBoolean("builtIn"),
|
||||
gitIpv4 = entry.getBoolean("gitIpv4"),
|
||||
built = entry.getBoolean("built"),
|
||||
git =
|
||||
entry.optJSONObject("git")?.let { git ->
|
||||
GitStatus(
|
||||
branch = git.getString("branch"),
|
||||
dirty = git.getBoolean("dirty"),
|
||||
upstream = git.optString("upstream").ifEmpty { null },
|
||||
root = git.getString("root"),
|
||||
)
|
||||
},
|
||||
canPull = entry.optBoolean("canPull", false),
|
||||
newCommits = entry.optBoolean("newCommits", false),
|
||||
checkError = entry.optString("checkError").ifEmpty { null },
|
||||
checkPending = entry.optBoolean("checkPending", false),
|
||||
pendingDeclaration = entry.optString("pendingDeclaration").ifEmpty { null },
|
||||
components =
|
||||
(0 until (components?.length() ?: 0)).map { j ->
|
||||
val component = components!!.getJSONObject(j)
|
||||
ProjectComponent(
|
||||
name = component.getString("name"),
|
||||
kind = component.optString("kind"),
|
||||
state = component.optString("state").ifEmpty { null },
|
||||
checking = component.optBoolean("checking", false),
|
||||
error = component.optString("error").ifEmpty { null },
|
||||
freshness = component.optString("freshness").ifEmpty { "unknown" },
|
||||
hasLogs = component.optBoolean("hasLogs", false),
|
||||
hasBuildLogs = component.optBoolean("hasBuildLogs", false),
|
||||
hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false),
|
||||
buildFailed = component.optBoolean("buildFailed", false),
|
||||
dataPath = component.optString("dataPath").ifEmpty { null },
|
||||
configPath = component.optString("configPath").ifEmpty { null },
|
||||
dataPresent = component.optBoolean("dataPresent", false),
|
||||
configPresent = component.optBoolean("configPresent", false),
|
||||
resourcesChecking = component.optBoolean("resourcesChecking", false),
|
||||
resourcesError = component.optString("resourcesError").ifEmpty { null },
|
||||
)
|
||||
},
|
||||
variants =
|
||||
(0 until variants.length()).map { j ->
|
||||
val variant = variants.getJSONObject(j)
|
||||
ApkVariant(
|
||||
path = variant.getString("path"),
|
||||
variant = variant.getString("variant"),
|
||||
mtime = variant.getDouble("mtime"),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/*
|
||||
* Which build of an app this device wants, when it wants a particular one.
|
||||
*
|
||||
* Per device, not per server. Two phones enrolled against one build
|
||||
* machine each look at the same projects, and one of them picking a
|
||||
* release build has no business changing what the other is offered -- so
|
||||
* the choice lives here and travels with the download request, rather than
|
||||
* being written into the server's config.
|
||||
*
|
||||
* Keyed by the project key, which the server promises never to change: it
|
||||
* is the same identifier the downloaded file is named after.
|
||||
*
|
||||
* The path is the server's, not this device's, and is checked there against
|
||||
* the builds it can actually see. Nothing here can name a file into
|
||||
* existence; a stale choice -- a variant deleted by a `gradlew clean` --
|
||||
* falls back to the newest build rather than failing.
|
||||
*/
|
||||
|
||||
private const val PREFS_NAME = "variants"
|
||||
|
||||
/** The build [key] is pinned to on this device, or null for "the newest". */
|
||||
fun chosenVariant(context: Context, key: String): String? =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, null)
|
||||
|
||||
/** Passing null goes back to "whatever is newest", which is the default. */
|
||||
fun chooseVariant(context: Context, key: String, path: String?) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (path == null) {
|
||||
prefs.edit().remove(key).apply()
|
||||
} else {
|
||||
prefs.edit().putString(key, path).apply()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a project's choice, for one being removed -- otherwise a key reused by a later project
|
||||
* would inherit a preference nobody set.
|
||||
*/
|
||||
fun forgetVariant(context: Context, key: String) = chooseVariant(context, key, null)
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.example.devupdater
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* "The server hasn't finished working this out."
|
||||
*
|
||||
* Small enough to sit inline beside the thing it qualifies, so it can be put next to whichever
|
||||
* value is still provisional rather than covering a whole screen. Anything the server decides in
|
||||
* the background gets one -- a value shown without it is meant to be read as settled.
|
||||
*
|
||||
* One composable rather than a size and a stroke width repeated per site, so every one of them
|
||||
* reads as the same mark.
|
||||
*/
|
||||
@Composable
|
||||
fun Working(modifier: Modifier = Modifier) {
|
||||
CircularProgressIndicator(
|
||||
modifier = modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
)
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<files-path name="apks" path="apks/" />
|
||||
</paths>
|
||||
Executable
+84
@@ -0,0 +1,84 @@
|
||||
#!/bin/sh
|
||||
# Builds the updater app's own APK.
|
||||
#
|
||||
# ./build-apk.sh
|
||||
#
|
||||
# The APK pins the CA on *this* machine ($XDG_CONFIG_HOME/dev-updater/certs/ca.pem,
|
||||
# or DEV_UPDATER_CA), so build it on the machine that runs the server: an app
|
||||
# built somewhere else trusts a CA that server can't present, and simply won't
|
||||
# connect. Start dev-updater once first if there are no certificates yet -- it
|
||||
# generates them --; the build stops with that instruction if it can't find one.
|
||||
#
|
||||
# Unlike ./run-android.sh, this touches no emulator: it only produces the file.
|
||||
# This is the updater itself, so a fresh install can't come *through* the
|
||||
# updater: serve it over the plain-HTTP bootstrap port instead
|
||||
# (dev-updater --download) and open http://<this machine>:8091 on the phone.
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# Prefer an SDK this machine has already configured -- the host and the dev
|
||||
# VM don't keep it in the same place, and android-env.sh is written for the
|
||||
# VM's layout (it also installs missing packages, which isn't wanted here).
|
||||
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}" ]; then
|
||||
echo "==> Using ANDROID_HOME=$ANDROID_HOME"
|
||||
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}" ]; then
|
||||
ANDROID_HOME="$ANDROID_SDK_ROOT"
|
||||
export ANDROID_HOME
|
||||
echo "==> Using ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT"
|
||||
elif [ -d "$HOME/Android/Sdk" ]; then
|
||||
ANDROID_HOME="$HOME/Android/Sdk"
|
||||
ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export ANDROID_HOME ANDROID_SDK_ROOT
|
||||
echo "==> Using $ANDROID_HOME"
|
||||
else
|
||||
echo "No Android SDK found. Set ANDROID_HOME to it, or install one" >&2
|
||||
echo "(Android Studio's default location is ~/Android/Sdk)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CA="${DEV_UPDATER_CA:-${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/certs/ca.pem}"
|
||||
if [ -f "$CA" ]; then
|
||||
# Printed so a wrong or stale certificate is visible here rather than as
|
||||
# a handshake failure on the phone: it should match the CA the server
|
||||
# you are going to talk to generated, which is the one in the directory
|
||||
# named above unless DEV_UPDATER_CA points elsewhere.
|
||||
FINGERPRINT=$(openssl x509 -in "$CA" -pubkey -noout 2>/dev/null \
|
||||
| openssl pkey -pubin -outform der 2>/dev/null \
|
||||
| openssl dgst -sha256 -binary 2>/dev/null \
|
||||
| openssl base64 2>/dev/null || echo "(openssl unavailable)")
|
||||
echo "==> Pinning the CA at $CA"
|
||||
echo " fingerprint: $FINGERPRINT"
|
||||
else
|
||||
echo "No CA certificate at $CA -- run the server on this machine" >&2
|
||||
echo "first, or set DEV_UPDATER_CA to one. The APK has to embed it at build time." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Building"
|
||||
# Dev Updater ships a wrapper that turns a Gradle build into the
|
||||
# `@@progress done/total` lines its cards draw a real bar from, and points
|
||||
# $DEV_UPDATER_PROGRESS at it when it is the one running this. Gradle
|
||||
# cannot report a count any other way -- see the wrapper's own header --
|
||||
# and having it there rather than here is what stops every project copying
|
||||
# the same twenty-five lines of counting. Built by hand, the variable is
|
||||
# unset and the build simply runs.
|
||||
if [ -x "${DEV_UPDATER_PROGRESS:-}" ]; then
|
||||
"$DEV_UPDATER_PROGRESS" gradle ./gradlew :androidApp:assembleDebug
|
||||
else
|
||||
./gradlew :androidApp:assembleDebug
|
||||
fi
|
||||
|
||||
APK="$SCRIPT_DIR/androidApp/build/outputs/apk/debug/androidApp-debug.apk"
|
||||
echo
|
||||
echo "==> Built $APK"
|
||||
[ -f "$APK" ] && ls -lh "$APK" | awk '{print " " $5}'
|
||||
echo
|
||||
echo "To get it onto the phone:"
|
||||
echo " - if the installed copy still trusts this CA, hit Update on the"
|
||||
echo " 'Dev Updater' entry in the app itself;"
|
||||
echo " - if the CA was regenerated, the installed copy can no longer reach"
|
||||
echo " the server, so reinstall over the bootstrap port instead:"
|
||||
echo " dev-updater --download"
|
||||
echo " then open http://<this machine>:8091 in the phone's browser."
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rebuilds androidApp/src/main/res/font/nerd_icons.ttf.
|
||||
#
|
||||
# The app draws a handful of icons -- a folder, a git branch, a server, a
|
||||
# cog -- as text in a Nerd Fonts glyph, rather than as vector assets or as
|
||||
# ordinary Unicode. Unicode has no character for most of these, and the
|
||||
# ones it does have are not reliably in an Android system font, so they
|
||||
# land as tofu boxes on somebody's phone.
|
||||
#
|
||||
# The whole symbols font is 2.5 MB for the handful below, so what is
|
||||
# committed is a subset. Add a codepoint to GLYPHS below and to NerdIcons.kt (the two
|
||||
# lists have to agree -- a codepoint in the Kotlin but not here is a glyph
|
||||
# that silently doesn't exist), then run this and commit the result.
|
||||
#
|
||||
# Needs python3 and network access; fontTools is fetched into a temporary
|
||||
# venv, so nothing has to be installed on the machine.
|
||||
set -euo pipefail
|
||||
|
||||
# Codepoint, then the Nerd Fonts glyph name it came from. All from the
|
||||
# Material Design Icons set bar one, so they look like one family; the
|
||||
# exception is noted on its own line.
|
||||
GLYPHS=(
|
||||
U+F024B # md-folder
|
||||
U+F062C # md-source_branch
|
||||
U+F233 # fa-server
|
||||
U+F0493 # md-cog
|
||||
U+F0415 # md-plus
|
||||
U+F0450 # md-refresh
|
||||
U+F0A7A # md-trash_can_outline
|
||||
U+F02D # fa-book
|
||||
)
|
||||
|
||||
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
|
||||
out="$(cd "$(dirname "$0")" && pwd)/androidApp/src/main/res/font/nerd_icons.ttf"
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
echo "Fetching $url"
|
||||
curl -fsSL -o "$work/nf.zip" "$url"
|
||||
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
|
||||
|
||||
python3 -m venv "$work/venv"
|
||||
"$work/venv/bin/pip" -q install fonttools
|
||||
|
||||
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
|
||||
mkdir -p "$(dirname "$out")"
|
||||
# The Mono face: every glyph gets the same advance, so an icon occupies the
|
||||
# same width whichever one it is and a row of them lines up with the row
|
||||
# above. The proportional face varies the advance per glyph, which puts the
|
||||
# text after each icon at a slightly different place.
|
||||
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
|
||||
--unicodes="$unicodes" \
|
||||
--layout-features= \
|
||||
--drop-tables+=DSIG \
|
||||
--output-file="$out"
|
||||
|
||||
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
|
||||
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication) apply false
|
||||
alias(libs.plugins.androidLibrary) apply false
|
||||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.composeCompiler) apply false
|
||||
// Applied here as well as in :androidApp, because a project only
|
||||
// formats its own files -- this one owns the two build scripts at the
|
||||
// root, and that module owns the Kotlin.
|
||||
alias(libs.plugins.ktfmt)
|
||||
}
|
||||
|
||||
// ktfmt offers exactly two styles. This is the one in kotlinlang.org's
|
||||
// coding conventions; the other is Google's 2-space internal style.
|
||||
// Picked as the language's own standard rather than because it matches
|
||||
// what is here -- matching is a consequence, and would be the wrong
|
||||
// reason (code rule 27).
|
||||
ktfmt { kotlinLangStyle() }
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/bin/sh
|
||||
# Enrols the Android emulator against this machine's dev-updater, without a
|
||||
# camera and without a QR code.
|
||||
#
|
||||
# ./app/enroll-emulator.sh [--host 10.0.2.2] [--port 8090] [--avd dev-updater]
|
||||
#
|
||||
# Enrolling normally means scanning the QR the server prints. There is no
|
||||
# camera on a headless emulator, so this uses the other path the app already
|
||||
# supports: the `devupdater://enroll` intent that MainActivity handles for
|
||||
# camera apps that redirect a scanned URI. Firing it with `am start` is the
|
||||
# whole trick.
|
||||
#
|
||||
# It is idempotent. A token for the emulator is generated once and kept in
|
||||
# $XDG_DATA_HOME/dev-updater/emulator-token; later runs reuse it, so
|
||||
# re-enrolling after reinstalling the app costs nothing and never touches
|
||||
# the token belonging to a real phone.
|
||||
#
|
||||
# Two things that cost an afternoon each, written down so they don't again:
|
||||
#
|
||||
# 1. **`adb shell am start -d "...?a=1&b=2"` silently loses everything
|
||||
# after the first `&`.** The URI is handed to the *device's* shell,
|
||||
# which treats `&` as "run in background" no matter how carefully it was
|
||||
# quoted on this side. The symptom is an intent that starts the app and
|
||||
# enrols nothing, with no error anywhere. Escape them: `\&`.
|
||||
#
|
||||
# 2. **More than one emulator can be attached, and then bare `adb` fails.**
|
||||
# Every `adb` call here names a device, because `adb get-state` and
|
||||
# `adb shell` both exit 1 with "more than one device/emulator" the
|
||||
# moment a second AVD is running -- and the old version read that as
|
||||
# "no emulator is running", which is the opposite of what happened and
|
||||
# sends you off to start a third. The device is chosen by AVD *name*
|
||||
# rather than by taking the only one attached, so it cannot enrol
|
||||
# somebody else's emulator by accident.
|
||||
#
|
||||
# 3. **The server has to be listening somewhere the emulator can reach.**
|
||||
# Inside the emulator, 10.0.2.2 is this machine. dev-updater binds wg0
|
||||
# and nothing else by default, which the emulator has no route to, so
|
||||
# the app reports the server as unreachable. Start it with
|
||||
# `--bind 0.0.0.0` for the duration of the test. The CA already covers
|
||||
# 10.0.2.2 -- `local_addresses` in main.rs puts it there deliberately --
|
||||
# so TLS is not the problem, and a TLS error means something else.
|
||||
set -eu
|
||||
|
||||
HOST=10.0.2.2
|
||||
PORT=8090
|
||||
# Defaulted from the environment the same way run-android.sh reads it, so
|
||||
# the two agree about which emulator "the emulator" means; the flag is here
|
||||
# because this script already takes its other settings that way.
|
||||
AVD_NAME="${AVD_NAME:-dev-updater}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--host) HOST=$2; shift 2 ;;
|
||||
--port) PORT=$2; shift 2 ;;
|
||||
--avd) AVD_NAME=$2; shift 2 ;;
|
||||
*) echo "usage: $0 [--host H] [--port P] [--avd NAME]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PACKAGE=com.example.devupdater
|
||||
CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/dev-updater/config.ron"
|
||||
STATE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater"
|
||||
TOKEN_FILE="$STATE_DIR/emulator-token"
|
||||
|
||||
command -v adb >/dev/null 2>&1 || {
|
||||
echo "adb is not on PATH -- add \$HOME/Android/Sdk/platform-tools." >&2
|
||||
exit 1
|
||||
}
|
||||
# Prints the adb serial of a running instance of AVD "$1", or nothing.
|
||||
# Lifted from run-android.sh, which has always had to do this: `adb -e`
|
||||
# works only when exactly one emulator is attached and cannot tell ours
|
||||
# apart from somebody else's.
|
||||
avd_serial() {
|
||||
for s in $(adb devices | awk '$2 == "device" {print $1}'); do
|
||||
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
|
||||
echo "$s"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
SERIAL=$(avd_serial "$AVD_NAME")
|
||||
if [ -z "$SERIAL" ]; then
|
||||
# Three different situations, and saying the wrong one costs an
|
||||
# afternoon: nothing running, something running that isn't this AVD,
|
||||
# or adb itself not answering. The list is what tells them apart, so
|
||||
# it is printed rather than described.
|
||||
attached=$(adb devices | awk '$2 == "device" {print $1}')
|
||||
if [ -z "$attached" ]; then
|
||||
echo "No emulator is running. Start one with app/run-android.sh first." >&2
|
||||
else
|
||||
echo "No emulator named '$AVD_NAME' is running. These are attached:" >&2
|
||||
for s in $attached; do
|
||||
echo " $s ($(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r'))" >&2
|
||||
done
|
||||
echo "Pass --avd NAME to pick one, or start '$AVD_NAME' with app/run-android.sh." >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Using $SERIAL (AVD '$AVD_NAME')"
|
||||
|
||||
# Every adb call from here names the device: with a second emulator
|
||||
# attached, a bare one exits 1 rather than picking.
|
||||
adb() { command adb -s "$SERIAL" "$@"; }
|
||||
|
||||
# Generated once and kept, so this script can be run again after a
|
||||
# reinstall without adding a second entry to the server's config every time.
|
||||
if [ ! -f "$TOKEN_FILE" ]; then
|
||||
mkdir -p "$STATE_DIR"
|
||||
# Alphanumeric only: the token goes in a URI, and anything needing
|
||||
# percent-encoding would have to survive two shells to get there.
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 > "$TOKEN_FILE"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
echo "==> Generated an emulator token in $TOKEN_FILE"
|
||||
fi
|
||||
TOKEN=$(cat "$TOKEN_FILE")
|
||||
HASH=$(printf %s "$TOKEN" | sha256sum | cut -d' ' -f1)
|
||||
|
||||
# Only the hash is stored server-side, so this is what the config needs.
|
||||
RESTART_NEEDED=no
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
echo "No config at $CONFIG -- start dev-updater once to create it." >&2
|
||||
exit 1
|
||||
elif grep -q "$HASH" "$CONFIG"; then
|
||||
echo "==> The server already knows this token"
|
||||
else
|
||||
# Inserted rather than replacing the token list: a real phone's
|
||||
# enrolment lives in the same list and must survive this.
|
||||
tmp=$(mktemp)
|
||||
awk -v hash="$HASH" '
|
||||
{ print }
|
||||
/^tokens: \[/ && !done {
|
||||
print " ("
|
||||
print " name: \"emulator\","
|
||||
print " sha256: \"" hash "\","
|
||||
print " ),"
|
||||
done = 1
|
||||
}
|
||||
' "$CONFIG" > "$tmp"
|
||||
cp "$tmp" "$CONFIG"
|
||||
rm -f "$tmp"
|
||||
chmod 600 "$CONFIG"
|
||||
echo "==> Added an 'emulator' token to $CONFIG"
|
||||
RESTART_NEEDED=yes
|
||||
fi
|
||||
|
||||
if [ "$RESTART_NEEDED" = yes ]; then
|
||||
# The server reads its config at startup, so a token added underneath a
|
||||
# running one is not yet a token it will accept.
|
||||
echo "==> Restart dev-updater now so it reads the new token, then re-run this."
|
||||
echo " (the running server, if any, still has the old list in memory)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# force-stop first: an already-running activity receives this through
|
||||
# onNewIntent, and whether that path enrols is not something to depend on.
|
||||
adb shell am force-stop "$PACKAGE" >/dev/null 2>&1 || true
|
||||
before=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none)
|
||||
|
||||
# The backslashes are load-bearing -- see the note at the top.
|
||||
adb shell am start -a android.intent.action.VIEW \
|
||||
-d "devupdater://enroll?host=$HOST\&port=$PORT\&token=$TOKEN" >/dev/null 2>&1
|
||||
|
||||
# The app seals the token under a Keystore key before writing it, so the
|
||||
# stored blob differs even for the same token. Changed is the signal; equal
|
||||
# means the intent never landed.
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 15 ]; do
|
||||
sleep 1
|
||||
after=$(adb shell run-as "$PACKAGE" cat shared_prefs/server.xml 2>/dev/null || echo none)
|
||||
if [ "$after" != "$before" ]; then
|
||||
echo "==> Enrolled against $HOST:$PORT"
|
||||
echo " If the app still says the server is unreachable, it is listening"
|
||||
echo " on the wrong interface: restart it with --bind 0.0.0.0."
|
||||
exit 0
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "The app's stored settings did not change, so the intent did not land." >&2
|
||||
echo "Check that $PACKAGE is installed (app/run-android.sh) and try again." >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx2048M -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
kotlin.code.style=official
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,42 @@
|
||||
[versions]
|
||||
agp = "9.3.2"
|
||||
kotlin = "2.4.10"
|
||||
compose-multiplatform = "1.12.0"
|
||||
# Compose Multiplatform's material3 is on its own release train, so it is
|
||||
# pinned separately rather than following the version above. Checked
|
||||
# 2026-08-28: the newest material3 is 1.12.0-alpha03, so 1.9.0 is still
|
||||
# the current *stable* one while runtime/foundation/ui are stable at
|
||||
# 1.12.0. Not a stale pin -- the two trains are simply this far apart.
|
||||
compose-material3 = "1.9.0"
|
||||
# The Gradle wrapper around ktfmt. Checked against the Gradle Plugin
|
||||
# Portal 2026-08-28: 0.27.0 is the latest, published 2026-08-03.
|
||||
ktfmt-gradle = "0.27.0"
|
||||
androidx-activityCompose = "1.13.0"
|
||||
# Checked against Maven Central 2026-08-25.
|
||||
zxing-embedded = "4.3.0"
|
||||
androidx-core-ktx = "1.17.0"
|
||||
|
||||
[libraries]
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
|
||||
# In-app QR scanner: a ready-made scanning Activity (camera preview, runtime
|
||||
# permission prompt, flashlight toggle) reached through the AndroidX Activity
|
||||
# Result API (ScanContract, added in 4.3.0). Fully offline -- no Play
|
||||
# Services / ML Kit model download involved.
|
||||
zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing-embedded" }
|
||||
# Required by the shared link library, which uses the KTX `edit` block.
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
|
||||
# Declared directly rather than through the `compose.*` accessors, which
|
||||
# Compose Multiplatform 1.11 deprecates.
|
||||
compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
|
||||
compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
|
||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-material3" }
|
||||
|
||||
[plugins]
|
||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||
# For the shared link subproject: a subproject resolves plugin versions
|
||||
# from the build including it rather than from its own.
|
||||
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ktfmt = { id = "com.ncorti.ktfmt.gradle", version.ref = "ktfmt-gradle" }
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/bin/sh
|
||||
# Builds and runs this app on an emulator, creating/booting the AVD first if
|
||||
# it isn't already up.
|
||||
#
|
||||
# Environment setup (SDK location, PATH, ...) lives in ./android-env.sh,
|
||||
# which can also be sourced directly for one-off commands; see its header
|
||||
# comment. The emulator handling below is inline rather than in its own
|
||||
# shared script because this repo has exactly one app, so there's no second
|
||||
# caller to share it with.
|
||||
#
|
||||
# Goes through classic avdmanager/emulator/adb rather than the newer
|
||||
# `android` CLI's emulator subsystem: that one manages its own AVD pool and
|
||||
# would start a *second* instance alongside whatever is already running.
|
||||
set -eu
|
||||
|
||||
APP_ID="com.example.devupdater"
|
||||
|
||||
# One AVD per repository, named after it, which is how the other Android
|
||||
# projects on this machine are set up: two sessions working in two repos
|
||||
# otherwise fight over one emulator instance, and neither can tell that the
|
||||
# app it just installed was replaced by the other one's.
|
||||
# This app installs *other* projects' builds, so exercising it needs
|
||||
# something to install -- but that something should be a throwaway app you
|
||||
# control, added to the list like any other project, not another repo's real
|
||||
# app sharing this emulator. AVD_NAME=... overrides the name if you do need
|
||||
# a second one.
|
||||
AVD_NAME="${AVD_NAME:-dev-updater}"
|
||||
DEVICE_PROFILE="${DEVICE_PROFILE:-pixel_10}"
|
||||
SYSTEM_IMAGE="${SYSTEM_IMAGE:-system-images;android-36;google_apis;x86_64}"
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# shellcheck source=./android-env.sh
|
||||
. ./android-env.sh
|
||||
|
||||
# Prints the adb serial of a running instance of AVD "$1", or nothing. Unlike
|
||||
# `adb -e` (which only works when exactly one emulator is attached, and
|
||||
# can't tell $AVD_NAME apart from some other AVD attached separately), this
|
||||
# checks by name so it can't mistake someone else's emulator for ours.
|
||||
avd_serial() {
|
||||
for s in $(adb devices | awk '$2 == "device" {print $1}'); do
|
||||
if [ "$(adb -s "$s" emu avd name 2>/dev/null | head -n1 | tr -d '\r')" = "$1" ]; then
|
||||
echo "$s"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
echo "==> Ensuring emulator system image is installed"
|
||||
android sdk install emulator "$SYSTEM_IMAGE" || echo " (non-fatal: see above)"
|
||||
|
||||
if [ ! -f "$ANDROID_AVD_HOME/$AVD_NAME.ini" ]; then
|
||||
echo "==> Creating AVD '$AVD_NAME' ($DEVICE_PROFILE, $SYSTEM_IMAGE)"
|
||||
echo no | avdmanager create avd \
|
||||
-n "$AVD_NAME" \
|
||||
-k "$SYSTEM_IMAGE" \
|
||||
--device "$DEVICE_PROFILE" \
|
||||
--sdcard 512M
|
||||
else
|
||||
echo "==> Reusing existing AVD '$AVD_NAME'"
|
||||
fi
|
||||
|
||||
# avdmanager defaults new AVDs to hw.keyboard=no, which disables forwarding
|
||||
# the host keyboard into the emulator and leaves you dependent on the
|
||||
# on-screen keyboard. Force it on so typing works -- this app has a
|
||||
# free-text path field, so that matters more here than most.
|
||||
CONFIG_INI="$ANDROID_AVD_HOME/$AVD_NAME.avd/config.ini"
|
||||
if [ -f "$CONFIG_INI" ]; then
|
||||
grep -v '^hw\.keyboard=' "$CONFIG_INI" >"$CONFIG_INI.tmp"
|
||||
echo "hw.keyboard=yes" >>"$CONFIG_INI.tmp"
|
||||
mv "$CONFIG_INI.tmp" "$CONFIG_INI"
|
||||
fi
|
||||
|
||||
SERIAL=$(avd_serial "$AVD_NAME")
|
||||
if [ -n "$SERIAL" ]; then
|
||||
echo "==> Emulator '$AVD_NAME' already running ($SERIAL)"
|
||||
else
|
||||
# Clean up a stray/crashed process for this AVD, if any, so it doesn't
|
||||
# end up with two instances fighting over the same AVD directory. The
|
||||
# bracketed first character keeps the pattern from matching the shell
|
||||
# running this script, which has the pattern text on its own command
|
||||
# line -- unbracketed, this kills that shell mid-run.
|
||||
pkill -f "[e]mulator.*-avd $AVD_NAME" >/dev/null 2>&1 || true
|
||||
|
||||
EMU_LOG="/tmp/$AVD_NAME-emulator.log"
|
||||
: >"$EMU_LOG"
|
||||
# Real GPU acceleration when a display is available; headless software
|
||||
# rendering otherwise (e.g. a VM with no display attached).
|
||||
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
|
||||
echo "==> Starting emulator '$AVD_NAME' with GPU acceleration (-gpu host)"
|
||||
emulator -avd "$AVD_NAME" -gpu host -no-audio >"$EMU_LOG" 2>&1 &
|
||||
else
|
||||
echo "==> No display available (DISPLAY/WAYLAND_DISPLAY unset) -- starting" \
|
||||
"emulator '$AVD_NAME' headless with software rendering (-gpu swiftshader_indirect)"
|
||||
emulator -avd "$AVD_NAME" -gpu swiftshader_indirect -no-audio -no-window \
|
||||
>"$EMU_LOG" 2>&1 &
|
||||
fi
|
||||
EMU_PID=$!
|
||||
|
||||
i=0
|
||||
booted=""
|
||||
while [ "$i" -lt 150 ]; do
|
||||
if ! kill -0 "$EMU_PID" 2>/dev/null; then
|
||||
echo "Emulator process exited unexpectedly. Log output:" >&2
|
||||
cat "$EMU_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
SERIAL=$(avd_serial "$AVD_NAME")
|
||||
if [ -n "$SERIAL" ]; then
|
||||
booted=$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
|
||||
[ "$booted" = "1" ] && break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 2
|
||||
done
|
||||
if [ "$booted" != "1" ]; then
|
||||
echo "Emulator did not finish booting in time. Log output:" >&2
|
||||
cat "$EMU_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "==> Building debug APK"
|
||||
./gradlew :androidApp:assembleDebug
|
||||
|
||||
APK="androidApp/build/outputs/apk/debug/androidApp-debug.apk"
|
||||
echo "==> Installing and launching $APK"
|
||||
adb -s "$SERIAL" install -r "$APK"
|
||||
adb -s "$SERIAL" shell am start -n "$APP_ID/.MainActivity"
|
||||
@@ -0,0 +1,25 @@
|
||||
rootProject.name = "DevUpdater"
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
include(":androidApp")
|
||||
|
||||
// The shared link, as a subproject of this build rather than a published
|
||||
// artifact, so it stays locked to whatever commit the submodule points at
|
||||
// -- the same arrangement the Rust half uses with a path dependency.
|
||||
include(":link")
|
||||
|
||||
project(":link").projectDir = file("../vendor/wg-app-link/app")
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Rebuilds this server and its app, in the order a self-update needs.
|
||||
#
|
||||
# For running by hand. The "Dev Updater" card no longer calls this: the two
|
||||
# halves are declared as a Server and an Apk component in
|
||||
# app/.dev-updater.ron, and a build walks components in order, so the order
|
||||
# below is expressed there now.
|
||||
#
|
||||
# Still has two callers, so it stays: ./start.sh builds through it, and a
|
||||
# server started before the components change has the old declaration in
|
||||
# memory pointing straight here -- the pull that carries that change is
|
||||
# built from it.
|
||||
#
|
||||
# The APK is built last and deliberately after the binary: if the build
|
||||
# fails, the phone keeps being offered the APK it already had rather than
|
||||
# a half-updated pair.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "==> Building the server"
|
||||
cargo build --release --manifest-path server/Cargo.toml
|
||||
|
||||
echo "==> Building the app"
|
||||
./app/build-apk.sh
|
||||
@@ -0,0 +1,18 @@
|
||||
// This project's own resources: the values it would otherwise have to
|
||||
// write down in several places.
|
||||
//
|
||||
// Structured as the body of the struct, with no outer parentheses, which
|
||||
// is the house rule every RON file here follows -- `wg_app_link::format`
|
||||
// is the only thing that knows it, so both this project's code and Dev
|
||||
// Updater read the file the same way.
|
||||
//
|
||||
// Dev Updater reads the keys it understands (see `Resources` in
|
||||
// `server/src/config.rs`) and ignores the rest, so anything else this
|
||||
// project needs to keep in one place belongs here too.
|
||||
|
||||
// What this project calls itself for the purpose of keeping state, which
|
||||
// is where `~/.local/share/dev-updater` and `~/.config/dev-updater` come
|
||||
// from. Not the crate name, which answers to a registry's namespace
|
||||
// rather than to this; and not the label, which is what a person reads on
|
||||
// a card.
|
||||
name: "dev-updater",
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Runs this repo's tests. Extra arguments are forwarded to `cargo test`,
|
||||
# e.g. `./run-tests.sh discover` to run just the discovery tests.
|
||||
#
|
||||
# Only `server/` has tests: it holds all the logic worth testing (APK
|
||||
# discovery, config round-tripping, staleness, the git read/pull/check
|
||||
# split, token gating, certificate generation), while the Android app is
|
||||
# UI over its HTTP API. Verifying the app means running it -- see the
|
||||
# README.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/server"
|
||||
exec cargo test "$@"
|
||||
Generated
+1981
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "dev-updater"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "dev-updater"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The private link between a phone and a machine you run: the WireGuard
|
||||
# binding, the CA the app pins, QR enrollment, owner-only file creation and
|
||||
# the RON house rules. Shared with ai-app as a submodule rather than a
|
||||
# published crate, so the two cannot end up on versions of it that disagree
|
||||
# -- a pull that moves one moves the other.
|
||||
wg-app-link = { path = "../vendor/wg-app-link/server" }
|
||||
axum = { version = "0.8", features = ["json"] }
|
||||
axum-server = { version = "0.8", features = ["tls-rustls"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "io-util"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
zip = "8"
|
||||
serde_json = "1"
|
||||
tempfile = "3"
|
||||
# The format of both this server's own config and the file a project carries
|
||||
# to describe itself -- both read and edited by hand, where comments earn
|
||||
# their keep, and both describing things that are genuinely sums (a
|
||||
# component is an APK or a server, not a tagged bag of every field). TOML
|
||||
# can express the second only as a tag plus flat sibling keys, which nothing
|
||||
# checks until run time. serde_json is still here for the HTTP surface.
|
||||
ron = "0.12.2"
|
||||
|
||||
[dev-dependencies]
|
||||
# ServiceExt::oneshot, to drive the auth middleware without a socket.
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
# Setting an explicit mtime is the only way to test "newest build wins"
|
||||
# deterministically -- files written in the same instant tie.
|
||||
filetime = "0.2"
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Reading an APK's own identity -- package name and display label -- out of
|
||||
//! the built file, so adding an app needs nothing but a path.
|
||||
//!
|
||||
//! The package name is not cosmetic: it is what the updater app looks up
|
||||
//! with `PackageManager` to tell "already installed this build" apart from
|
||||
//! "update available", so an app whose package can't be read can't be
|
||||
//! served usefully and is rejected at add time rather than showing up as a
|
||||
//! permanently-out-of-date card.
|
||||
//!
|
||||
//! This shells out to `aapt2`, which costs a process spawn (tens of
|
||||
//! milliseconds), so it never runs on the manifest path. It runs when an
|
||||
//! app is added, and off the request path after a download, which is the
|
||||
//! one moment this server can notice that a local rebuild changed what the
|
||||
//! APK installs over. The answer is cached on the component in the config
|
||||
//! (`Component::Apk`'s `package`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
use crate::sdk;
|
||||
|
||||
pub struct ApkInfo {
|
||||
pub package: String,
|
||||
/// The APK's own `android:label`, when it has one -- what the user sees
|
||||
/// on the device, so a better card title than the directory name.
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
pub fn read(apk: &Path) -> Result<ApkInfo> {
|
||||
let output = Command::new(sdk::aapt2_path()?)
|
||||
.arg("dump")
|
||||
.arg("badging")
|
||||
.arg(apk)
|
||||
.output()
|
||||
.context("failed to spawn aapt2")?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"aapt2 couldn't read {} as an APK: {}",
|
||||
apk.display(),
|
||||
String::from_utf8_lossy(&output.stderr).trim(),
|
||||
);
|
||||
}
|
||||
let badging = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
let package = badging
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("package:"))
|
||||
.and_then(|rest| quoted_value(rest, "name="))
|
||||
.with_context(|| format!("no package name in aapt2's output for {}", apk.display()))?;
|
||||
|
||||
// `application-label:'X'` is the default-locale label; `application:`
|
||||
// carries the same thing plus the icon. Either is fine, and which one
|
||||
// appears depends on the aapt2 version, so accept whichever is present.
|
||||
let label = badging
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("application-label:"))
|
||||
.and_then(|rest| {
|
||||
rest.trim()
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.strip_suffix('\''))
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.or_else(|| {
|
||||
badging
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("application:"))
|
||||
.and_then(|rest| quoted_value(rest, "label="))
|
||||
})
|
||||
.filter(|label| !label.is_empty());
|
||||
|
||||
Ok(ApkInfo { package, label })
|
||||
}
|
||||
|
||||
/// Pulls `key='value'` out of one of aapt2's space-separated badging lines.
|
||||
fn quoted_value(line: &str, key: &str) -> Option<String> {
|
||||
let rest = line.split_once(key)?.1;
|
||||
let rest = rest.strip_prefix('\'')?;
|
||||
let (value, _) = rest.split_once('\'')?;
|
||||
Some(value.to_owned())
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Bearer-token auth for the TLS listener's whole surface.
|
||||
//!
|
||||
//! Everything on that listener either is, or decides, what gets handed to
|
||||
//! `REQUEST_INSTALL_PACKAGES` next, and the management routes can repoint
|
||||
//! the scanner, add apps by path, and trigger a configured build command.
|
||||
//! Pinning authenticates this server to the phone but never the phone to
|
||||
//! this server, so the token supplies the other direction; binding the
|
||||
//! WireGuard interface narrows who can try at all, and this narrows it to
|
||||
//! who was enrolled.
|
||||
//!
|
||||
//! The middleware is applied once around the whole router (including the
|
||||
//! fallback) in `main.rs`, never per-route, so a new route can't forget
|
||||
//! it. The bootstrap listener is deliberately *not* wrapped: it exists for
|
||||
//! a browser that has nothing to authenticate with yet, and serves only
|
||||
//! this app's own APK.
|
||||
//!
|
||||
//! Nothing in this module -- and nothing anywhere else -- may log the
|
||||
//! Authorization header or the token; `token_is_never_logged` below holds a
|
||||
//! tripwire against a logging change silently starting to.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::registry::AppState;
|
||||
use axum::extract::{ConnectInfo, Request, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
/// Applied to every rejection. Not against brute force -- infeasible at 256
|
||||
/// bits -- but so a scanner probing the port shows up as a slow, loggable
|
||||
/// drip rather than a fast one.
|
||||
const REJECT_DELAY: Duration = Duration::from_millis(300);
|
||||
|
||||
/// The scheme the app registers for enrollment URIs.
|
||||
///
|
||||
/// The one product-specific thing about enrollment, which is why the
|
||||
/// shared implementation takes it as an argument rather than knowing it.
|
||||
const ENROLL_SCHEME: &str = "devupdater";
|
||||
|
||||
/// Re-exported rather than wrapped: `main` and the routes both enroll, and
|
||||
/// a wrapper here would only be a second name for the same function.
|
||||
pub use wg_app_link::enroll::{generate_token, token_hash_hex};
|
||||
|
||||
/// Prints the one-time enrollment QR: a `devupdater://enroll` URI carrying
|
||||
/// where to connect and the bearer token. The CA stays embedded in the APK,
|
||||
/// so this carries no trust material -- photographing the terminal leaks
|
||||
/// only the token, which is rotatable (`--rotate-token`).
|
||||
pub fn print_enrollment(host: IpAddr, port: u16, token: &str) -> anyhow::Result<()> {
|
||||
wg_app_link::enroll::print_enrollment(ENROLL_SCHEME, host, port, token)
|
||||
}
|
||||
|
||||
pub async fn require_token(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let presented = request
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.strip_prefix("Bearer "));
|
||||
if let Some(token) = presented {
|
||||
let hashes: Vec<String> = state
|
||||
.tokens()
|
||||
.into_iter()
|
||||
.map(|entry| entry.sha256)
|
||||
.collect();
|
||||
if wg_app_link::enroll::token_matches(token, &hashes) {
|
||||
return next.run(request).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Peer address only -- never the header value. Absent when there is no
|
||||
// real socket (tests driving the router directly).
|
||||
let peer = request
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|ConnectInfo(addr)| addr.to_string())
|
||||
.unwrap_or_else(|| "unknown peer".to_string());
|
||||
tracing::warn!("rejected request from {peer}: missing or invalid bearer token");
|
||||
tokio::time::sleep(REJECT_DELAY).await;
|
||||
(StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::routing::get;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use crate::config::TokenEntry;
|
||||
|
||||
fn state_with_token(dir: &std::path::Path, token: &str) -> Arc<AppState> {
|
||||
let state =
|
||||
Arc::new(AppState::new(dir.join("config.ron"), dir.join("app")).expect("state"));
|
||||
state
|
||||
.set_tokens(vec![TokenEntry {
|
||||
name: "phone".to_string(),
|
||||
sha256: token_hash_hex(token),
|
||||
}])
|
||||
.expect("set token");
|
||||
state
|
||||
}
|
||||
|
||||
fn guarded_router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/probe", get(|| async { "ok" }))
|
||||
.fallback(|| async { StatusCode::NOT_FOUND })
|
||||
.layer(axum::middleware::from_fn_with_state(state, require_token))
|
||||
}
|
||||
|
||||
fn request(path: &str, auth: Option<&str>) -> Request {
|
||||
let mut builder = axum::http::Request::builder().uri(path);
|
||||
if let Some(auth) = auth {
|
||||
builder = builder.header(header::AUTHORIZATION, auth);
|
||||
}
|
||||
builder.body(Body::empty()).expect("request")
|
||||
}
|
||||
|
||||
/// One test rather than separate gating and logging tests,
|
||||
/// deliberately: tracing caches callsite interest process-wide, so a
|
||||
/// test that hits the rejection path with no subscriber installed can
|
||||
/// poison the interest cache for the one that captures logs. Keeping
|
||||
/// every exercise of the middleware under the capturing subscriber
|
||||
/// makes the log assertions deterministic.
|
||||
#[tokio::test]
|
||||
async fn gates_every_route_and_never_logs_the_token() {
|
||||
#[derive(Clone, Default)]
|
||||
struct Capture(Arc<Mutex<Vec<u8>>>);
|
||||
impl std::io::Write for Capture {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(buf);
|
||||
Ok(buf.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
|
||||
type Writer = Capture;
|
||||
fn make_writer(&'a self) -> Capture {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
let capture = Capture::default();
|
||||
let subscriber = tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::TRACE)
|
||||
.with_writer(capture.clone())
|
||||
.finish();
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let token = generate_token();
|
||||
let router = guarded_router(state_with_token(dir.path(), &token));
|
||||
|
||||
// No header, wrong token, wrong scheme: 401 everywhere, including
|
||||
// paths that don't exist -- a scanner learns nothing.
|
||||
for (path, auth) in [
|
||||
("/probe", None),
|
||||
("/probe", Some("Bearer wrong".to_string())),
|
||||
("/probe", Some(format!("Basic {token}"))),
|
||||
("/no-such-route", None),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path, auth.as_deref()))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{path} {auth:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let ok = router
|
||||
.clone()
|
||||
.oneshot(request("/probe", Some(&format!("Bearer {token}"))))
|
||||
.await
|
||||
.expect("response");
|
||||
assert_eq!(ok.status(), StatusCode::OK);
|
||||
|
||||
// The tripwire that keeps a future logging change (e.g. logging
|
||||
// request headers) from silently leaking credentials.
|
||||
let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).into_owned();
|
||||
assert!(
|
||||
!logged.contains(&token),
|
||||
"the bearer token leaked into the logs: {logged}"
|
||||
);
|
||||
// The rejections themselves do get logged (that's the point).
|
||||
assert!(logged.contains("missing or invalid bearer token"));
|
||||
}
|
||||
}
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/bin/sh
|
||||
# Runs a build command and reports its progress to Dev Updater, for the
|
||||
# build systems that cannot be asked for it directly.
|
||||
#
|
||||
# <this> gradle ./gradlew :androidApp:assembleDebug
|
||||
#
|
||||
# Dev Updater draws a real bar from `@@progress done/total` lines on a
|
||||
# build's output, and ignores anything that is not exactly that shape. Most
|
||||
# tools can be made to report their own counts -- cargo does, once
|
||||
# `CARGO_TERM_PROGRESS_WHEN=always` is set, which the server sets for every
|
||||
# build it runs, so a cargo build needs nothing and must not be wrapped in
|
||||
# this. Gradle is the one that cannot, and this is why this file exists
|
||||
# rather than the knowledge being copied into each project's build script.
|
||||
#
|
||||
# The server writes this out and points `$DEV_UPDATER_PROGRESS` at it, so a
|
||||
# project's script uses it when it is being built by Dev Updater and runs
|
||||
# the command plainly when somebody is building by hand:
|
||||
#
|
||||
# if [ -x "${DEV_UPDATER_PROGRESS:-}" ]; then
|
||||
# "$DEV_UPDATER_PROGRESS" gradle ./gradlew :androidApp:assembleDebug
|
||||
# else
|
||||
# ./gradlew :androidApp:assembleDebug
|
||||
# fi
|
||||
#
|
||||
# Exits with the build's own status, which is the thing that must not be
|
||||
# lost: a wrapper that reports a failed build as a success is worse than no
|
||||
# wrapper.
|
||||
set -eu
|
||||
|
||||
FLAVOUR="${1:?usage: $0 <gradle> <command...>}"
|
||||
shift
|
||||
|
||||
case "$FLAVOUR" in
|
||||
gradle)
|
||||
# Gradle cannot be asked for a count directly. An init script using
|
||||
# taskGraph.afterTask is rejected outright by the configuration
|
||||
# cache, and whenReady never fires on a cache hit. Its rich console
|
||||
# does print a percentage, but only as a full-screen redraw --
|
||||
# cursor movement, erases and IDLE lines -- which would make the
|
||||
# output unreadable, and the output is the other half of what the
|
||||
# card shows.
|
||||
#
|
||||
# --dry-run costs about a second, is cache-friendly, and prints one
|
||||
# ":task SKIPPED" line per task the real build will run, which is
|
||||
# exactly the total. The build then prints one "> Task :x" line per
|
||||
# task as it goes, so counting those against it is the whole
|
||||
# mechanism.
|
||||
#
|
||||
# Task count is not time -- compileDebugKotlin and dexBuilder are
|
||||
# most of the wall clock -- so the bar moves unevenly. It is still
|
||||
# counted work rather than a guess at how long last time took.
|
||||
TASKS=$("$@" --dry-run --console=plain 2>/dev/null |
|
||||
grep -c '^:[A-Za-z:]* SKIPPED' || true)
|
||||
if [ "${TASKS:-0}" -le 0 ]; then
|
||||
# No total, so no honest bar. The build still runs and still
|
||||
# prints; the card shows a bar that only spins, which is what
|
||||
# not knowing looks like.
|
||||
exec "$@"
|
||||
fi
|
||||
echo "@@progress 0/$TASKS"
|
||||
"$@" --console=plain 2>&1 | (
|
||||
DONE=0
|
||||
while IFS= read -r line; do
|
||||
echo "$line"
|
||||
case "$line" in
|
||||
"> Task "*)
|
||||
DONE=$((DONE + 1))
|
||||
echo "@@progress $DONE/$TASKS"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
)
|
||||
# The pipeline's status is the subshell's, not the build's, so ask
|
||||
# again rather than reporting a failed build as a success. It is up
|
||||
# to date by now, so this costs a second.
|
||||
exec "$@" --console=plain >/dev/null
|
||||
;;
|
||||
*)
|
||||
echo "$0: unknown build system '$FLAVOUR'" >&2
|
||||
echo "cargo reports its own progress and needs no wrapper." >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,363 @@
|
||||
//! Asking something slow in the background, so the request path never
|
||||
//! waits for it.
|
||||
//!
|
||||
//! Two things here are asked about a project without being fast enough to
|
||||
//! ask while answering a request: whether a checkout's remote has commits
|
||||
//! it hasn't (a network round trip), and what a service is doing (a
|
||||
//! process spawn). `/manifest` is fetched on every open, resume and
|
||||
//! Refresh, so neither may happen inside it.
|
||||
//!
|
||||
//! The arrangement both need is the same one, and it was written twice
|
||||
//! before this module existed. An answer lands *after* the response that
|
||||
//! started the work, so a card shows "still finding out" until the next
|
||||
//! look; a second request must not start a second worker for the same
|
||||
//! thing; and a failure must not be able to masquerade as an answer --
|
||||
//! which is the part that had already gone wrong once. A remote check that
|
||||
//! failed left `new_commits` at `false`, and a card with no badge is how
|
||||
//! this says "asked, and there is nothing new". The failure read as good
|
||||
//! news. So an entry keeps the last answer *and* why the last attempt
|
||||
//! produced none, and whatever displays it can say both.
|
||||
//!
|
||||
//! Being one mechanism matters more than the lines it saves: the counting
|
||||
//! of what is still outstanding was once written against remote checks
|
||||
//! alone, and service checks -- added later, in the same shape -- were
|
||||
//! left out of it. The symptom was a component's buttons missing after a
|
||||
//! restart, on whichever cards lost the race, with nothing looking broken.
|
||||
|
||||
use std::borrow::Borrow;
|
||||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// What one attempt learned.
|
||||
///
|
||||
/// Both halves are optional, and the combinations are all real: an answer
|
||||
/// with no error is success; an error with no answer is a failure that
|
||||
/// found out nothing; and *both* is a partial answer worth keeping
|
||||
/// alongside the reason it is not the whole one -- a service whose status
|
||||
/// could not be read while its logs could.
|
||||
pub struct Report<A> {
|
||||
/// What is now known. `None` leaves whatever was known before, because
|
||||
/// a failed attempt is a reason to keep showing the last answer rather
|
||||
/// than to claim ignorance.
|
||||
pub answer: Option<A>,
|
||||
/// Why this attempt did not fully succeed. Cleared by an attempt that
|
||||
/// did, so it never outlives the condition it describes.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl<A> Report<A> {
|
||||
pub fn answered(answer: A) -> Self {
|
||||
Self {
|
||||
answer: Some(answer),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed(error: String) -> Self {
|
||||
Self {
|
||||
answer: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<A> From<Result<A, String>> for Report<A> {
|
||||
fn from(result: Result<A, String>) -> Self {
|
||||
match result {
|
||||
Ok(answer) => Self::answered(answer),
|
||||
Err(error) => Self::failed(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry<A> {
|
||||
answer: Option<A>,
|
||||
/// A worker is running for this key right now, so a second request
|
||||
/// must not start another.
|
||||
in_flight: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
// Written out rather than derived: `#[derive(Default)]` would demand
|
||||
// `A: Default`, and an answer that has never been given is `None` whether
|
||||
// or not its type has a default.
|
||||
impl<A> Default for Entry<A> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
answer: None,
|
||||
in_flight: false,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers being worked out in the background, by key.
|
||||
///
|
||||
/// Held behind an `Arc` by whatever owns it, because a worker outlives the
|
||||
/// call that started it and has to be able to write its answer somewhere
|
||||
/// that still exists.
|
||||
pub struct Checks<K, A>(Mutex<HashMap<K, Entry<A>>>);
|
||||
|
||||
impl<K, A> Default for Checks<K, A> {
|
||||
fn default() -> Self {
|
||||
Self(Mutex::new(HashMap::new()))
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Eq + Hash + Clone + Send + 'static, A: Clone + Send + 'static> Checks<K, A> {
|
||||
/// The last answer, or `None` if there has never been one. Distinct
|
||||
/// from an answer that happens to be falsy: never-asked and
|
||||
/// asked-and-told-no are different things to show.
|
||||
pub fn answer<Q>(&self, key: &Q) -> Option<A>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized,
|
||||
{
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(key)
|
||||
.and_then(|entry| entry.answer.clone())
|
||||
}
|
||||
|
||||
/// Something is being worked out for this key right now.
|
||||
pub fn is_checking<Q>(&self, key: &Q) -> bool
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized,
|
||||
{
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(key)
|
||||
.is_some_and(|entry| entry.in_flight)
|
||||
}
|
||||
|
||||
/// Why the last attempt produced no answer, if it produced none.
|
||||
/// `None` both before anything has been asked and after an attempt
|
||||
/// that worked, since neither is something to warn about.
|
||||
pub fn error<Q>(&self, key: &Q) -> Option<String>
|
||||
where
|
||||
K: Borrow<Q>,
|
||||
Q: Hash + Eq + ?Sized,
|
||||
{
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(key)
|
||||
.and_then(|entry| entry.error.clone())
|
||||
}
|
||||
|
||||
/// Records something this server just caused, so a display reflects an
|
||||
/// action without waiting for the next background attempt.
|
||||
///
|
||||
/// A change applied in place under one lock rather than a whole answer
|
||||
/// handed in, because an answer can have more than one part and a
|
||||
/// caller usually knows only the part it changed -- overwriting the
|
||||
/// rest with a fresh default would throw away what was still true.
|
||||
///
|
||||
/// Clears the error: what is recorded here is known, so nothing is
|
||||
/// left for a previous failure to qualify.
|
||||
///
|
||||
/// Deliberately leaves `in_flight` alone. Clearing it would let a
|
||||
/// second worker start for something already being worked on, and the
|
||||
/// running one would then write its now-stale answer over this.
|
||||
pub fn update(&self, key: K, change: impl FnOnce(&mut A))
|
||||
where
|
||||
A: Default,
|
||||
{
|
||||
let mut map = self.0.lock().unwrap();
|
||||
let entry = map.entry(key).or_default();
|
||||
change(entry.answer.get_or_insert_with(A::default));
|
||||
entry.error = None;
|
||||
}
|
||||
|
||||
/// Forgets everything whose key `keep` rejects, for something being
|
||||
/// removed.
|
||||
pub fn retain(&self, keep: impl FnMut(&K) -> bool) {
|
||||
let mut keep = keep;
|
||||
self.0.lock().unwrap().retain(|key, _| keep(key));
|
||||
}
|
||||
|
||||
/// Starts working one key out, unless it already is being. Returns at
|
||||
/// once.
|
||||
///
|
||||
/// `work` is handed the previous answer so it can build on it -- which
|
||||
/// is what lets a partial failure keep the half it still knows rather
|
||||
/// than having to choose between the whole answer and none of it.
|
||||
///
|
||||
/// The lock is taken twice and held across neither the spawn nor the
|
||||
/// work: once to claim the key, and once to record what came back.
|
||||
pub fn start<W>(self: &Arc<Self>, key: K, work: W)
|
||||
where
|
||||
W: FnOnce(Option<A>) -> Report<A> + Send + 'static,
|
||||
{
|
||||
let previous = {
|
||||
let mut map = self.0.lock().unwrap();
|
||||
let entry = map.entry(key.clone()).or_default();
|
||||
if entry.in_flight {
|
||||
// Already being asked; nothing to add by asking twice.
|
||||
return;
|
||||
}
|
||||
entry.in_flight = true;
|
||||
entry.answer.clone()
|
||||
};
|
||||
|
||||
let checks = Arc::clone(self);
|
||||
std::thread::spawn(move || {
|
||||
let report = work(previous);
|
||||
let mut map = checks.0.lock().unwrap();
|
||||
let entry = map.entry(key).or_default();
|
||||
if let Some(answer) = report.answer {
|
||||
entry.answer = Some(answer);
|
||||
}
|
||||
entry.error = report.error;
|
||||
entry.in_flight = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn settle<K: Eq + Hash + Clone + Send + 'static, A: Clone + Send + 'static>(
|
||||
checks: &Arc<Checks<K, A>>,
|
||||
key: &K,
|
||||
) {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while checks.is_checking(key) && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert!(!checks.is_checking(key), "the worker never finished");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_lands_after_the_call_that_asked_for_it() {
|
||||
let checks: Arc<Checks<String, bool>> = Arc::default();
|
||||
let key = "one".to_string();
|
||||
|
||||
// Nothing asked yet is not the same as an answer of false.
|
||||
assert_eq!(checks.answer(key.as_str()), None);
|
||||
assert!(!checks.is_checking(key.as_str()));
|
||||
|
||||
checks.start(key.clone(), |_| Report::answered(true));
|
||||
settle(&checks, &key);
|
||||
assert_eq!(checks.answer(key.as_str()), Some(true));
|
||||
assert_eq!(checks.error(key.as_str()), None);
|
||||
}
|
||||
|
||||
/// The failure this whole arrangement exists to make visible: a failed
|
||||
/// attempt must keep the last answer *and* say that it is not current,
|
||||
/// rather than silently reading as "asked, nothing to report".
|
||||
#[test]
|
||||
fn a_failure_keeps_the_last_answer_and_records_why() {
|
||||
let checks: Arc<Checks<String, bool>> = Arc::default();
|
||||
let key = "one".to_string();
|
||||
|
||||
checks.start(key.clone(), |_| Report::answered(true));
|
||||
settle(&checks, &key);
|
||||
|
||||
checks.start(key.clone(), |_| Report::failed("unreachable".to_string()));
|
||||
settle(&checks, &key);
|
||||
assert_eq!(
|
||||
checks.answer(key.as_str()),
|
||||
Some(true),
|
||||
"the last known answer is what to show"
|
||||
);
|
||||
assert_eq!(checks.error(key.as_str()), Some("unreachable".to_string()));
|
||||
|
||||
// And an attempt that works clears the qualification.
|
||||
checks.start(key.clone(), |_| Report::answered(false));
|
||||
settle(&checks, &key);
|
||||
assert_eq!(checks.answer(key.as_str()), Some(false));
|
||||
assert_eq!(checks.error(key.as_str()), None);
|
||||
}
|
||||
|
||||
/// A worker can build on what was already known, which is what lets a
|
||||
/// partial failure keep the half it still has.
|
||||
#[test]
|
||||
fn a_worker_is_handed_the_previous_answer() {
|
||||
let checks: Arc<Checks<String, u32>> = Arc::default();
|
||||
let key = "one".to_string();
|
||||
|
||||
checks.start(key.clone(), |previous| {
|
||||
assert_eq!(previous, None);
|
||||
Report::answered(1)
|
||||
});
|
||||
settle(&checks, &key);
|
||||
checks.start(key.clone(), |previous| {
|
||||
Report::answered(previous.expect("the first answer") + 1)
|
||||
});
|
||||
settle(&checks, &key);
|
||||
assert_eq!(checks.answer(key.as_str()), Some(2));
|
||||
}
|
||||
|
||||
/// Two requests arriving together must not produce two workers. The
|
||||
/// second is dropped rather than queued: it would ask the same
|
||||
/// question and the first is already asking it.
|
||||
#[test]
|
||||
fn a_second_request_does_not_start_a_second_worker() {
|
||||
let checks: Arc<Checks<String, u32>> = Arc::default();
|
||||
let key = "one".to_string();
|
||||
let started = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
|
||||
for _ in 0..5 {
|
||||
let started = Arc::clone(&started);
|
||||
let release = Arc::clone(&release);
|
||||
checks.start(key.clone(), move |_| {
|
||||
started.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
while !release.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Report::answered(1)
|
||||
});
|
||||
}
|
||||
assert!(checks.is_checking(key.as_str()));
|
||||
release.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
settle(&checks, &key);
|
||||
assert_eq!(started.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// Recording an answer must not clear the in-flight flag: doing so
|
||||
/// would let a second worker start beside the running one, and the
|
||||
/// running one would then overwrite this with something older.
|
||||
#[test]
|
||||
fn updating_an_answer_leaves_a_running_worker_claimed() {
|
||||
let checks: Arc<Checks<String, u32>> = Arc::default();
|
||||
let key = "one".to_string();
|
||||
let release = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
|
||||
let held = Arc::clone(&release);
|
||||
checks.start(key.clone(), move |_| {
|
||||
while !held.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
Report::answered(1)
|
||||
});
|
||||
|
||||
checks.update(key.clone(), |answer| *answer = 99);
|
||||
assert_eq!(checks.answer(key.as_str()), Some(99));
|
||||
assert!(
|
||||
checks.is_checking(key.as_str()),
|
||||
"the worker is still running and must stay claimed"
|
||||
);
|
||||
|
||||
release.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
settle(&checks, &key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_forgets_what_it_rejects() {
|
||||
let checks: Arc<Checks<String, u32>> = Arc::default();
|
||||
checks.update("keep".to_string(), |answer| *answer = 1);
|
||||
checks.update("drop".to_string(), |answer| *answer = 2);
|
||||
checks.retain(|key| key != "drop");
|
||||
assert_eq!(checks.answer("keep"), Some(1));
|
||||
assert_eq!(checks.answer("drop"), None);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,446 @@
|
||||
//! Finding an app's built APKs given only the path to its *project*, and
|
||||
//! finding candidate projects given a repo root.
|
||||
//!
|
||||
//! # Why fixed path shapes rather than a general search
|
||||
//!
|
||||
//! A general recursive search is far too slow to sit behind an interactive
|
||||
//! "add an app" screen. Measured against one real 28 GB / 39k-file Rust +
|
||||
//! Android project, warm cache:
|
||||
//!
|
||||
//! | approach | time |
|
||||
//! |---------------------------------------------------|--------|
|
||||
//! | `find -name '*.apk'` | 406 ms |
|
||||
//! | the same, depth-limited to 8..16 | 305 ms |
|
||||
//! | the same, pruning `.git`/`deps`/`.fingerprint`/... | 132 ms |
|
||||
//! | the patterns below | 6 ms |
|
||||
//!
|
||||
//! Depth limits buy nothing because the breadth is in shallow Cargo/Gradle
|
||||
//! output directories, and pruning by name still leaves an order of
|
||||
//! magnitude to make up -- on a warm cache, at that. So this matches the
|
||||
//! handful of shapes Android build tooling actually emits into.
|
||||
//!
|
||||
//! The patterns stay cheap because every literal segment is a `stat` rather
|
||||
//! than a directory listing: only a `*` costs a `read_dir`, and only ever of
|
||||
//! one level. Adding a pattern for a build system not covered here is the
|
||||
//! intended way to extend this -- see [`APK_PATTERNS`].
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// Where Android build tooling puts APKs, relative to a project root. `*`
|
||||
/// matches one path segment.
|
||||
///
|
||||
/// Covers, in order: a single-module Gradle project; the usual one- and
|
||||
/// two-level module layouts (`app/`, `androidApp/`, `composeApp/`, and
|
||||
/// nested variants including React Native / Capacitor's `android/app/`);
|
||||
/// Flutter; and dioxus-cli's generated Android project under a Cargo
|
||||
/// `target/` directory.
|
||||
const APK_PATTERNS: &[&str] = &[
|
||||
"build/outputs/apk/*/*.apk",
|
||||
"*/build/outputs/apk/*/*.apk",
|
||||
"*/*/build/outputs/apk/*/*.apk",
|
||||
"build/app/outputs/flutter-apk/*.apk",
|
||||
"target/dx/*/*/android/app/app/build/outputs/apk/*/*.apk",
|
||||
];
|
||||
|
||||
/// Marker files that make a directory worth treating as an app project when
|
||||
/// scanning a repo root. Deliberately loose -- a false positive costs one
|
||||
/// wasted [`find_apks`] call (microseconds), while a false negative means
|
||||
/// the project never gets suggested at all.
|
||||
const PROJECT_MARKERS: &[&str] = &[
|
||||
"settings.gradle",
|
||||
"settings.gradle.kts",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
"Dioxus.toml",
|
||||
"pubspec.yaml",
|
||||
];
|
||||
|
||||
/// Directory names never worth expanding a `*` into. `node_modules` and
|
||||
/// `.git` are the expensive ones; hidden directories are skipped wholesale
|
||||
/// below since no build system emits APKs into one.
|
||||
const SKIP_DIRS: &[&str] = &["node_modules", "Pods", "vendor"];
|
||||
|
||||
/// A built APK found under a project, with the mtime used both to pick a
|
||||
/// default among several and to tell the app whether the installed copy is
|
||||
/// behind (see `crate::routes`'s manifest).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApkCandidate {
|
||||
pub path: PathBuf,
|
||||
pub modified: SystemTime,
|
||||
/// The build variant, taken from the containing directory name
|
||||
/// (`debug`, `release`, `freeRelease`, ...) -- what the app shows when
|
||||
/// offering a choice between several.
|
||||
pub variant: String,
|
||||
}
|
||||
|
||||
/// Every APK built under `project`, newest first.
|
||||
///
|
||||
/// Derived artifacts this server itself produced (`*.slim.apk`, see
|
||||
/// `crate::strip`) and unsigned intermediates are filtered out: neither is
|
||||
/// a build output a caller would ever mean to select, and offering the slim
|
||||
/// copy of an APK beside that APK would be two entries for one build.
|
||||
pub fn find_apks(project: &Path) -> Vec<ApkCandidate> {
|
||||
let mut found = Vec::new();
|
||||
for pattern in APK_PATTERNS {
|
||||
for path in expand(project, pattern) {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
if name.ends_with(".slim.apk") || name.ends_with("-unsigned.apk") {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = std::fs::metadata(&path).and_then(|meta| meta.modified()) else {
|
||||
continue;
|
||||
};
|
||||
let variant = path
|
||||
.parent()
|
||||
.and_then(|parent| parent.file_name())
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
found.push(ApkCandidate {
|
||||
path,
|
||||
modified,
|
||||
variant,
|
||||
});
|
||||
}
|
||||
}
|
||||
// A project can match more than one pattern (a Gradle project whose
|
||||
// root is also a module), so the same file can be found twice.
|
||||
found.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
found.dedup_by(|a, b| a.path == b.path);
|
||||
found.sort_by_key(|apk| std::cmp::Reverse(apk.modified));
|
||||
found
|
||||
}
|
||||
|
||||
/// A project directory found under a configured repo root that the "add an
|
||||
/// app" screen can offer: one with a build under it, or one carrying a
|
||||
/// declaration of its own, which can be added before its first build.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSuggestion {
|
||||
pub path: PathBuf,
|
||||
/// Display name: the project's path relative to the root it was found
|
||||
/// under. Not just the directory's own name, because the interesting
|
||||
/// ones are routinely generic -- two repos each with an `app/` produce
|
||||
/// two suggestions indistinguishable by name alone, where `foo/app` and
|
||||
/// `bar/app` are not. The real label is read out of the APK once the
|
||||
/// project is actually added (`crate::apkinfo`), which costs a
|
||||
/// subprocess per APK and so isn't done for every suggestion.
|
||||
pub name: String,
|
||||
pub apk_count: usize,
|
||||
pub newest: SystemTime,
|
||||
}
|
||||
|
||||
/// Every project under `roots` worth offering, newest first -- by build
|
||||
/// where there is one, and by when the project declared itself where there
|
||||
/// isn't.
|
||||
///
|
||||
/// Descends two levels below each root, which covers both a root holding
|
||||
/// projects directly and one holding multi-project repositories (a repo
|
||||
/// with `app/` and `app-dioxus/` side by side). Deeper than that costs
|
||||
/// fanout for a layout nobody uses, and a project nested further can always
|
||||
/// be added by typing its path.
|
||||
pub fn scan_roots(roots: &[PathBuf]) -> Vec<ProjectSuggestion> {
|
||||
let mut suggestions = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for root in roots {
|
||||
for depth1 in child_dirs(root) {
|
||||
consider(&depth1, root, &mut suggestions, &mut seen);
|
||||
// A declaration ends the descent: it says "this directory is
|
||||
// the project", so whatever is underneath is that project's
|
||||
// own layout rather than another project to offer. Without
|
||||
// this, a repository declaring itself at the root and building
|
||||
// through a Gradle module underneath came back twice -- the
|
||||
// root matched through the one-level APK pattern, the module
|
||||
// through its own marker and the same build, and the two are
|
||||
// different directories so nothing deduplicated them.
|
||||
//
|
||||
// A genuinely separate project nested inside a declared one
|
||||
// can still be added by typing its path, which is the escape
|
||||
// hatch for every layout this scan does not cover.
|
||||
if declaration_of(&depth1).is_some() {
|
||||
continue;
|
||||
}
|
||||
for depth2 in child_dirs(&depth1) {
|
||||
consider(&depth2, root, &mut suggestions, &mut seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suggestions.sort_by_key(|item| std::cmp::Reverse(item.newest));
|
||||
suggestions
|
||||
}
|
||||
|
||||
/// The declaration this directory carries, if it carries one.
|
||||
///
|
||||
/// The path rather than a bare yes, because a project offered before its
|
||||
/// first build is sorted by when it said so and there is nothing else to
|
||||
/// date it by.
|
||||
fn declaration_of(dir: &Path) -> Option<PathBuf> {
|
||||
let path = dir.join(crate::config::PROJECT_CONFIG_FILE);
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
fn consider(
|
||||
dir: &Path,
|
||||
root: &Path,
|
||||
suggestions: &mut Vec<ProjectSuggestion>,
|
||||
seen: &mut std::collections::HashSet<PathBuf>,
|
||||
) {
|
||||
// A project carrying this server's own config file has said outright
|
||||
// that it is one, which is a stronger claim than any build-system
|
||||
// marker guessed at below.
|
||||
let declaration = declaration_of(dir);
|
||||
let declared = declaration.is_some();
|
||||
if !declared
|
||||
&& !PROJECT_MARKERS
|
||||
.iter()
|
||||
.any(|marker| dir.join(marker).is_file())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let apks = find_apks(dir);
|
||||
let newest = match apks.first() {
|
||||
Some(apk) => apk.modified,
|
||||
// Offered with nothing built, because a project that declares
|
||||
// itself usually declares a build step too -- and that step is
|
||||
// frequently the thing that produces the first APK, so requiring
|
||||
// one first is a chicken-and-egg. Sorted by when it said so, which
|
||||
// keeps a freshly declared project among the recent ones instead
|
||||
// of at the bottom forever.
|
||||
None => match &declaration {
|
||||
Some(path) => std::fs::metadata(path)
|
||||
.and_then(|meta| meta.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH),
|
||||
None => return,
|
||||
},
|
||||
};
|
||||
if !seen.insert(dir.to_path_buf()) {
|
||||
return;
|
||||
}
|
||||
suggestions.push(ProjectSuggestion {
|
||||
path: dir.to_path_buf(),
|
||||
name: dir
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(dir)
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
apk_count: apks.len(),
|
||||
newest,
|
||||
});
|
||||
}
|
||||
|
||||
/// Immediate subdirectories of `dir`, minus hidden ones and the known-huge
|
||||
/// names in [`SKIP_DIRS`]. Symlinks are not followed -- a repo root full of
|
||||
/// symlinks into each other would otherwise turn a bounded scan unbounded.
|
||||
fn child_dirs(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| !is_skipped(path))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_skipped(path: &Path) -> bool {
|
||||
let Some(name) = path.file_name().map(|name| name.to_string_lossy()) else {
|
||||
return true;
|
||||
};
|
||||
name.starts_with('.') || SKIP_DIRS.contains(&name.as_ref())
|
||||
}
|
||||
|
||||
/// Expands a `/`-separated pattern against `base`, one segment at a time.
|
||||
/// A literal segment is appended without touching the filesystem; only `*`
|
||||
/// and `*.apk`-style segments read a directory, and only that one level.
|
||||
/// The final existence check happens in [`find_apks`]'s `metadata` call, so
|
||||
/// a literal-only pattern costs nothing until then.
|
||||
fn expand(base: &Path, pattern: &str) -> Vec<PathBuf> {
|
||||
let mut current = vec![base.to_path_buf()];
|
||||
for segment in pattern.split('/') {
|
||||
let mut next = Vec::new();
|
||||
if let Some(suffix) = segment.strip_prefix('*') {
|
||||
for dir in ¤t {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.filter_map(|entry| entry.ok()) {
|
||||
let path = entry.path();
|
||||
if is_skipped(&path) {
|
||||
continue;
|
||||
}
|
||||
if suffix.is_empty() || path.to_string_lossy().ends_with(suffix) {
|
||||
next.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.extend(current.iter().map(|dir| dir.join(segment)));
|
||||
}
|
||||
if next.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
current
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a tree of empty files from `/`-separated relative paths.
|
||||
fn tree(paths: &[&str]) -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
for path in paths {
|
||||
let full = dir.path().join(path);
|
||||
std::fs::create_dir_all(full.parent().expect("has a parent")).expect("mkdir");
|
||||
std::fs::write(&full, b"").expect("write");
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
fn names(apks: &[ApkCandidate]) -> Vec<String> {
|
||||
apks.iter()
|
||||
.map(|apk| apk.path.file_name().unwrap().to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_apks_across_the_supported_module_layouts() {
|
||||
let root = tree(&[
|
||||
"build/outputs/apk/debug/root-debug.apk",
|
||||
"app/build/outputs/apk/debug/one-level.apk",
|
||||
"androidApp/nested/build/outputs/apk/debug/two-level.apk",
|
||||
"build/app/outputs/flutter-apk/app-debug.apk",
|
||||
"target/dx/demo/debug/android/app/app/build/outputs/apk/debug/dx.apk",
|
||||
]);
|
||||
let mut found = names(&find_apks(root.path()));
|
||||
found.sort();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
"app-debug.apk",
|
||||
"dx.apk",
|
||||
"one-level.apk",
|
||||
"root-debug.apk",
|
||||
"two-level.apk"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_this_servers_own_derived_artifacts() {
|
||||
// The slim copy sits beside the APK it was derived from; offering
|
||||
// both would be two entries for one build.
|
||||
let root = tree(&[
|
||||
"app/build/outputs/apk/debug/app-debug.apk",
|
||||
"app/build/outputs/apk/debug/app-debug.apk.slim.apk",
|
||||
"app/build/outputs/apk/release/app-release-unsigned.apk",
|
||||
]);
|
||||
assert_eq!(names(&find_apks(root.path())), ["app-debug.apk"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_the_variant_directory_and_orders_newest_first() {
|
||||
let root = tree(&[
|
||||
"app/build/outputs/apk/debug/app-debug.apk",
|
||||
"app/build/outputs/apk/freeRelease/app-freeRelease.apk",
|
||||
]);
|
||||
// mtimes from a single `tree` call are too close together to order
|
||||
// reliably, so make the intended winner explicitly newer.
|
||||
let newer = root
|
||||
.path()
|
||||
.join("app/build/outputs/apk/freeRelease/app-freeRelease.apk");
|
||||
let stamp = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
filetime::set_file_mtime(&newer, filetime::FileTime::from_system_time(stamp))
|
||||
.expect("set mtime");
|
||||
|
||||
let found = find_apks(root.path());
|
||||
assert_eq!(found[0].variant, "freeRelease");
|
||||
assert_eq!(found[1].variant, "debug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_directories_that_are_never_worth_walking() {
|
||||
let root = tree(&[
|
||||
"node_modules/pkg/build/outputs/apk/debug/vendored.apk",
|
||||
".git/build/outputs/apk/debug/hidden.apk",
|
||||
]);
|
||||
assert!(find_apks(root.path()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggests_only_marked_projects_that_have_actually_been_built() {
|
||||
let root = tree(&[
|
||||
// Two levels down, the layout a multi-project repo has.
|
||||
"repo/built/settings.gradle.kts",
|
||||
"repo/built/app/build/outputs/apk/debug/built.apk",
|
||||
// Marked, but nothing built yet.
|
||||
"repo/unbuilt/settings.gradle.kts",
|
||||
// Built, but not recognizable as a project root.
|
||||
"repo/unmarked/app/build/outputs/apk/debug/stray.apk",
|
||||
]);
|
||||
let found = scan_roots(&[root.path().to_path_buf()]);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].name, "repo/built");
|
||||
assert_eq!(found[0].apk_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggests_a_project_sitting_directly_under_a_root() {
|
||||
let root = tree(&[
|
||||
"solo/build.gradle.kts",
|
||||
"solo/build/outputs/apk/debug/solo.apk",
|
||||
]);
|
||||
let found = scan_roots(&[root.path().to_path_buf()]);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].name, "solo");
|
||||
}
|
||||
|
||||
/// The chicken-and-egg case: a project whose build step is what
|
||||
/// produces the first APK has to be addable before there is one, so
|
||||
/// declaring itself is enough to be offered.
|
||||
#[test]
|
||||
fn a_project_that_declares_itself_is_offered_before_its_first_build() {
|
||||
let root = tree(&[
|
||||
"repo/declared/.dev-updater.ron",
|
||||
// No marker file and nothing built -- the declaration is the
|
||||
// only reason this is a project at all.
|
||||
"repo/silent/src/main.rs",
|
||||
]);
|
||||
let found = scan_roots(&[root.path().to_path_buf()]);
|
||||
assert_eq!(found.len(), 1, "{found:?}");
|
||||
assert_eq!(found[0].name, "repo/declared");
|
||||
assert_eq!(found[0].apk_count, 0);
|
||||
}
|
||||
|
||||
/// A project that declares itself is *the* project, and must be
|
||||
/// offered once. Before this, a repository with a declaration at its
|
||||
/// root and a Gradle module underneath produced two suggestions for
|
||||
/// one app: the root matched through the one-level APK pattern, the
|
||||
/// module matched through its own marker and its own build, and
|
||||
/// nothing deduplicated them because they are different directories.
|
||||
#[test]
|
||||
fn a_declared_project_is_not_offered_again_through_its_module() {
|
||||
let root = tree(&[
|
||||
"repo/.dev-updater.ron",
|
||||
"repo/app/build.gradle.kts",
|
||||
"repo/app/build/outputs/apk/debug/app-debug.apk",
|
||||
]);
|
||||
let found = scan_roots(&[root.path().to_path_buf()]);
|
||||
assert_eq!(found.len(), 1, "one app, one suggestion: {found:?}");
|
||||
assert_eq!(found[0].name, "repo", "the declaration names the project");
|
||||
assert_eq!(
|
||||
found[0].apk_count, 1,
|
||||
"and it still finds the build below it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_not_an_error() {
|
||||
assert!(scan_roots(&[PathBuf::from("/definitely/not/here")]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,810 @@
|
||||
//! What the checkout under a project looks like, and bringing it up to
|
||||
//! date.
|
||||
//!
|
||||
//! Uses the `git` binary rather than a Rust git library: it is already on
|
||||
//! any machine that produced these projects, it reads the same config,
|
||||
//! credentials, and remotes the person uses by hand, and there is no
|
||||
//! second implementation of "what does this repo consider upstream" to
|
||||
//! disagree with the one they debug with -- which is what prefers what
|
||||
//! is already here over a new dependency.
|
||||
//!
|
||||
//! The split that matters here is network versus not. [`status`] only
|
||||
//! reads what is already on disk, so it is cheap enough to answer on every
|
||||
//! manifest request; it reports the branch as of the last fetch, which is
|
||||
//! exactly what "3 commits behind" means everywhere else in git. [`fetch`]
|
||||
//! is the one call that talks to a remote, and happens only when a person
|
||||
//! asks for an update.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// A project's checkout, as of the last fetch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitStatus {
|
||||
/// The checked-out branch, or `HEAD` when detached.
|
||||
pub branch: String,
|
||||
/// Uncommitted changes, which make pulling something this shouldn't
|
||||
/// decide on its own.
|
||||
pub dirty: bool,
|
||||
/// `origin/main` and the like; absent for a branch that tracks
|
||||
/// nothing, in which case there is nothing to pull.
|
||||
pub upstream: Option<String>,
|
||||
/// The top of the checkout. What gets pulled, and what a person calls
|
||||
/// the project -- a card showing `~/repos/thing/app` is naming the
|
||||
/// directory this server watches, not the repository it lives in.
|
||||
///
|
||||
/// Contracted for display here rather than at the one place that shows
|
||||
/// it, because that is the only thing it is for; nothing on this side
|
||||
/// opens it.
|
||||
pub root: String,
|
||||
}
|
||||
|
||||
/// Reads `project`'s checkout without touching the network. `None` when
|
||||
/// it isn't in a git repository at all, which is not an error -- a project
|
||||
/// can perfectly well be a directory somebody builds by hand.
|
||||
pub fn status(project: &Path) -> Option<GitStatus> {
|
||||
// Doubles as the "is this a repo at all" question, which is why it is
|
||||
// first and why its failure is a plain `None`.
|
||||
let toplevel = git(project, &["rev-parse", "--show-toplevel"]).ok()?;
|
||||
let root = crate::config::contract_tilde(Path::new(&toplevel));
|
||||
|
||||
let branch = git(project, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
let upstream = git(project, &["rev-parse", "--abbrev-ref", "@{u}"]).ok();
|
||||
let dirty = git(project, &["status", "--porcelain"])
|
||||
.map(|out| !out.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
Some(GitStatus {
|
||||
branch,
|
||||
dirty,
|
||||
upstream,
|
||||
root,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether anything under `within` is edited or untracked, relative to
|
||||
/// `project`. `None` when the checkout cannot be read.
|
||||
///
|
||||
/// Scoped for the same reason `subtree_head` is: a whole-checkout answer
|
||||
/// would put every component of a one-checkout-two-things project into the
|
||||
/// same state on any edit, which is the daily annoyance the scoping exists
|
||||
/// to avoid. An edit under `app/` is about the app.
|
||||
///
|
||||
/// Untracked files count. A new source file genuinely changes what a build
|
||||
/// produces, and `.gitignore` has already filtered out the editor noise
|
||||
/// and the build outputs, so what is left is real. This also answers the
|
||||
/// submodule case correctly and without a special case: a submodule whose
|
||||
/// working tree has moved shows as a modified path, so whichever component
|
||||
/// contains it goes unknown and the others do not.
|
||||
pub fn subtree_dirty(project: &Path, within: Option<&Path>) -> Option<bool> {
|
||||
let mut args = vec!["status", "--porcelain", "--"];
|
||||
let within = within.map(|path| path.to_string_lossy().into_owned());
|
||||
args.push(within.as_deref().unwrap_or("."));
|
||||
git(project, &args).ok().map(|out| !out.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The newest commit touching `within` (a path relative to `project`), or
|
||||
/// the whole checkout when `within` is `None`.
|
||||
///
|
||||
/// This is what a build is recorded against, and the scoping is the point.
|
||||
/// One checkout routinely produces several things -- a backend under
|
||||
/// `server/` and an APK under `app/` -- and comparing both against `HEAD`
|
||||
/// would mark the backend stale for a commit that only touched the app,
|
||||
/// offering a pointless rebuild on every app-only change. Asking what last
|
||||
/// touched the component's own directory makes "out of date" mean what a
|
||||
/// person reading the card expects it to.
|
||||
///
|
||||
/// `None` when the checkout cannot be read, which is "we cannot tell"
|
||||
/// rather than an answer; see `BuildState::freshness`.
|
||||
pub fn subtree_head(project: &Path, within: Option<&Path>) -> Option<String> {
|
||||
let mut args = vec!["log", "-1", "--format=%H"];
|
||||
let within = within.map(|path| path.to_string_lossy().into_owned());
|
||||
if let Some(path) = within.as_deref() {
|
||||
args.push("--");
|
||||
args.push(path);
|
||||
}
|
||||
let sha = git(project, &args).ok()?;
|
||||
(!sha.is_empty()).then_some(sha)
|
||||
}
|
||||
|
||||
/// Commits the upstream branch has that this one doesn't, counted from
|
||||
/// refs already on disk. Only meaningful right after a fetch, which is why
|
||||
/// the only caller is the pull itself.
|
||||
pub fn behind(project: &Path) -> usize {
|
||||
git(project, &["rev-list", "--count", "HEAD..@{u}"])
|
||||
.ok()
|
||||
.and_then(|count| count.parse().ok())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether pulling this checkout would actually move it.
|
||||
///
|
||||
/// Read-only: `ls-remote` asks the remote what it has without writing
|
||||
/// anything into the checkout, so this can run on a timer behind every
|
||||
/// manifest request. Fetching would be the mutating half, and that is
|
||||
/// `pull`'s job.
|
||||
///
|
||||
/// The question is asked as "would a fast-forward move HEAD?", i.e. is
|
||||
/// the remote's commit already an ancestor of what is checked out. The
|
||||
/// obvious comparison -- remote tip against the remote-tracking ref --
|
||||
/// answers a different question and gets two cases wrong: a checkout that
|
||||
/// has fetched but not merged looks current when a pull would move it,
|
||||
/// and a checkout carrying local commits looks behind when a pull would
|
||||
/// do nothing.
|
||||
///
|
||||
/// A remote commit that isn't in the object store yet makes
|
||||
/// `--is-ancestor` fail, which is the right answer for the right reason:
|
||||
/// a commit we don't have is by definition new.
|
||||
pub fn has_new_commits(project: &Path, ipv4: bool) -> Result<bool, String> {
|
||||
let upstream = git(project, &["rev-parse", "--abbrev-ref", "@{u}"])
|
||||
.map_err(|_| "branch tracks no upstream".to_string())?;
|
||||
let (remote, branch) = upstream
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("cannot tell a remote from a branch in {upstream}"))?;
|
||||
|
||||
let listed = run_bounded(
|
||||
project,
|
||||
ipv4,
|
||||
&["ls-remote", remote, &format!("refs/heads/{branch}")],
|
||||
)?;
|
||||
// "<sha>\trefs/heads/<branch>"; empty when the branch is gone.
|
||||
let Some(remote_sha) = listed.split_whitespace().next() else {
|
||||
return Err(format!("{remote} has no branch {branch}"));
|
||||
};
|
||||
|
||||
Ok(git(
|
||||
project,
|
||||
&["merge-base", "--is-ancestor", remote_sha, "HEAD"],
|
||||
)
|
||||
.is_err())
|
||||
}
|
||||
|
||||
/// Updates the remote-tracking refs. The one call here that uses the
|
||||
/// network, and therefore the one that can hang: a black-holed host makes
|
||||
/// a bare `git fetch` sit for minutes, which is long enough to be
|
||||
/// indistinguishable from the server being down.
|
||||
///
|
||||
/// Bounded twice over. `ConnectTimeout` caps the part that actually
|
||||
/// hangs, and `BatchMode` makes a key that wants a passphrase fail rather
|
||||
/// than wait for a person who isn't there. Then the whole thing is killed
|
||||
/// at [`REMOTE_HARD_TIMEOUT`] regardless, so nothing accumulates when a
|
||||
/// remote is merely very slow.
|
||||
pub fn fetch(project: &Path, ipv4: bool) -> Result<(), String> {
|
||||
// `fetch` is one of the few git subcommands that takes the flag
|
||||
// itself, and that is what carries the preference to an https remote.
|
||||
// An ssh remote is covered by `ssh_command` either way.
|
||||
let mut args = vec!["fetch", "--quiet"];
|
||||
if ipv4 {
|
||||
args.insert(1, "-4");
|
||||
}
|
||||
run_bounded(project, ipv4, &args).map(|_| ())
|
||||
}
|
||||
|
||||
/// How git should invoke ssh: the two bounds every remote command needs,
|
||||
/// and IPv4 only when this project asked for it.
|
||||
///
|
||||
/// The address family is set *here* rather than on the git subcommand
|
||||
/// because most subcommands have no such option. `git fetch` takes `-4`;
|
||||
/// `git ls-remote` does not, and rejects it with "unknown switch" -- and
|
||||
/// `ls-remote` is what the new-commits check runs, so a flag spliced onto
|
||||
/// every command would have broken exactly the thing the card reports.
|
||||
/// The ssh invocation covers every remote command uniformly, which is what
|
||||
/// a setting called "force IPv4" has to do to be worth having.
|
||||
fn ssh_command(ipv4: bool) -> String {
|
||||
let family = if ipv4 { " -4" } else { "" };
|
||||
format!("ssh{family} -o ConnectTimeout=5 -o BatchMode=yes")
|
||||
}
|
||||
|
||||
/// Runs a git command that talks to a remote, with the two bounds such a
|
||||
/// command needs: `ConnectTimeout` for a host that never answers,
|
||||
/// `BatchMode` so a key wanting a passphrase fails instead of waiting for
|
||||
/// somebody who isn't there, and a hard stop for a remote that connects
|
||||
/// and then stalls.
|
||||
fn run_bounded(project: &Path, ipv4: bool, args: &[&str]) -> Result<String, String> {
|
||||
let mut child = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(project)
|
||||
.args(args)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
// Appended rather than replaced by git, so a custom ssh setup in
|
||||
// the user's config still applies.
|
||||
.env("GIT_SSH_COMMAND", ssh_command(ipv4))
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to run git {}: {err}", args[0]))?;
|
||||
|
||||
let deadline = Instant::now() + REMOTE_HARD_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("reading git output: {err}"))?;
|
||||
return if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(remote_failure(project, args[0], stderr.trim()))
|
||||
};
|
||||
}
|
||||
Ok(None) if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!(
|
||||
"git {} took longer than {}s and was stopped",
|
||||
args[0],
|
||||
REMOTE_HARD_TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
|
||||
Err(err) => return Err(format!("waiting on git {}: {err}", args[0])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast-forwards the current branch onto its upstream.
|
||||
///
|
||||
/// `--ff-only` deliberately: a merge or a rebase can conflict, and
|
||||
/// resolving a conflict is not something to start from a phone with no way
|
||||
/// to finish it. Refusing to touch a dirty tree is the same reasoning --
|
||||
/// the failure is reported and the working tree is left exactly as it was.
|
||||
pub fn pull(project: &Path, ipv4: bool) -> Result<(), String> {
|
||||
let status = status(project).ok_or_else(|| "not a git repository".to_string())?;
|
||||
if status.upstream.is_none() {
|
||||
return Err(format!(
|
||||
"branch {} tracks no upstream, so there is nothing to pull",
|
||||
status.branch
|
||||
));
|
||||
}
|
||||
if status.dirty {
|
||||
return Err(format!(
|
||||
"{} has uncommitted changes -- refusing to pull over them. Commit or stash on the \
|
||||
build machine first.",
|
||||
project.display()
|
||||
));
|
||||
}
|
||||
git(project, &["merge", "--ff-only", "@{u}"])?;
|
||||
|
||||
// A merge moves the *pointer* a submodule is recorded at without
|
||||
// touching its working tree, so a pull that updated one leaves the
|
||||
// build compiling the old contents -- which is precisely the drift a
|
||||
// submodule is here to prevent, arriving quietly. `git pull
|
||||
// --recurse-submodules` would cover it, but this fetches and merges
|
||||
// separately so that the two failures can be told apart, and `merge`
|
||||
// has no such flag.
|
||||
//
|
||||
// A no-op in a repository with no submodules, which is most of them,
|
||||
// and not fatal in one where it fails: the merge already landed, so
|
||||
// reporting the whole pull as failed would be worse than saying the
|
||||
// submodule is behind.
|
||||
//
|
||||
// Through `run_bounded` rather than `git`, because this one talks to a
|
||||
// remote: it needs the address-family preference in `GIT_SSH_COMMAND`
|
||||
// -- `git submodule` has no `-4` of its own to splice on -- and it
|
||||
// needs the timeout, since a clone that hangs on an unreachable
|
||||
// address would otherwise hang the request behind it with no way to
|
||||
// tell what it was waiting for.
|
||||
if let Err(message) = run_bounded(
|
||||
project,
|
||||
ipv4,
|
||||
&["submodule", "update", "--init", "--recursive"],
|
||||
) {
|
||||
tracing::warn!("pulled, but updating submodules failed: {message}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The phone-sized version of a failed remote command: the first line git
|
||||
/// printed, plus what this process can see of the ssh agent when what
|
||||
/// failed was authentication.
|
||||
///
|
||||
/// Shortened here rather than at each place that displays it, so there is
|
||||
/// one rule for what a remote failure looks like instead of one per
|
||||
/// caller. Git's remote failures run to a paragraph of advice about access
|
||||
/// rights and whether the repository exists -- true, generic, and several
|
||||
/// lines longer than a card on a phone should carry. The whole of it goes
|
||||
/// to the log, which is where someone standing at the build machine can
|
||||
/// read it.
|
||||
fn remote_failure(project: &Path, command: &str, stderr: &str) -> String {
|
||||
tracing::warn!("git {command} in {} failed: {stderr}", project.display());
|
||||
let headline = stderr
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.unwrap_or(stderr)
|
||||
.to_string();
|
||||
match agent_note(stderr) {
|
||||
Some(note) => format!("{headline}\n\n{note}"),
|
||||
None => headline,
|
||||
}
|
||||
}
|
||||
|
||||
/// What this process can see of the ssh agent, for a remote command the
|
||||
/// far end refused.
|
||||
///
|
||||
/// This is the whole difference between "it works when I run it myself"
|
||||
/// and "it fails from the service": an agent is reached through
|
||||
/// `SSH_AUTH_SOCK`, which is inherited, and a daemon started by an init
|
||||
/// system is not a child of the login shell that started the agent. That
|
||||
/// is invisible from a phone, and it is the first thing anyone would check
|
||||
/// at a terminal, so the answer travels with the failure.
|
||||
///
|
||||
/// Measured rather than guessed. The agent is asked and what it says is
|
||||
/// what is reported, so the note can say the agent is fine -- which is the
|
||||
/// answer that stops this being blamed for a key the remote simply doesn't
|
||||
/// accept. `ssh-add` is a process spawn, so this is reached only from a
|
||||
/// failure that has already paid for a network round trip; nothing on the
|
||||
/// manifest path runs it.
|
||||
fn agent_note(stderr: &str) -> Option<String> {
|
||||
if !stderr.contains("Permission denied") && !stderr.contains("publickey") {
|
||||
return None;
|
||||
}
|
||||
let Ok(socket) = std::env::var("SSH_AUTH_SOCK") else {
|
||||
return Some(
|
||||
"This server has no SSH_AUTH_SOCK, so ssh had no agent to ask. A service started \
|
||||
by an init system doesn't inherit the agent a login shell starts -- it has to be \
|
||||
given the path to one."
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
// ssh-add's exit status is the whole answer: 0 having listed
|
||||
// identities, 1 having reached an agent that holds none, 2 having
|
||||
// failed to reach one at all.
|
||||
match Command::new("ssh-add").arg("-l").output() {
|
||||
Err(err) => Some(format!(
|
||||
"SSH_AUTH_SOCK is {socket}, but ssh-add wouldn't run to check it: {err}"
|
||||
)),
|
||||
Ok(output) => Some(match output.status.code() {
|
||||
Some(0) => {
|
||||
let keys = String::from_utf8_lossy(&output.stdout).lines().count();
|
||||
format!(
|
||||
"The agent at {socket} answered and holds {keys} {}, so this is not the \
|
||||
agent being missing -- the remote refused the keys it was offered.",
|
||||
if keys == 1 { "key" } else { "keys" }
|
||||
)
|
||||
}
|
||||
Some(1) => format!(
|
||||
"The agent at {socket} answered but holds no keys. Nothing has been added to \
|
||||
it since it started."
|
||||
),
|
||||
_ => format!(
|
||||
"Nothing is answering on SSH_AUTH_SOCK ({socket}), so ssh had no agent to ask."
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// When a command talking to a remote is given up on entirely. Only
|
||||
/// reached by a host that accepts a connection and then stalls; an
|
||||
/// unreachable one fails at `ConnectTimeout` long before this.
|
||||
const REMOTE_HARD_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A checkout to ask about, and how to ask about it.
|
||||
///
|
||||
/// Carried together because the two are decided in different places -- the
|
||||
/// path by the project, the flag by whoever toggled it on this machine --
|
||||
/// and `refresh` needs both for each checkout it starts a thread for.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Checkout {
|
||||
pub path: PathBuf,
|
||||
/// Pass `-4` to the commands that talk to a remote.
|
||||
pub ipv4: bool,
|
||||
}
|
||||
|
||||
/// Remembers what each checkout's remote last said, and refreshes those
|
||||
/// answers **off the request path**.
|
||||
///
|
||||
/// Asking a remote costs a round trip, and the answer is decoration: the
|
||||
/// list is about which apps have builds and whether the phone has them.
|
||||
/// Making the list wait for it meant every reopen paid for it -- so a
|
||||
/// check runs in the background and the answer lands on the next look.
|
||||
///
|
||||
/// There is no time-based throttle. A check is a single `ls-remote`, which
|
||||
/// costs a fraction of a second, and the thing it guards against -- the
|
||||
/// list being asked for repeatedly -- only happens when a person opens,
|
||||
/// resumes or refreshes the app. Rationing the answer to once per interval
|
||||
/// bought little and meant a push made just after a check went unnoticed
|
||||
/// for the rest of it. The one rule left is that a checkout never has two
|
||||
/// checks running at once, and asking for a check is separated from asking
|
||||
/// whether one is outstanding ([`Self::is_checking`]) so that polling for
|
||||
/// an answer cannot itself keep starting new work.
|
||||
/// Answers kept by [`crate::checks`], which is the whole of the
|
||||
/// mechanism: this is the typed face of it for remotes.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct RemoteChecks(Arc<crate::checks::Checks<PathBuf, bool>>);
|
||||
|
||||
impl RemoteChecks {
|
||||
/// Whether this checkout's remote was last seen to have commits it
|
||||
/// doesn't. `false` before anything has been asked, which is the same
|
||||
/// as a card with no badge -- see [`Self::error`] for why that is safe
|
||||
/// to conflate here and nowhere else: a failed check is qualified by
|
||||
/// the error rather than by this.
|
||||
pub fn new_commits(&self, project: &Path) -> bool {
|
||||
self.0.answer(project).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A check for this checkout is outstanding, so what
|
||||
/// [`Self::new_commits`] says is provisional.
|
||||
pub fn is_checking(&self, project: &Path) -> bool {
|
||||
self.0.is_checking(project)
|
||||
}
|
||||
|
||||
/// Why the last check produced no answer, if it produced none.
|
||||
pub fn error(&self, project: &Path) -> Option<String> {
|
||||
self.0.error(project)
|
||||
}
|
||||
|
||||
/// Records that this checkout is level with its remote, for a pull
|
||||
/// this server just did -- so the card stops offering one without
|
||||
/// waiting for the next check to confirm what it already knows.
|
||||
pub fn mark_current(&self, project: &Path) {
|
||||
self.0
|
||||
.update(project.to_path_buf(), |new_commits| *new_commits = false);
|
||||
}
|
||||
|
||||
/// Asks every checkout that isn't already being asked. Returns at once.
|
||||
pub fn refresh(&self, projects: &[Checkout]) {
|
||||
for checkout in projects {
|
||||
let (project, ipv4) = (checkout.path.clone(), checkout.ipv4);
|
||||
self.0.start(project.clone(), move |_| {
|
||||
has_new_commits(&project, ipv4).into()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one git command in `project`, returning trimmed stdout, or
|
||||
/// stderr as the error so a failure says what git said.
|
||||
fn git(project: &Path, args: &[&str]) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(project)
|
||||
.args(args)
|
||||
// Never stop for credentials: this runs with nobody at the
|
||||
// terminal, and a prompt would hang the request instead of
|
||||
// failing it.
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.output()
|
||||
.map_err(|err| format!("failed to run git: {err}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(dir: &Path, args: &[&str]) {
|
||||
let output = Command::new(args[0])
|
||||
.args(&args[1..])
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.expect("run command");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{args:?}: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// An origin repo with one commit, and a clone of it.
|
||||
fn origin_and_clone(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let origin = root.join("origin");
|
||||
std::fs::create_dir_all(&origin).expect("mkdir");
|
||||
run(&origin, &["git", "init", "-q", "-b", "main"]);
|
||||
run(&origin, &["git", "config", "user.email", "t@example.com"]);
|
||||
run(&origin, &["git", "config", "user.name", "Test"]);
|
||||
std::fs::write(origin.join("file"), "one").expect("write");
|
||||
run(&origin, &["git", "add", "."]);
|
||||
run(&origin, &["git", "commit", "-qm", "one"]);
|
||||
|
||||
let clone = root.join("clone");
|
||||
run(
|
||||
root,
|
||||
&[
|
||||
"git",
|
||||
"clone",
|
||||
"-q",
|
||||
origin.to_str().unwrap(),
|
||||
clone.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
run(&clone, &["git", "config", "user.email", "t@example.com"]);
|
||||
run(&clone, &["git", "config", "user.name", "Test"]);
|
||||
(origin, clone)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_nothing_for_a_directory_outside_any_repository() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
assert_eq!(status(dir.path()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reports_the_checkout_without_touching_the_network() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
|
||||
let fresh = status(&clone).expect("a repository");
|
||||
assert_eq!(fresh.branch, "main");
|
||||
assert!(!fresh.dirty);
|
||||
assert_eq!(fresh.upstream.as_deref(), Some("origin/main"));
|
||||
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
// Unchanged: status only reads what is already here.
|
||||
assert_eq!(status(&clone).expect("status").branch, "main");
|
||||
assert_eq!(behind(&clone), 0);
|
||||
}
|
||||
|
||||
/// The bug this shape of the setting exists to avoid: `-4` spliced
|
||||
/// onto every git subcommand makes `git ls-remote` fail with "unknown
|
||||
/// switch", and `ls-remote` is what the check runs -- so forcing IPv4
|
||||
/// would have turned every card's commit count into an error.
|
||||
#[test]
|
||||
fn forcing_ipv4_does_not_break_the_check() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
assert!(!has_new_commits(&clone, true).expect("check with ipv4 forced"));
|
||||
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
assert!(has_new_commits(&clone, true).expect("check with ipv4 forced"));
|
||||
}
|
||||
|
||||
/// The sibling of the test above, and the reason it exists: the
|
||||
/// address-family preference governs *every* git command that reaches a
|
||||
/// remote, and it was applied to the check while the pull's submodule
|
||||
/// step was left out -- which hung, because `git submodule` has no `-4`
|
||||
/// to splice on and nothing bounded how long it waited.
|
||||
#[test]
|
||||
fn forcing_ipv4_does_not_break_the_pull() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
|
||||
fetch(&clone, true).expect("fetch with ipv4 forced");
|
||||
pull(&clone, true).expect("pull with ipv4 forced");
|
||||
assert!(!has_new_commits(&clone, true).expect("check"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ssh_command_carries_the_address_family_only_when_asked() {
|
||||
assert!(!ssh_command(false).contains("-4"));
|
||||
assert!(ssh_command(true).starts_with("ssh -4 "));
|
||||
// The bounds are not optional; forcing a family must not drop them.
|
||||
for command in [ssh_command(false), ssh_command(true)] {
|
||||
assert!(command.contains("ConnectTimeout=5"), "{command}");
|
||||
assert!(command.contains("BatchMode=yes"), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the list depends on: it can tell there is something to
|
||||
/// pull without downloading it or disturbing the repository.
|
||||
#[test]
|
||||
fn a_check_sees_new_commits_and_changes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
assert!(!has_new_commits(&clone, false).expect("check"));
|
||||
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
|
||||
// Read through git rather than off disk: a fresh clone packs its
|
||||
// refs, so there is no loose file to compare.
|
||||
let before = git(&clone, &["rev-parse", "origin/main"]).expect("tracking ref");
|
||||
assert!(
|
||||
has_new_commits(&clone, false).expect("check"),
|
||||
"should see the new commit"
|
||||
);
|
||||
|
||||
// Nothing was downloaded and no ref moved -- which is what makes
|
||||
// showing the list a read rather than a mutation.
|
||||
let after = git(&clone, &["rev-parse", "origin/main"]).expect("tracking ref");
|
||||
assert_eq!(before, after, "the check must not move the tracking ref");
|
||||
assert!(
|
||||
!clone.join(".git/FETCH_HEAD").exists(),
|
||||
"the check must not fetch"
|
||||
);
|
||||
assert_eq!(
|
||||
behind(&clone),
|
||||
0,
|
||||
"no objects were downloaded to count against"
|
||||
);
|
||||
|
||||
// Pull is what actually takes them.
|
||||
fetch(&clone, false).expect("fetch");
|
||||
assert_eq!(behind(&clone), 1);
|
||||
pull(&clone, false).expect("pull");
|
||||
assert!(!has_new_commits(&clone, false).expect("check"));
|
||||
}
|
||||
|
||||
/// Only an authentication failure gets the agent note -- an
|
||||
/// unreachable host is a different problem, and answering it with
|
||||
/// "your agent is fine" would send someone the wrong way.
|
||||
#[test]
|
||||
fn only_a_refused_key_is_explained_by_the_agent() {
|
||||
assert!(
|
||||
agent_note("fatal: unable to access: Could not resolve host: example.invalid")
|
||||
.is_none()
|
||||
);
|
||||
assert!(agent_note("git@host: Permission denied (publickey).").is_some());
|
||||
}
|
||||
|
||||
/// The two cases a remote-tip-vs-tracking-ref comparison gets wrong:
|
||||
/// having already fetched doesn't mean the commits have been taken,
|
||||
/// and having local commits of your own doesn't mean you are behind.
|
||||
#[test]
|
||||
fn the_question_is_whether_a_pull_would_move_head() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
fetch(&clone, false).expect("fetch");
|
||||
assert!(
|
||||
has_new_commits(&clone, false).expect("check"),
|
||||
"fetched but not merged is still something to pull",
|
||||
);
|
||||
|
||||
pull(&clone, false).expect("pull");
|
||||
run(
|
||||
&clone,
|
||||
&["git", "commit", "-q", "--allow-empty", "-m", "local"],
|
||||
);
|
||||
assert!(
|
||||
!has_new_commits(&clone, false).expect("check"),
|
||||
"a commit of one's own is not the remote being ahead",
|
||||
);
|
||||
}
|
||||
|
||||
/// The list must never wait on a remote. This is the whole reason the
|
||||
/// checks moved off the request path: reopening the app was stalling
|
||||
/// behind a round trip whose answer is decoration.
|
||||
#[test]
|
||||
fn refreshing_returns_at_once_and_the_answer_lands_after() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
let checks = Arc::new(RemoteChecks::default());
|
||||
let projects = vec![Checkout {
|
||||
path: clone.clone(),
|
||||
ipv4: false,
|
||||
}];
|
||||
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
|
||||
let started = Instant::now();
|
||||
checks.refresh(&projects);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(100),
|
||||
"refresh waited for the check instead of starting it",
|
||||
);
|
||||
assert!(
|
||||
checks.is_checking(&clone),
|
||||
"a first look has an answer outstanding"
|
||||
);
|
||||
// Not known yet, and said so rather than guessed at.
|
||||
assert!(!checks.new_commits(&clone));
|
||||
|
||||
// A second look while the first is still running must not pile on.
|
||||
checks.refresh(&projects);
|
||||
|
||||
settle(&checks, &projects);
|
||||
assert!(
|
||||
checks.new_commits(&clone),
|
||||
"the answer should have landed by now"
|
||||
);
|
||||
}
|
||||
|
||||
/// A push made moments after a check is seen on the very next look.
|
||||
///
|
||||
/// There is no interval over which the previous answer is reused. That
|
||||
/// used to be thirty seconds, which was exactly long enough for a push
|
||||
/// you had just made to look like nothing had happened.
|
||||
#[test]
|
||||
fn a_push_right_after_a_check_is_seen_on_the_next_look() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
let checks = Arc::new(RemoteChecks::default());
|
||||
let projects = vec![Checkout {
|
||||
path: clone.clone(),
|
||||
ipv4: false,
|
||||
}];
|
||||
|
||||
checks.refresh(&projects);
|
||||
settle(&checks, &projects);
|
||||
assert!(!checks.new_commits(&clone), "nothing pushed yet");
|
||||
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
|
||||
// Straight away, with no waiting out a window.
|
||||
checks.refresh(&projects);
|
||||
settle(&checks, &projects);
|
||||
assert!(
|
||||
checks.new_commits(&clone),
|
||||
"the push should be visible on the next look"
|
||||
);
|
||||
}
|
||||
|
||||
/// Waiting for a running check, the way a poll does: with
|
||||
/// `is_checking`, never `refresh`. Polling with `refresh` would start
|
||||
/// another check each time and so always find one outstanding.
|
||||
fn settle(checks: &Arc<RemoteChecks>, projects: &[Checkout]) {
|
||||
let busy = || {
|
||||
projects
|
||||
.iter()
|
||||
.any(|checkout| checks.is_checking(&checkout.path))
|
||||
};
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline && busy() {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(!busy(), "a check did not finish in time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pulling_fast_forwards_and_clears_being_behind() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
fetch(&clone, false).expect("fetch");
|
||||
|
||||
pull(&clone, false).expect("pull");
|
||||
assert_eq!(behind(&clone), 0);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(clone.join("file")).expect("read"),
|
||||
"two"
|
||||
);
|
||||
}
|
||||
|
||||
/// The working tree is the person's, and a pull that clobbered it
|
||||
/// would be discovered long after the phone tap that caused it.
|
||||
#[test]
|
||||
fn refuses_to_pull_over_uncommitted_changes() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (origin, clone) = origin_and_clone(dir.path());
|
||||
std::fs::write(origin.join("file"), "two").expect("write");
|
||||
run(&origin, &["git", "commit", "-qam", "two"]);
|
||||
fetch(&clone, false).expect("fetch");
|
||||
|
||||
std::fs::write(clone.join("file"), "local edit").expect("write");
|
||||
assert!(status(&clone).expect("status").dirty);
|
||||
|
||||
let err = pull(&clone, false).expect_err("should refuse");
|
||||
assert!(err.contains("uncommitted changes"), "{err}");
|
||||
// Left exactly as it was.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(clone.join("file")).expect("read"),
|
||||
"local edit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_tracking_nothing_has_nothing_to_pull() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let (_origin, clone) = origin_and_clone(dir.path());
|
||||
run(&clone, &["git", "checkout", "-qb", "detached-work"]);
|
||||
|
||||
let status = status(&clone).expect("status");
|
||||
assert_eq!(status.branch, "detached-work");
|
||||
assert_eq!(status.upstream, None);
|
||||
assert!(
|
||||
pull(&clone, false)
|
||||
.expect_err("no upstream")
|
||||
.contains("no upstream")
|
||||
);
|
||||
// Nothing to compare against, so the list is told plainly rather
|
||||
// than being handed a misleading "up to date".
|
||||
assert!(has_new_commits(&clone, false).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Reading the tail of a log file.
|
||||
//!
|
||||
//! Logs are read from the *end*, in chunks, rather than by loading the
|
||||
//! file and taking the last lines of it. A service that has been up for a
|
||||
//! week can have a log far larger than anything worth holding in memory,
|
||||
//! and the interesting part is always the end.
|
||||
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// How much of a log will ever be read, however many lines were asked
|
||||
/// for.
|
||||
///
|
||||
/// Asking for "all lines" is a legitimate request and the answer still has
|
||||
/// to fit in memory and in a response, so this bounds it -- and when it
|
||||
/// bites, the reader is told rather than handed a silently shortened log.
|
||||
const MAX_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// How much is read at a time when walking backwards.
|
||||
const CHUNK: u64 = 64 * 1024;
|
||||
|
||||
/// The tail of a log, and whether it is the whole of it.
|
||||
pub struct Tail {
|
||||
pub text: String,
|
||||
/// The file was longer than what is here, either because more lines
|
||||
/// exist or because [`MAX_BYTES`] cut it off. The distinction the
|
||||
/// reader needs is only "is this everything?", and this answers it.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// The last `lines` lines of `path`, or as much as [`MAX_BYTES`] allows
|
||||
/// when `lines` is 0.
|
||||
///
|
||||
/// Reads backwards from the end until it has enough newlines, so the cost
|
||||
/// is proportional to what was asked for rather than to the file.
|
||||
pub fn tail(path: &Path, lines: usize) -> Result<Tail> {
|
||||
let mut file = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
|
||||
let size = file.metadata().context("stat the log")?.len();
|
||||
|
||||
let mut from = size;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
if from == 0 || buffer.len() as u64 >= MAX_BYTES {
|
||||
break;
|
||||
}
|
||||
let step = CHUNK.min(from);
|
||||
from -= step;
|
||||
let mut chunk = vec![0u8; step as usize];
|
||||
file.seek(SeekFrom::Start(from)).context("seek the log")?;
|
||||
file.read_exact(&mut chunk).context("read the log")?;
|
||||
chunk.extend_from_slice(&buffer);
|
||||
buffer = chunk;
|
||||
// One more than asked for, so the first line in the buffer can be
|
||||
// dropped as the partial one this chunk started in the middle of.
|
||||
if lines > 0 && buffer.iter().filter(|byte| **byte == b'\n').count() > lines {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Lossy on purpose: a log is whatever the process wrote, and a stray
|
||||
// non-UTF-8 byte must not turn the whole thing into an error at the
|
||||
// moment somebody is trying to read why it died.
|
||||
let text = String::from_utf8_lossy(&buffer).into_owned();
|
||||
let mut kept: Vec<&str> = text.lines().collect();
|
||||
let mut truncated = from > 0;
|
||||
if truncated && !kept.is_empty() {
|
||||
// The first line is whatever the chunk boundary bisected.
|
||||
kept.remove(0);
|
||||
}
|
||||
if lines > 0 && kept.len() > lines {
|
||||
kept.drain(..kept.len() - lines);
|
||||
truncated = true;
|
||||
}
|
||||
Ok(Tail {
|
||||
text: kept.join("\n"),
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this server keeps the output of builds it runs.
|
||||
///
|
||||
/// Generated data, so `$XDG_DATA_HOME` rather than beside the config --
|
||||
/// the same split the service scripts follow for their own logs, so both
|
||||
/// kinds end up somewhere a person expects to find a log.
|
||||
fn build_log_dir() -> PathBuf {
|
||||
data_dir().join("builds")
|
||||
}
|
||||
|
||||
/// Everything this server generates for itself, under `$XDG_DATA_HOME`.
|
||||
///
|
||||
/// One answer to "where does generated state go", so the build logs and
|
||||
/// the built-in service script cannot end up under two different
|
||||
/// interpretations of the same rule -- and the rule itself is the shared
|
||||
/// one, so this server and ai-app resolve it identically.
|
||||
pub fn data_dir() -> PathBuf {
|
||||
wg_app_link::xdg::data_home(crate::PRODUCT)
|
||||
}
|
||||
|
||||
/// The log files for one component's builds, newest first.
|
||||
///
|
||||
/// The same shape a service script reports, so the route that serves them
|
||||
/// does not care which kind it was handed.
|
||||
/// Which of a component's two logs is wanted.
|
||||
///
|
||||
/// Two kinds rather than one list, because they answer different
|
||||
/// questions: the build log is what this server captured while building
|
||||
/// the component, and the runtime log is what the component itself wrote
|
||||
/// while running. Flattening them into a single sequence -- which is how
|
||||
/// this was first written -- meant the generation index had to carry both
|
||||
/// "which kind" and "how far back", and since build logs came first the
|
||||
/// runtime ones sat at an index nothing ever asked for. They were
|
||||
/// unreachable from the phone, and nothing said so.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogKind {
|
||||
/// Written by this server while building the component.
|
||||
Build,
|
||||
/// Written by the component itself while running, reported by its
|
||||
/// service script. Never present for an APK, which does not run here.
|
||||
Runtime,
|
||||
}
|
||||
|
||||
pub fn build_logs(key: &str, component: &str) -> Vec<PathBuf> {
|
||||
let current = build_log_path(key, component);
|
||||
let previous = previous_of(¤t);
|
||||
[current, previous]
|
||||
.into_iter()
|
||||
.filter(|path| path.is_file())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_log_path(key: &str, component: &str) -> PathBuf {
|
||||
// Both are already route-safe identifiers, but a component name comes
|
||||
// from a project's own file, so anything that could climb out of the
|
||||
// directory is flattened rather than trusted.
|
||||
let safe = |text: &str| -> String {
|
||||
text.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
build_log_dir().join(format!("{}-{}.log", safe(key), safe(component)))
|
||||
}
|
||||
|
||||
fn previous_of(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".1");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Opens this component's build log for a run that is starting, rotating
|
||||
/// the previous one aside.
|
||||
///
|
||||
/// One generation, rotated at the start of a run, for the same reason a
|
||||
/// service script rotates on start: the split lands where a reader wants
|
||||
/// it -- this build and the one before -- which after a failure and a
|
||||
/// retry is the pair worth having.
|
||||
///
|
||||
/// A failure to open is not a reason to fail the build. The build is the
|
||||
/// point; the log is how you read about it afterwards, and losing it
|
||||
/// leaves the tail on the card exactly as before.
|
||||
pub fn open_build_log(key: &str, component: &str) -> Option<std::fs::File> {
|
||||
let path = build_log_path(key, component);
|
||||
if let Some(parent) = path.parent()
|
||||
&& let Err(err) = wg_app_link::private::create_dir(parent)
|
||||
{
|
||||
tracing::warn!("no build log for {key}'s {component}: {err:#}");
|
||||
return None;
|
||||
}
|
||||
if path.is_file() {
|
||||
let _ = std::fs::rename(&path, previous_of(&path));
|
||||
}
|
||||
match wg_app_link::private::create_file(&path) {
|
||||
Ok(file) => Some(file),
|
||||
Err(err) => {
|
||||
tracing::warn!("no build log for {key}'s {component}: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, contents).expect("write");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_log_comes_back_whole() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = write(dir.path(), "a.log", "one\ntwo\nthree\n");
|
||||
let tail = tail(&path, 100).expect("tail");
|
||||
assert_eq!(tail.text, "one\ntwo\nthree");
|
||||
assert!(!tail.truncated, "nothing was cut off");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asking_for_fewer_lines_takes_them_from_the_end() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = write(dir.path(), "a.log", "one\ntwo\nthree\nfour\n");
|
||||
let tail = tail(&path, 2).expect("tail");
|
||||
assert_eq!(tail.text, "three\nfour", "the end is the interesting part");
|
||||
assert!(tail.truncated, "and the reader is told there was more");
|
||||
}
|
||||
|
||||
/// The case the backwards read exists for: more than one chunk, so the
|
||||
/// boundary lands in the middle of a line and the partial one has to
|
||||
/// be dropped rather than shown as though it were a whole line.
|
||||
#[test]
|
||||
fn a_log_larger_than_one_chunk_reads_from_the_end_without_a_partial_line() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let body: String = (0..40_000).map(|n| format!("line {n}\n")).collect();
|
||||
let path = write(dir.path(), "big.log", &body);
|
||||
assert!(
|
||||
std::fs::metadata(&path).expect("stat").len() > CHUNK * 2,
|
||||
"spans chunks"
|
||||
);
|
||||
|
||||
let tail = tail(&path, 3).expect("tail");
|
||||
assert_eq!(tail.text, "line 39997\nline 39998\nline 39999");
|
||||
assert!(tail.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_means_everything_it_is_allowed_to_read() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = write(dir.path(), "a.log", "one\ntwo\n");
|
||||
let tail = tail(&path, 0).expect("tail");
|
||||
assert_eq!(tail.text, "one\ntwo");
|
||||
assert!(!tail.truncated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
//! Serves locally-built debug APKs so a phone -- or the `updater` app
|
||||
//! itself -- can install them, without a browser and without leaving files
|
||||
//! in the Downloads folder. See the repo README for the whole picture; this
|
||||
//! is the entry point and the two listeners it runs.
|
||||
//!
|
||||
//! Which apps get served is not compiled in: an app is a *project path*
|
||||
//! added at runtime from the phone (`POST /apps`), and the APK underneath
|
||||
//! it is rediscovered per request. See `discover` for how that stays fast
|
||||
//! enough to sit behind an interactive screen, and `config` for what
|
||||
//! persists.
|
||||
//!
|
||||
//! Runs up to two listeners:
|
||||
//!
|
||||
//! - `--port` (default 8090), always on, TLS pinned against the CA
|
||||
//! `certs` generates. The whole API surface the `updater` app itself
|
||||
//! ever talks to -- see `routes` for the table. Pinned because
|
||||
//! everything on it either is, or decides, what gets handed to
|
||||
//! `REQUEST_INSTALL_PACKAGES` next.
|
||||
//! - `--download` (off by default; pass alone for port 8091, or
|
||||
//! with a value for another port), plain HTTP, serving only the
|
||||
//! updater app's own APK at a bare `/` -- for a human's browser,
|
||||
//! bootstrapping `updater` onto a fresh phone. Plain HTTP because a
|
||||
//! stock browser has nothing to pin against before that first install.
|
||||
//!
|
||||
//! Both bind the WireGuard interface and nothing else (see `wg_address`),
|
||||
//! so neither is reachable from the LAN. That is the outer of two gates:
|
||||
//! the tunnel decides who can try, and the bearer token every TLS request
|
||||
//! carries (see `auth`) decides who is answered. It also means the
|
||||
//! bootstrap port's plain HTTP travels inside the tunnel's encryption.
|
||||
|
||||
mod apkinfo;
|
||||
mod auth;
|
||||
mod build_state;
|
||||
mod checks;
|
||||
mod config;
|
||||
mod discover;
|
||||
mod git;
|
||||
mod logs;
|
||||
mod purge;
|
||||
mod registry;
|
||||
mod resources;
|
||||
mod restart;
|
||||
mod routes;
|
||||
mod sdk;
|
||||
mod service;
|
||||
mod shipped;
|
||||
mod strip;
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use config::TokenEntry;
|
||||
use registry::AppState;
|
||||
|
||||
const DEFAULT_PORT: u16 = 8090;
|
||||
/// A `&str` rather than a `u16` because clap's `default_missing_value` takes
|
||||
/// one, and this is only otherwise printed -- so the flag's default and the
|
||||
/// port named in the "not passed" log line below can't drift apart.
|
||||
const DEFAULT_BOOTSTRAP_PORT: &str = "8091";
|
||||
|
||||
/// `$XDG_CONFIG_HOME/dev-updater`, or `~/.config/dev-updater`. Holds
|
||||
/// `config.ron` and `certs/`.
|
||||
///
|
||||
/// Per machine, deliberately, and not in the repo. This repo is shared
|
||||
/// between a machine and a VM over virtiofs at *different* absolute paths,
|
||||
/// so one shared `config.ron` means project paths that resolve on only
|
||||
/// one side -- which showed up as every app reading "not built" on the
|
||||
/// other. It also keeps the CA's private key off a mount the VM can read,
|
||||
/// which is what stops a compromised VM from signing a certificate the
|
||||
/// pinned app would accept.
|
||||
/// What this binary is called, where a shared function needs to name it.
|
||||
///
|
||||
/// The link crate is generic over the two servers using it, so anything it
|
||||
/// says to a person -- a failure telling somebody to bring the tunnel up,
|
||||
/// the directory its state lives in -- takes the name from here rather
|
||||
/// than guessing. One constant so the two cannot disagree.
|
||||
const PRODUCT: &str = "dev-updater";
|
||||
|
||||
/// This server's *own* project: the checkout whose APK it serves as the
|
||||
/// built-in self entry, and which that card pulls. Config and certificates
|
||||
/// live outside the repo; see `wg_app_link::xdg`.
|
||||
///
|
||||
/// The working directory itself, i.e. this server expects to be run from
|
||||
/// the root of its own checkout. That is what the service unit sets, and
|
||||
/// it is where the rest of this project is driven from anyway
|
||||
/// (`./run-tests.sh`, `./app/build-apk.sh`, `./server/target/*/dev-updater`).
|
||||
/// The components say where they live from there, exactly as any other
|
||||
/// project's do -- there is nothing special about this one's layout.
|
||||
///
|
||||
/// It used to be `CARGO_MANIFEST_DIR`, which is fixed when the binary is
|
||||
/// *compiled*: a re-clone, a moved repo, or a binary built in one checkout
|
||||
/// and run against another left it naming a directory nobody pulls, and the
|
||||
/// card then quietly lost its branch line and its Pull button while
|
||||
/// otherwise working. Asking where the process was started is a question
|
||||
/// with one answer that is true right now, rather than one baked in months
|
||||
/// ago -- and being wrong about it is loud (see the warning at startup)
|
||||
/// rather than silent.
|
||||
fn self_project() -> PathBuf {
|
||||
let project = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
// Canonical because this path is *compared* -- `add_app` refuses a
|
||||
// project already in the list, and `/suggestions` marks one as added,
|
||||
// both by path equality against exactly this value. Those go through
|
||||
// `canonicalize`, so a symlink anywhere above the checkout would make
|
||||
// the same directory look like two and let this server's own project
|
||||
// be added a second time.
|
||||
//
|
||||
// Left as-is when it does not resolve: there is nothing to compare
|
||||
// against then, and the warning below wants to name the path that was
|
||||
// actually looked for.
|
||||
project.canonicalize().unwrap_or(project)
|
||||
}
|
||||
|
||||
/// Serves locally-built debug APKs to phones and the updater app.
|
||||
#[derive(Parser)]
|
||||
struct Args {
|
||||
/// TLS port for the manifest, app payloads, and the management routes.
|
||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||
port: u16,
|
||||
|
||||
/// Address to bind instead of the wg0 interface's. `0.0.0.0` restores
|
||||
/// the old LAN-wide behaviour, putting the management routes in reach
|
||||
/// of anything on the network -- still behind the bearer token, but
|
||||
/// with nothing in front of it. For recovering when the tunnel is
|
||||
/// down, not for everyday use.
|
||||
#[arg(long)]
|
||||
bind: Option<IpAddr>,
|
||||
|
||||
/// Invalidate every enrolled token, generate a fresh one, and print
|
||||
/// its enrollment QR -- the whole lost-phone story.
|
||||
#[arg(long)]
|
||||
rotate_token: bool,
|
||||
|
||||
/// Also serve the updater app's own APK over plain HTTP at "/", for a
|
||||
/// bare browser link. Optionally takes a port (default 8091).
|
||||
#[arg(short, long, num_args = 0..=1, default_missing_value = DEFAULT_BOOTSTRAP_PORT)]
|
||||
download: Option<u16>,
|
||||
|
||||
/// Drive this server's own service, and exit: `install`, `uninstall`,
|
||||
/// `start`, `stop`, `restart`, `status` or `logs`.
|
||||
///
|
||||
/// The same command the running server would use for its own `Server`
|
||||
/// component, so a script bootstrapping this machine does not have to
|
||||
/// know how a service is named or invoked -- it asks the binary that
|
||||
/// decides both. `start.sh` is the caller.
|
||||
#[arg(long, value_name = "SUBCOMMAND")]
|
||||
service: Option<String>,
|
||||
|
||||
/// Where the added-apps list and repo roots live. Defaults to
|
||||
/// `$XDG_CONFIG_HOME/dev-updater/config.ron`.
|
||||
#[arg(long)]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
/// Directory holding the TLS certificates, generated here on first
|
||||
/// start. Defaults to `$XDG_CONFIG_HOME/dev-updater/certs`.
|
||||
#[arg(long)]
|
||||
certs: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Runs one service subcommand against this server's own `Server`
|
||||
/// component and exits with its result.
|
||||
///
|
||||
/// Exists so that nothing outside this binary has to know the name of this
|
||||
/// service or how its script is invoked. Both are derived here, by the
|
||||
/// same `service::driver` the running server uses, from the same
|
||||
/// declaration -- so a bootstrap script cannot drift out of step with them
|
||||
/// the way a second copy of the arguments would.
|
||||
fn drive_own_service(self_project: &Path, subcommand: &str) -> Result<()> {
|
||||
let declaration = config::project_config(self_project);
|
||||
let component = declaration
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| service::driver(registry::SELF_KEY, component).is_some())
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"{} declares no server component with a service, so there is nothing to \
|
||||
{subcommand}",
|
||||
config::PROJECT_CONFIG_FILE
|
||||
)
|
||||
})?;
|
||||
let script = service::driver(registry::SELF_KEY, component).expect("just found one");
|
||||
|
||||
// Written out first: `install` is the step that needs it, and it is
|
||||
// also the step a fresh machine runs before any server has started.
|
||||
shipped::install().context("write this server's shipped scripts")?;
|
||||
|
||||
let output = service::run(&script, self_project, component.cwd(), subcommand)
|
||||
.map_err(|message| anyhow::anyhow!("{subcommand} failed: {message}"))?;
|
||||
if !output.is_empty() {
|
||||
println!("{output}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt().with_env_filter("info").init();
|
||||
// Before anything else can rebuild this binary: once a build replaces
|
||||
// the file, the running process can no longer name where it came from.
|
||||
restart::remember_executable();
|
||||
// Before anything can be asked about a service: a component declaring
|
||||
// `Managed` is driven through this file, and a stale copy of it would
|
||||
// be the shared default drifting in the one way it was meant to stop.
|
||||
if let Err(err) = shipped::install() {
|
||||
tracing::warn!(
|
||||
"could not write this server's shipped scripts to {} ({err}) -- a component \
|
||||
declaring Managed, and a build asking for a progress bar, will not work until \
|
||||
this succeeds",
|
||||
crate::logs::data_dir().display()
|
||||
);
|
||||
}
|
||||
let args = Args::parse();
|
||||
|
||||
// Printed once the bind address is known, so the QR carries the
|
||||
// address the phone should actually dial.
|
||||
let mut pending_enrollment: Option<String> = None;
|
||||
|
||||
let config_path = args
|
||||
.config
|
||||
.unwrap_or_else(|| wg_app_link::xdg::config_home(PRODUCT).join("config.ron"));
|
||||
// The updater app's own Gradle project, not the repo root: it is the
|
||||
// path a suggestion for this repo would carry, and matching it is what
|
||||
// stops the built-in self entry from also being offered as something to
|
||||
// add.
|
||||
let self_project = self_project();
|
||||
// Said out loud because the alternative is finding out from a card that
|
||||
// looks fine and never updates: with no checkout there is no branch,
|
||||
// no Pull and no commit count, which reads exactly like a project that
|
||||
// simply has no news.
|
||||
if git::status(&self_project).is_none() {
|
||||
tracing::warn!(
|
||||
"no checkout at {}, so the {} card can't show commits or offer Pull. This \
|
||||
server takes its own project from the working directory: start it from the \
|
||||
root of the checkout you pull, as the service unit does.",
|
||||
self_project.display(),
|
||||
registry::SELF_LABEL,
|
||||
);
|
||||
} else if !self_project.join(config::PROJECT_CONFIG_FILE).is_file() {
|
||||
// In a checkout, but not one of *this* project: same broken card,
|
||||
// different cause, so it gets its own sentence rather than being
|
||||
// folded into the one above.
|
||||
tracing::warn!(
|
||||
"{} has no {}, so the {} card has nothing to build. This server takes its own \
|
||||
project from the working directory: start it from the root of the checkout \
|
||||
you pull.",
|
||||
self_project.display(),
|
||||
config::PROJECT_CONFIG_FILE,
|
||||
registry::SELF_LABEL,
|
||||
);
|
||||
}
|
||||
// Before the state, the certificates and the listeners: this mode
|
||||
// installs or reports on the service and exits, and doing any of that
|
||||
// first would mean generating a CA in order to run `status`.
|
||||
if let Some(subcommand) = args.service.as_deref() {
|
||||
return drive_own_service(&self_project, subcommand);
|
||||
}
|
||||
|
||||
let state = Arc::new(
|
||||
AppState::new(config_path.clone(), self_project)
|
||||
.with_context(|| format!("failed to load {}", config_path.display()))?,
|
||||
);
|
||||
// Asked on this server's own behalf, before any phone asks. What a
|
||||
// component is doing is known only from the first answer of *this
|
||||
// process's* life, and a row whose state is unknown is drawn with no
|
||||
// buttons at all -- so a restart is otherwise visible on the phone as
|
||||
// every server row losing its controls until a check it started
|
||||
// catches up. Restarting is the ordinary way this server is updated,
|
||||
// which makes that the moment it is most likely to be looked at.
|
||||
//
|
||||
// Free, near enough: it is the work the first `/manifest` would start
|
||||
// anyway, off the request path either way, and it returns at once.
|
||||
routes::refresh_checkouts(&state, &state.entries());
|
||||
|
||||
let certs_dir = args
|
||||
.certs
|
||||
.unwrap_or_else(|| wg_app_link::xdg::config_home(PRODUCT).join("certs"));
|
||||
let certificates =
|
||||
wg_app_link::certs::ensure(PRODUCT, &certs_dir, &wg_app_link::netif::local_addresses())
|
||||
.with_context(|| {
|
||||
format!("failed to prepare certificates in {}", certs_dir.display())
|
||||
})?;
|
||||
if certificates.ca_is_new {
|
||||
tracing::warn!(
|
||||
"a new CA was generated in {} -- any installed updater app pins the previous one \
|
||||
and can no longer reach this server. Rebuild it (app/build-apk.sh, which embeds \
|
||||
this CA) and reinstall over the bootstrap port: --download",
|
||||
certs_dir.display(),
|
||||
);
|
||||
}
|
||||
let tls_config = axum_server::tls_rustls::RustlsConfig::from_pem_file(
|
||||
&certificates.leaf_cert,
|
||||
&certificates.leaf_key,
|
||||
)
|
||||
.await
|
||||
.context("failed to load TLS cert/key")?;
|
||||
|
||||
// Token bootstrap: first run generates one; --rotate-token replaces
|
||||
// whatever exists. Either way the plaintext appears exactly once, in
|
||||
// the QR printed here.
|
||||
if args.rotate_token || state.tokens().is_empty() {
|
||||
let rotating = args.rotate_token && !state.tokens().is_empty();
|
||||
let token = auth::generate_token();
|
||||
state.set_tokens(vec![TokenEntry {
|
||||
name: "phone".to_string(),
|
||||
sha256: auth::token_hash_hex(&token),
|
||||
}])?;
|
||||
if rotating {
|
||||
tracing::info!("rotated the enrolled token; the previous one is now invalid");
|
||||
}
|
||||
pending_enrollment = Some(token);
|
||||
}
|
||||
|
||||
let bind_ip = match args.bind {
|
||||
Some(ip) => {
|
||||
tracing::warn!(
|
||||
"binding {ip} by explicit --bind override -- anything that can reach this \
|
||||
address can now reach the management routes, with only the bearer token in \
|
||||
front of them"
|
||||
);
|
||||
ip
|
||||
}
|
||||
None => wg_app_link::netif::wg_address(PRODUCT)?,
|
||||
};
|
||||
|
||||
if let Some(bootstrap_port) = args.download {
|
||||
// The bootstrap listener serves exactly one thing: this app's own
|
||||
// APK. Saying so up front, with the address to open and whether
|
||||
// there is anything behind it, because the alternative is finding
|
||||
// out from a bare 404 in a phone browser -- which is where this
|
||||
// flag is used and where there is least to go on.
|
||||
let self_entry = state.entry(registry::SELF_KEY);
|
||||
match self_entry
|
||||
.as_ref()
|
||||
.and_then(|entry| entry.resolve_apk(None))
|
||||
{
|
||||
// The age is here because this listener serves the file already
|
||||
// on disk and builds nothing, so an old APK installs in silence.
|
||||
// That one is unusually expensive to land on: a fresh install is
|
||||
// not enrolled, and everything that would replace it -- Update,
|
||||
// and the QR scan that gets a device enrolled at all -- lives in
|
||||
// the copy being installed. Ship a stale one and the way out of
|
||||
// it is another trip through this flag.
|
||||
Some(apk) => tracing::info!(
|
||||
"bootstrap link: open http://{bind_ip}:{bootstrap_port} in the phone's browser \
|
||||
to install {} ({} build from {}). Run ./app/build-apk.sh first if that is \
|
||||
older than the checkout.",
|
||||
self_entry
|
||||
.map(|entry| entry.label.clone())
|
||||
.unwrap_or_default(),
|
||||
apk.variant,
|
||||
describe_age(apk.modified),
|
||||
),
|
||||
None => tracing::warn!(
|
||||
"--download is on, but there is no APK to serve: nothing is built under \
|
||||
{}. http://{bind_ip}:{bootstrap_port} will answer 404 until you build it -- \
|
||||
run ./app/build-apk.sh on this machine.",
|
||||
self_entry
|
||||
.map(|entry| entry.project_path.display().to_string())
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
}
|
||||
tokio::spawn(run_bootstrap_listener(
|
||||
Arc::clone(&state),
|
||||
bind_ip,
|
||||
bootstrap_port,
|
||||
));
|
||||
} else {
|
||||
tracing::info!(
|
||||
"--download not passed -- port {DEFAULT_BOOTSTRAP_PORT} (updater bootstrap link) won't be served",
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!("config: {}", config_path.display());
|
||||
let roots = state.repo_roots();
|
||||
if roots.is_empty() {
|
||||
tracing::info!(
|
||||
"no repo roots configured yet -- set one from the app's Add screen to get \
|
||||
project suggestions",
|
||||
);
|
||||
} else {
|
||||
for root in &roots {
|
||||
tracing::info!("scanning for projects under {}", root.display());
|
||||
}
|
||||
}
|
||||
for entry in state.entries() {
|
||||
match entry.resolve_apk(None) {
|
||||
Some(apk) => tracing::info!(" {} -> {}", entry.key, apk.path.display()),
|
||||
None => tracing::warn!(
|
||||
" {} -> no build found under {} (it will show as not built)",
|
||||
entry.key,
|
||||
entry.project_path.display(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(token) = pending_enrollment {
|
||||
auth::print_enrollment(bind_ip, args.port, &token)?;
|
||||
}
|
||||
|
||||
let addr = SocketAddr::new(bind_ip, args.port);
|
||||
tracing::info!("serving https://{addr}");
|
||||
let app = routes::tls_router(Arc::clone(&state)).layer(axum::middleware::from_fn_with_state(
|
||||
state,
|
||||
auth::require_token,
|
||||
));
|
||||
axum_server::bind_rustls(addr, tls_config)
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.await
|
||||
.context("TLS listener failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How long ago `when` was, in the largest whole unit that fits.
|
||||
///
|
||||
/// Rounded down and never more precise than the unit it names, because the
|
||||
/// only question being answered is "is this the build I just made, or one
|
||||
/// from last week?".
|
||||
fn describe_age(when: SystemTime) -> String {
|
||||
let Ok(elapsed) = when.elapsed() else {
|
||||
// An mtime in the future is a clock that moved, not an age.
|
||||
return "a timestamp in the future".to_string();
|
||||
};
|
||||
let seconds = elapsed.as_secs();
|
||||
let (count, unit) = match seconds {
|
||||
..60 => return "under a minute ago".to_string(),
|
||||
60..3600 => (seconds / 60, "minute"),
|
||||
3600..86400 => (seconds / 3600, "hour"),
|
||||
_ => (seconds / 86400, "day"),
|
||||
};
|
||||
let plural = if count == 1 { "" } else { "s" };
|
||||
format!("{count} {unit}{plural} ago")
|
||||
}
|
||||
|
||||
async fn run_bootstrap_listener(state: Arc<AppState>, bind_ip: IpAddr, port: u16) {
|
||||
// Same address as the TLS listener: inside the tunnel, so this port's
|
||||
// plain HTTP is carried encrypted anyway, and a phone that can reach
|
||||
// the server can reach its bootstrap link too.
|
||||
let addr = SocketAddr::new(bind_ip, port);
|
||||
let listener = match tokio::net::TcpListener::bind(addr).await {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to bind bootstrap listener on port {port}: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tracing::info!("serving http://{addr} (updater bootstrap link)");
|
||||
if let Err(err) = axum::serve(
|
||||
listener,
|
||||
routes::bootstrap_router(state).into_make_service(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!("bootstrap listener failed: {err:#}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
//! What a component leaves on the build machine, and taking it away when
|
||||
//! the service goes.
|
||||
//!
|
||||
//! Uninstalling used to remove the service and stop there, which is the
|
||||
//! right default -- the built files stay, and so does whatever the thing
|
||||
//! accumulated while it ran. But there is no other way to reach any of it
|
||||
//! from a phone, so "uninstall and then clean up by hand at the machine"
|
||||
//! is a capability withheld rather than a decision offered. The Uninstall
|
||||
//! dialog therefore asks about three things separately, and this module is
|
||||
//! what it asks.
|
||||
//!
|
||||
//! The three are deliberately not one switch. They differ in what losing
|
||||
//! them costs: logs are a record of what already happened, data is what
|
||||
//! the thing produced, and config is what somebody typed. Only the first
|
||||
//! is on by default.
|
||||
//!
|
||||
//! **Nothing here is recoverable, and nothing here is guarded by path.**
|
||||
//! A `data:` or `config:` the project declared is removed wherever it
|
||||
//! points -- Iris's call, on 2026-08-29, over the alternative of refusing
|
||||
//! anything outside the XDG directories. What stands in for that guard is
|
||||
//! the dialog: the resolved path is shown, per toggle, before the button
|
||||
//! can be pressed, so the phone never asks for a path nobody saw. Any
|
||||
//! change that stops the path being displayed removes the only check
|
||||
//! there is.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::config::ResourceFacts;
|
||||
|
||||
/// Where a project keeps the state that outlives a build.
|
||||
///
|
||||
/// `None` is "this project has not said", which is a different thing from
|
||||
/// a path that is there and empty, and the dialog draws it differently:
|
||||
/// one greys the toggle out saying nothing is there, the other says it
|
||||
/// cannot tell. A path that *is* known is resolved whether or not it
|
||||
/// exists, because absent from the display is how "we did not look" and
|
||||
/// "there is nothing" become the same thing.
|
||||
#[derive(Default)]
|
||||
pub struct StatePaths {
|
||||
pub data: Option<PathBuf>,
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Turns what a project said about itself into the two directories.
|
||||
///
|
||||
/// An explicit `data:`/`config:` wins. Otherwise they follow from the
|
||||
/// project's `name:` -- `$XDG_DATA_HOME/<name>` and
|
||||
/// `$XDG_CONFIG_HOME/<name>`, through the same `wg_app_link::xdg` both
|
||||
/// servers use for their own state rather than a second reading of the
|
||||
/// same rule. A project that says neither gets neither, and nothing is
|
||||
/// invented from the checkout's directory name, the config key, or a
|
||||
/// crate name: those are all facts that happen to be true rather than
|
||||
/// things the project asserted, and a guess that lands on a directory
|
||||
/// which does not exist reads exactly like a directory that is empty.
|
||||
///
|
||||
/// A path may start with `~`, and a relative one is resolved against the
|
||||
/// project -- the rule `cwd` already follows, so a project's file has one
|
||||
/// meaning of "relative" rather than two.
|
||||
pub fn paths(facts: &ResourceFacts, project: &Path) -> StatePaths {
|
||||
StatePaths {
|
||||
data: resolve(facts.data.as_deref(), project)
|
||||
.or_else(|| facts.name.as_deref().map(wg_app_link::xdg::data_home)),
|
||||
config: resolve(facts.config.as_deref(), project)
|
||||
.or_else(|| facts.name.as_deref().map(wg_app_link::xdg::config_home)),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(declared: Option<&Path>, project: &Path) -> Option<PathBuf> {
|
||||
let declared = declared?;
|
||||
// The same expansion a path typed on a phone gets, so `~` means one
|
||||
// thing across the whole server rather than one thing per reader.
|
||||
let expanded = declared
|
||||
.to_str()
|
||||
.map(crate::config::expand_tilde)
|
||||
.unwrap_or_else(|| declared.to_path_buf());
|
||||
Some(if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
project.join(expanded)
|
||||
})
|
||||
}
|
||||
|
||||
/// Which of the three the phone asked to take away.
|
||||
///
|
||||
/// A struct rather than three arguments so a caller cannot transpose two
|
||||
/// booleans, which is the one mistake here that deletes the wrong thing
|
||||
/// without failing.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Wanted {
|
||||
pub logs: bool,
|
||||
pub data: bool,
|
||||
pub config: bool,
|
||||
}
|
||||
|
||||
impl Wanted {
|
||||
/// Nothing beyond removing the service itself.
|
||||
pub fn nothing(&self) -> bool {
|
||||
!self.logs && !self.data && !self.config
|
||||
}
|
||||
|
||||
/// Removing the data implies removing the logs.
|
||||
///
|
||||
/// Applied here rather than trusted from the request, because the
|
||||
/// dialog forces the same pairing and the two must not be able to
|
||||
/// disagree -- a project whose service script writes its log inside
|
||||
/// its own data directory would otherwise have the log deleted by a
|
||||
/// request that said to keep it, and be told the opposite.
|
||||
pub fn normalized(self) -> Self {
|
||||
Self {
|
||||
logs: self.logs || self.data,
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes what was asked for, and says what could not be removed.
|
||||
///
|
||||
/// Returns one line per failure, in the order the three are listed in the
|
||||
/// dialog. An empty result is a clean sweep. Failures do not stop the
|
||||
/// rest: the service is already gone by the time this runs, so stopping
|
||||
/// half way would leave a component that is uninstalled, partly cleaned,
|
||||
/// and reported as failed -- three states to reason about instead of one.
|
||||
///
|
||||
/// `log_files` is collected by the caller *before* the service is
|
||||
/// uninstalled, because the script that reports where its log lives is
|
||||
/// the thing being removed.
|
||||
pub fn remove(paths: &StatePaths, log_files: &[PathBuf], wanted: Wanted) -> Vec<String> {
|
||||
let wanted = wanted.normalized();
|
||||
let mut problems = Vec::new();
|
||||
|
||||
if wanted.logs {
|
||||
for file in log_files {
|
||||
if let Err(err) = remove_file(file) {
|
||||
problems.push(format!(
|
||||
"could not remove the log {}: {err}",
|
||||
file.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
// The directories those files were the only contents of. Removed
|
||||
// only when empty, so this can never take away more than the logs
|
||||
// it was pointed at -- and a component's log directory is one this
|
||||
// server made, so leaving it behind is leaving the same kind of
|
||||
// orphan the logs themselves were.
|
||||
for parent in log_files.iter().filter_map(|file| file.parent()) {
|
||||
let _ = std::fs::remove_dir(parent);
|
||||
}
|
||||
}
|
||||
// Nothing to remove when the project never said where it is. The
|
||||
// dialog does not offer the toggle in that state, so this is the
|
||||
// belt to its braces rather than a case anyone reaches by hand.
|
||||
for (asked, what, path) in [
|
||||
(wanted.data, "data", paths.data.as_deref()),
|
||||
(wanted.config, "config", paths.config.as_deref()),
|
||||
] {
|
||||
let Some(path) = path.filter(|_| asked) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(err) = remove_tree(path) {
|
||||
problems.push(format!(
|
||||
"could not remove the {what} at {}: {err}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// Already gone is the outcome that was asked for, not a failure --
|
||||
/// otherwise pressing Uninstall twice reports a problem the second time.
|
||||
fn remove_file(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_tree(path: &Path) -> std::io::Result<()> {
|
||||
match std::fs::remove_dir_all(path) {
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn facts(name: Option<&str>, data: Option<&str>) -> ResourceFacts {
|
||||
ResourceFacts {
|
||||
name: name.map(str::to_string),
|
||||
data: data.map(PathBuf::from),
|
||||
config: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A project that names itself gets both directories from that name,
|
||||
/// resolved the way both servers resolve their own.
|
||||
#[test]
|
||||
fn a_name_is_enough_to_place_both_directories() {
|
||||
let paths = paths(&facts(Some("ai-app"), None), Path::new("/repos/ai-app"));
|
||||
assert!(paths.data.as_ref().unwrap().ends_with("ai-app"));
|
||||
assert!(paths.config.as_ref().unwrap().ends_with("ai-app"));
|
||||
assert_ne!(paths.data, paths.config);
|
||||
}
|
||||
|
||||
/// The correction that produced this module: nothing is invented from
|
||||
/// the checkout's directory, the config key, or anything else that
|
||||
/// merely happens to be true. Not said is not knowing.
|
||||
#[test]
|
||||
fn a_project_that_says_nothing_places_nothing() {
|
||||
let paths = paths(&ResourceFacts::default(), Path::new("/repos/ai-app"));
|
||||
assert_eq!(paths.data, None);
|
||||
assert_eq!(paths.config, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_path_wins_over_the_name() {
|
||||
let paths = paths(
|
||||
&facts(Some("ai-app"), Some("/srv/sessions")),
|
||||
Path::new("/repos/ai-app"),
|
||||
);
|
||||
assert_eq!(paths.data.as_deref(), Some(Path::new("/srv/sessions")));
|
||||
assert!(paths.config.as_ref().unwrap().ends_with("ai-app"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_path_is_read_against_the_project() {
|
||||
let paths = paths(&facts(None, Some("state")), Path::new("/repos/ai-app"));
|
||||
assert_eq!(
|
||||
paths.data.as_deref(),
|
||||
Some(Path::new("/repos/ai-app/state"))
|
||||
);
|
||||
}
|
||||
|
||||
/// The pairing the dialog also enforces. Stated in both places on
|
||||
/// purpose, and asserted here because this is the half that deletes.
|
||||
#[test]
|
||||
fn asking_for_the_data_asks_for_the_logs_too() {
|
||||
let asked = Wanted {
|
||||
logs: false,
|
||||
data: true,
|
||||
config: false,
|
||||
};
|
||||
assert!(asked.normalized().logs);
|
||||
// And not the other way: logs are the cheap one.
|
||||
let logs_only = Wanted {
|
||||
logs: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!logs_only.normalized().data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_what_is_already_gone_is_not_a_failure() {
|
||||
let dir = std::env::temp_dir().join(format!("purge-test-{}", std::process::id()));
|
||||
let paths = StatePaths {
|
||||
data: Some(dir.join("data")),
|
||||
config: Some(dir.join("config")),
|
||||
};
|
||||
let problems = remove(
|
||||
&paths,
|
||||
&[dir.join("nothing.log")],
|
||||
Wanted {
|
||||
logs: true,
|
||||
data: true,
|
||||
config: true,
|
||||
},
|
||||
);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
}
|
||||
|
||||
/// Asked to remove something the project never located, there is
|
||||
/// nothing to remove and nothing to complain about.
|
||||
#[test]
|
||||
fn a_path_that_was_never_known_removes_nothing() {
|
||||
let problems = remove(
|
||||
&StatePaths::default(),
|
||||
&[],
|
||||
Wanted {
|
||||
logs: false,
|
||||
data: true,
|
||||
config: true,
|
||||
},
|
||||
);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_was_asked_for_goes_and_the_rest_stays() {
|
||||
let dir = std::env::temp_dir().join(format!("purge-keep-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let paths = StatePaths {
|
||||
data: Some(dir.join("data")),
|
||||
config: Some(dir.join("config")),
|
||||
};
|
||||
std::fs::create_dir_all(paths.data.as_ref().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(paths.config.as_ref().unwrap()).unwrap();
|
||||
std::fs::write(paths.data.as_ref().unwrap().join("session"), "x").unwrap();
|
||||
|
||||
let problems = remove(
|
||||
&paths,
|
||||
&[],
|
||||
Wanted {
|
||||
logs: false,
|
||||
data: true,
|
||||
config: false,
|
||||
},
|
||||
);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
assert!(!paths.data.as_ref().unwrap().exists(), "asked for");
|
||||
assert!(paths.config.as_ref().unwrap().exists(), "not asked for");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,266 @@
|
||||
//! Reading what a project says about itself.
|
||||
//!
|
||||
//! A project's `resources:` declaration points at values the project
|
||||
//! keeps for its own use -- its name, where its data and config live --
|
||||
//! and this reads the few of them this server needs. The file belongs to
|
||||
//! the project: its own code is expected to read the same one, which is
|
||||
//! why unrecognised keys are ignored rather than refused.
|
||||
//!
|
||||
//! **Off the request path, like every other slow answer here.** The
|
||||
//! `Script` variant spawns a process, and `/manifest` is fetched on every
|
||||
//! open, resume and Refresh -- the one thing that path must not do. So
|
||||
//! this goes through [`crate::checks`] beside the git and service checks,
|
||||
//! answers arriving after the response that started them, and the phone
|
||||
//! waiting on all three through the same outstanding count.
|
||||
//!
|
||||
//! Not knowing is a first-class answer. A project that declares nothing,
|
||||
//! a file that will not parse, a script that fails: each leaves the
|
||||
//! Uninstall dialog saying it cannot tell where the data lives, rather
|
||||
//! than filling in a directory that looks plausible. The version of this
|
||||
//! that guessed -- first from the config key, then from the checkout's
|
||||
//! directory name -- was wrong in a way nothing could see, because a path
|
||||
//! that is not there reads as "this component keeps nothing here".
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::checks::{Checks, Report};
|
||||
use crate::config::{ResourceFacts, Resources};
|
||||
|
||||
/// How long a resources script is given before it is treated as having
|
||||
/// failed.
|
||||
///
|
||||
/// It prints three values; anything that takes longer than this is stuck
|
||||
/// rather than slow, and a check that never finishes holds the key
|
||||
/// claimed forever -- so the card would sit on "still finding out" with
|
||||
/// nothing ever arriving.
|
||||
const SCRIPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Reads a project's resources, or says why it could not.
|
||||
///
|
||||
/// The error is what the card shows, so it names the file or the command
|
||||
/// rather than only the underlying complaint -- "no such file" without a
|
||||
/// path is not something a person can act on.
|
||||
pub fn read(project: &Path, declaration: &Resources) -> Result<ResourceFacts, String> {
|
||||
match declaration {
|
||||
Resources::Inline(facts) => Ok(facts.clone()),
|
||||
Resources::Ron(path) => {
|
||||
// `~` expanded the same way it is for a path typed on a
|
||||
// phone; `join` on an absolute path yields that path, so a
|
||||
// resources file may live outside the checkout.
|
||||
let path = project.join(
|
||||
path.to_str()
|
||||
.map(crate::config::expand_tilde)
|
||||
.unwrap_or_else(|| path.clone()),
|
||||
);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|err| format!("reading {}: {err}", path.display()))?;
|
||||
parse(&text).map_err(|err| format!("in {}: {err}", path.display()))
|
||||
}
|
||||
Resources::Script(command) => {
|
||||
let text = run(project, command)?;
|
||||
parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's own house rules, which are this server's: the file is the
|
||||
/// *body* of the struct, and an optional value is written bare.
|
||||
///
|
||||
/// Shared with `wg_app_link::format` rather than reimplemented, because
|
||||
/// the project's own code parses the same file and the two must agree
|
||||
/// about whether it has outer parentheses.
|
||||
fn parse(text: &str) -> Result<ResourceFacts, String> {
|
||||
wg_app_link::format::parse(text).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn run(project: &Path, command: &crate::config::Command) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
|
||||
let mut child = command
|
||||
.to_process(project, None, &[])?
|
||||
// Closed, because a script that asks a question would otherwise
|
||||
// wait for an answer nobody is there to give -- the same rule the
|
||||
// service scripts run under.
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("starting {}: {err}", command.to_line()))?;
|
||||
|
||||
let deadline = std::time::Instant::now() + SCRIPT_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Err(err) => return Err(format!("waiting on {}: {err}", command.to_line())),
|
||||
Ok(Some(status)) => {
|
||||
let mut out = String::new();
|
||||
if let Some(mut pipe) = child.stdout.take() {
|
||||
let _ = pipe.read_to_string(&mut out);
|
||||
}
|
||||
if status.success() {
|
||||
return Ok(out);
|
||||
}
|
||||
let mut err = String::new();
|
||||
if let Some(mut pipe) = child.stderr.take() {
|
||||
let _ = pipe.read_to_string(&mut err);
|
||||
}
|
||||
return Err(format!(
|
||||
"{} failed ({status}): {}",
|
||||
command.to_line(),
|
||||
err.lines().next().unwrap_or("no output").trim()
|
||||
));
|
||||
}
|
||||
Ok(None) if std::time::Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
return Err(format!(
|
||||
"{} did not answer within {}s",
|
||||
command.to_line(),
|
||||
SCRIPT_TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One project to read, and where from.
|
||||
pub struct Target {
|
||||
pub key: String,
|
||||
pub project: PathBuf,
|
||||
pub declaration: Resources,
|
||||
}
|
||||
|
||||
/// The resources this server has read, by project key.
|
||||
///
|
||||
/// A facade over [`Checks`] in the same shape as `git::RemoteChecks` and
|
||||
/// `service::ServiceChecks`, so all three are started, counted and
|
||||
/// reported the same way.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ResourceChecks(Arc<Checks<String, ResourceFacts>>);
|
||||
|
||||
impl ResourceChecks {
|
||||
/// What this project last said about itself. `None` before anything
|
||||
/// has been read, which is not the same as a project that declares
|
||||
/// nothing -- the caller knows which by whether there is a
|
||||
/// declaration at all.
|
||||
pub fn facts(&self, key: &str) -> Option<ResourceFacts> {
|
||||
self.0.answer(key)
|
||||
}
|
||||
|
||||
pub fn is_checking(&self, key: &str) -> bool {
|
||||
self.0.is_checking(key)
|
||||
}
|
||||
|
||||
pub fn error(&self, key: &str) -> Option<String> {
|
||||
self.0.error(key)
|
||||
}
|
||||
|
||||
/// Reads every target that isn't already being read. Returns at once.
|
||||
pub fn refresh(&self, targets: Vec<Target>) {
|
||||
for target in targets {
|
||||
let Target {
|
||||
key,
|
||||
project,
|
||||
declaration,
|
||||
} = target;
|
||||
self.0
|
||||
.start(key, move |_| Report::from(read(&project, &declaration)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets a project being removed, so its key cannot be answered
|
||||
/// after nothing points at it.
|
||||
pub fn forget(&self, key: &str) {
|
||||
self.0.retain(|held| held != key);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_is_read_as_the_body_of_the_struct() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-ron-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("resources.ron"),
|
||||
"// what this project calls itself\nname: \"ai-app\",\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let facts = read(&dir, &Resources::Ron(PathBuf::from("resources.ron"))).unwrap();
|
||||
assert_eq!(facts.name.as_deref(), Some("ai-app"));
|
||||
assert_eq!(facts.data, None, "not said is not a value to invent");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The project's file, so what this server does not recognise is
|
||||
/// somebody else's business rather than an error.
|
||||
#[test]
|
||||
fn keys_this_server_does_not_know_are_ignored() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-extra-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("r.ron"),
|
||||
"name: \"ai-app\",\nmodelCache: \"~/models\",\nport: 8443,\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let facts = read(&dir, &Resources::Ron(PathBuf::from("r.ron"))).unwrap();
|
||||
assert_eq!(facts.name.as_deref(), Some("ai-app"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_says_which_file() {
|
||||
let err = read(
|
||||
Path::new("/nowhere-at-all"),
|
||||
&Resources::Ron(PathBuf::from("r.ron")),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("r.ron"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_read_from_its_output() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-script-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("say.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\necho 'name: \"computed\",'\n").unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
let facts = read(
|
||||
&dir,
|
||||
&Resources::Script(crate::config::Command::from_line("./say.sh")),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(facts.name.as_deref(), Some("computed"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A failing script is a state the card has a word for, not something
|
||||
/// to fall back from.
|
||||
#[test]
|
||||
fn a_failing_script_reports_rather_than_answering() {
|
||||
let dir = std::env::temp_dir().join(format!("resources-fail-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let script = dir.join("no.sh");
|
||||
std::fs::write(&script, "#!/bin/sh\necho 'nope' >&2\nexit 3\n").unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
let err = read(
|
||||
&dir,
|
||||
&Resources::Script(crate::config::Command::from_line("./no.sh")),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("nope"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! Restarting this server in place, after it has rebuilt itself.
|
||||
//!
|
||||
//! Only the self-update path uses this: pulling this repository rebuilds
|
||||
//! the binary, and a new binary sitting on disk while the old process
|
||||
//! keeps serving is not an update.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use crate::config::Command;
|
||||
|
||||
/// APK downloads currently being sent, so a restart can wait for them
|
||||
/// instead of cutting them off.
|
||||
///
|
||||
/// A build of this server's own project ends in an exec, and the thing
|
||||
/// that most often happens *immediately* after such a build is the phone
|
||||
/// downloading the APK it just produced. Those two raced: the exec was on
|
||||
/// a fixed two-second timer, the phone learned the build had finished up
|
||||
/// to a poll interval late, and an APK takes longer than the remainder to
|
||||
/// transfer. The download died with the socket and the card reported that
|
||||
/// the server could not be reached -- in the middle of the update that was
|
||||
/// working.
|
||||
///
|
||||
/// Counting them is the honest fix. A timer longer than "most" downloads
|
||||
/// would be a guess about a number this can simply know.
|
||||
#[derive(Default)]
|
||||
pub struct Downloads(AtomicUsize);
|
||||
|
||||
impl Downloads {
|
||||
/// Counts one download until the returned guard is dropped, which
|
||||
/// happens when the response body is finished or the client goes away
|
||||
/// -- either way, when this server is no longer sending.
|
||||
pub fn start(self: &Arc<Self>) -> DownloadGuard {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
DownloadGuard(Arc::clone(self))
|
||||
}
|
||||
|
||||
fn in_flight(&self) -> usize {
|
||||
self.0.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DownloadGuard(Arc<Downloads>);
|
||||
|
||||
impl Drop for DownloadGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a restart will wait for downloads to finish before going
|
||||
/// anyway.
|
||||
///
|
||||
/// Bounded because a stalled client must not be able to keep this server
|
||||
/// on an old binary indefinitely -- at that point the update not landing
|
||||
/// is the worse failure. Generous enough for a large APK over a phone's
|
||||
/// connection.
|
||||
const DOWNLOAD_GRACE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// How long to give a service manager to actually stop this process after
|
||||
/// being asked to, before concluding it will not and restarting without it.
|
||||
///
|
||||
/// Generous, because being restarted twice is a second's interruption
|
||||
/// while restarting *early* would race the manager's own start and could
|
||||
/// leave two of this server, or none. Bounded, because the phone reaches
|
||||
/// this machine only through this process: a restart that quietly never
|
||||
/// happens is the failure that ends with somebody at a keyboard.
|
||||
const HANDOVER: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Where this process was started from, captured before anything can
|
||||
/// rebuild it.
|
||||
///
|
||||
/// A process-wide value rather than something passed down, because it is
|
||||
/// process identity: there is exactly one answer, it never changes, and
|
||||
/// the one thing that *would* change it is the rebuild this module exists
|
||||
/// to survive. Reading it late is precisely the bug -- see below.
|
||||
static EXECUTABLE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// What that file looked like at startup, so a later build can be asked
|
||||
/// whether it actually replaced it. `None` if it couldn't be read.
|
||||
static ORIGINAL: OnceLock<Option<Identity>> = OnceLock::new();
|
||||
|
||||
/// Enough of a file to tell "untouched" from "something wrote here".
|
||||
///
|
||||
/// Deliberately `stat` rather than a hash of the contents. The question is
|
||||
/// whether the build replaced the binary, and cargo replaces it by
|
||||
/// renaming a new file over the old -- which changes the inode, and almost
|
||||
/// always the length and mtime too. Reading tens of megabytes to answer
|
||||
/// what three numbers already answer would cost more than the restart it
|
||||
/// is trying to avoid.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Identity {
|
||||
inode: u64,
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
}
|
||||
|
||||
fn identity(path: &Path) -> Option<Identity> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let meta = std::fs::metadata(path).ok()?;
|
||||
Some(Identity {
|
||||
inode: meta.ino(),
|
||||
len: meta.len(),
|
||||
modified: meta.modified().ok(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the binary on disk is still the one this process is running.
|
||||
///
|
||||
/// A build that finds nothing to do leaves the file alone, and exec-ing
|
||||
/// into a byte-identical binary drops every open connection to achieve
|
||||
/// nothing -- so the self-update path asks this before restarting.
|
||||
///
|
||||
/// Answers `false` whenever it cannot tell, which is the behaviour there
|
||||
/// was before this check existed: a restart that wasn't needed is a
|
||||
/// second's interruption, while a skipped one leaves the old build serving
|
||||
/// and looks like the update silently failing.
|
||||
pub fn binary_unchanged() -> bool {
|
||||
let (Some(exe), Some(Some(original))) = (EXECUTABLE.get(), ORIGINAL.get()) else {
|
||||
return false;
|
||||
};
|
||||
identity(exe).is_some_and(|now| now == *original)
|
||||
}
|
||||
|
||||
/// Records the running binary's path. Call once, at startup.
|
||||
///
|
||||
/// This has to happen before a rebuild replaces the file. Cargo installs a
|
||||
/// new binary by renaming over the old one, which unlinks the inode this
|
||||
/// process is running from; from that moment Linux reports the path as
|
||||
/// `/path/to/binary (deleted)`, and `current_exe` hands that string back
|
||||
/// verbatim. Exec'ing it fails with "no such file or directory" -- the
|
||||
/// restart failing at the one moment it was needed.
|
||||
pub fn remember_executable() {
|
||||
match std::env::current_exe() {
|
||||
Ok(path) => {
|
||||
let _ = ORIGINAL.set(identity(&path));
|
||||
let _ = EXECUTABLE.set(path);
|
||||
}
|
||||
Err(err) => tracing::warn!(
|
||||
"cannot determine this binary's path, so restarting after a \
|
||||
self-update won't work: {err}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// How long to wait before exec-ing, so whatever asked for the restart is
|
||||
/// answered first.
|
||||
///
|
||||
/// Both callers need it for the same reason: a reply that dies with the
|
||||
/// socket looks like a failure, whether it was a build reporting success
|
||||
/// or a Restart button asking for exactly this.
|
||||
const DELAY: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Replaces this process with the binary now on disk, shortly.
|
||||
///
|
||||
/// `exec` rather than spawn-and-exit so the process keeps its PID:
|
||||
/// whatever supervises this server sees one continuous process rather than
|
||||
/// one vanishing and another appearing, so nothing has to be configured to
|
||||
/// restart it. Where there is no supervisor, the exec *is* the restart.
|
||||
///
|
||||
/// Returns at once; the exec happens on its own thread after [`DELAY`],
|
||||
/// and after any download in flight has finished -- see [`Downloads`].
|
||||
/// Waits until replacing this process would not interrupt anything: the
|
||||
/// reply is out, and no APK is still going down the wire.
|
||||
///
|
||||
/// Split out so the two ways of restarting -- exec-ing in place, and
|
||||
/// asking a service manager to do it -- wait the same way rather than one
|
||||
/// of them being remembered to.
|
||||
pub fn settle(downloads: &Downloads) {
|
||||
std::thread::sleep(DELAY);
|
||||
// The reply is out by now; what may not be out is an APK. Waiting here
|
||||
// rather than lengthening DELAY because the question is not "how long
|
||||
// is long enough", which nobody can answer, but "is this server still
|
||||
// sending", which it knows.
|
||||
let deadline = Instant::now() + DOWNLOAD_GRACE;
|
||||
while downloads.in_flight() > 0 && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
if downloads.in_flight() > 0 {
|
||||
tracing::warn!(
|
||||
"restarting with {} download(s) still open -- they were given {}s",
|
||||
downloads.in_flight(),
|
||||
DOWNLOAD_GRACE.as_secs(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces this process with the binary on disk, now.
|
||||
///
|
||||
/// Only returns on failure; on success this process is gone. The caller
|
||||
/// is expected to have called [`settle`] first.
|
||||
pub fn exec_now() {
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
let Some(exe) = EXECUTABLE.get().cloned() else {
|
||||
tracing::error!("no remembered binary path, so not restarting");
|
||||
return;
|
||||
};
|
||||
if !exe.is_file() {
|
||||
tracing::error!(
|
||||
"{} is gone, so not restarting -- the old binary keeps serving",
|
||||
exe.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
tracing::info!("restarting into {}", exe.display());
|
||||
let err = std::process::Command::new(&exe).args(&args).exec();
|
||||
tracing::error!("restart failed, carrying on with the old binary: {err}");
|
||||
}
|
||||
|
||||
/// What this server needs in order to ask its supervisor to restart it:
|
||||
/// the same three things any other server component is driven with.
|
||||
///
|
||||
/// Carried as owned values because the restart outlives the request that
|
||||
/// asked for it -- it happens on its own thread, after the reply.
|
||||
pub struct Handover {
|
||||
pub script: Command,
|
||||
pub project: PathBuf,
|
||||
pub cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Restarts this server once nothing is left to interrupt: through its
|
||||
/// supervisor when one is holding it, and by exec-ing the binary on disk
|
||||
/// when nothing is.
|
||||
///
|
||||
/// Returns at once. Every restart of this server goes through here --
|
||||
/// the Restart button and the end of a self-build alike -- so that the
|
||||
/// two cannot come to mean different things. They did once: the button
|
||||
/// exec'd unconditionally on the grounds that exec keeps the PID, which
|
||||
/// meant it was the one restart a refreshed unit did not apply to.
|
||||
///
|
||||
/// `handover` is `None` only when this project declares no server script
|
||||
/// at all, which leaves the exec as the only restart there is.
|
||||
pub fn deferred(handover: Option<Handover>, downloads: Arc<Downloads>) {
|
||||
std::thread::spawn(move || {
|
||||
settle(&downloads);
|
||||
if handover.is_some_and(|handover| through_service(&handover)) {
|
||||
return;
|
||||
}
|
||||
exec_now();
|
||||
});
|
||||
}
|
||||
|
||||
/// Asks the service manager to restart this server, reporting whether it
|
||||
/// got that far.
|
||||
///
|
||||
/// This is the answer to "can't it just stop itself and start again?". It
|
||||
/// cannot -- a process that stops itself has nothing left to start it --
|
||||
/// but its *supervisor* can, and that is the whole difference.
|
||||
///
|
||||
/// Going through the manager is also the only way a rewritten unit takes
|
||||
/// effect: `install` writes the file, and only a manager-driven start
|
||||
/// reads it. An exec cannot, because it inherits the file descriptors of
|
||||
/// the process it replaces -- so a newly declared `StandardOutput=` would
|
||||
/// go on writing wherever the old one did.
|
||||
///
|
||||
/// Answers false when no manager is holding this server -- run by hand, or
|
||||
/// never installed -- because then asking a script to stop it would stop it
|
||||
/// for good, and exec-ing is the only restart available. That is why the
|
||||
/// state is *asked* rather than assumed.
|
||||
fn through_service(handover: &Handover) -> bool {
|
||||
let Handover {
|
||||
script,
|
||||
project,
|
||||
cwd,
|
||||
} = handover;
|
||||
match crate::service::status(script, project, cwd.as_deref()) {
|
||||
Ok(crate::service::ServiceState::Running) => {}
|
||||
Ok(state) => {
|
||||
tracing::info!("this server is {state:?} to its manager, so exec-ing instead");
|
||||
return false;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("could not ask how this server is run ({err}), so exec-ing");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
tracing::info!("restarting through the service manager, so a refreshed unit applies");
|
||||
if let Err(message) = crate::service::spawn_detached(script, project, cwd.as_deref(), "restart")
|
||||
{
|
||||
// The script could not even be started, which it did without
|
||||
// stopping us -- so exec-ing is still available, and still better
|
||||
// than not restarting at all.
|
||||
tracing::error!("could not ask the service manager to restart this server: {message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nothing is waited on: what was asked for is this process ending, so
|
||||
// the answer arrives as a signal rather than as an exit status. If it
|
||||
// does not arrive, the manager did not do it -- the script failed, or
|
||||
// it restarted something that is not us -- and this server is still
|
||||
// the old binary with the update undelivered. Falling back to the exec
|
||||
// then costs a redundant restart at worst, where trusting the manager
|
||||
// and waiting forever costs the phone its only way back in.
|
||||
std::thread::sleep(HANDOVER);
|
||||
tracing::warn!(
|
||||
"still running {}s after asking the service manager to restart -- exec-ing instead",
|
||||
HANDOVER.as_secs()
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_rewritten_file_is_not_the_file_it_was() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("binary");
|
||||
std::fs::write(&path, b"one").unwrap();
|
||||
let before = identity(&path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
identity(&path).unwrap(),
|
||||
before,
|
||||
"reading twice must not look like a change"
|
||||
);
|
||||
|
||||
// How cargo installs a new binary, and the reason inode is part of
|
||||
// the identity at all: the old file is unlinked, not overwritten.
|
||||
let replacement = dir.path().join("new");
|
||||
std::fs::write(&replacement, b"two").unwrap();
|
||||
std::fs::rename(&replacement, &path).unwrap();
|
||||
assert_ne!(identity(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
/// The whole predicate, against a real binary that nothing rebuilt:
|
||||
/// this test's own. `remember_executable` is what the server calls at
|
||||
/// startup, so this is the same pair of calls the self-update path
|
||||
/// makes, with the build in between doing nothing -- which is exactly
|
||||
/// the case that must not restart.
|
||||
#[test]
|
||||
fn a_binary_nothing_rebuilt_reports_unchanged() {
|
||||
remember_executable();
|
||||
assert!(binary_unchanged());
|
||||
}
|
||||
|
||||
/// The count has to fall to zero on its own, including when a client
|
||||
/// disappears rather than finishing -- which is a drop either way.
|
||||
#[test]
|
||||
fn a_download_is_counted_only_while_it_is_being_sent() {
|
||||
let downloads = Arc::new(Downloads::default());
|
||||
assert_eq!(downloads.in_flight(), 0, "nothing is being sent yet");
|
||||
|
||||
let first = downloads.start();
|
||||
let second = downloads.start();
|
||||
assert_eq!(downloads.in_flight(), 2, "two phones at once");
|
||||
|
||||
drop(first);
|
||||
assert_eq!(downloads.in_flight(), 1, "one finished");
|
||||
|
||||
// Dropped without finishing, which is what a client going away
|
||||
// looks like from here.
|
||||
drop(second);
|
||||
assert_eq!(downloads.in_flight(), 0, "a restart may now proceed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_has_no_identity() {
|
||||
assert!(identity(Path::new("/nonexistent/dev-updater")).is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,151 @@
|
||||
//! Locating the Android SDK/NDK tools this server shells out to: `aapt2`
|
||||
//! (reading a discovered APK's package name and label), and
|
||||
//! `llvm-strip`/`zipalign`/`apksigner` (the slim-APK pipeline in
|
||||
//! `crate::strip`).
|
||||
//!
|
||||
//! Each lookup is resolved lazily and cached, since a miss is a
|
||||
//! configuration problem worth reporting with the path it looked at rather
|
||||
//! than a bare "command not found" from the failed spawn.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// This user's home directory, or an empty path when there is none.
|
||||
///
|
||||
/// Not a panic: every use of this is a path that is then checked for
|
||||
/// existence and reported with the path it looked at, so a machine with no
|
||||
/// `HOME` gets "debug keystore not found at ..." rather than a crash at
|
||||
/// startup. Uses the same `std::env::home_dir` the rest of the crate does,
|
||||
/// so there is one answer to where home is.
|
||||
fn home_dir() -> PathBuf {
|
||||
std::env::home_dir().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Honors `$ANDROID_HOME`/`$ANDROID_SDK_ROOT` before falling back to
|
||||
/// Android Studio's default location on Linux, so this works on a machine
|
||||
/// that keeps its SDK somewhere else without needing a flag.
|
||||
fn sdk_root() -> PathBuf {
|
||||
std::env::var_os("ANDROID_HOME")
|
||||
.or_else(|| std::env::var_os("ANDROID_SDK_ROOT"))
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir().join("Android/Sdk"))
|
||||
}
|
||||
|
||||
/// Highest-versioned entry directly under `dir`, by plain name sort --
|
||||
/// good enough for SDK/NDK release directories, which sort correctly as
|
||||
/// strings within a single major version scheme.
|
||||
fn newest_child(dir: &Path) -> Option<PathBuf> {
|
||||
let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||
.ok()?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| path.is_dir())
|
||||
.collect();
|
||||
children.sort();
|
||||
children.pop()
|
||||
}
|
||||
|
||||
/// The newest installed `build-tools/<version>/` directory, which is where
|
||||
/// `aapt2`, `zipalign` and `apksigner` live.
|
||||
pub fn build_tools_dir() -> Result<&'static Path> {
|
||||
static DIR: OnceLock<Result<PathBuf, String>> = OnceLock::new();
|
||||
match DIR.get_or_init(|| {
|
||||
let base = sdk_root().join("build-tools");
|
||||
newest_child(&base).ok_or_else(|| {
|
||||
format!(
|
||||
"no Android SDK build-tools found under {} -- install one \
|
||||
(`android sdk install build-tools/37.0.0`, or source \
|
||||
../app/android-env.sh, which does it for you)",
|
||||
base.display(),
|
||||
)
|
||||
})
|
||||
}) {
|
||||
Ok(dir) => Ok(dir),
|
||||
Err(message) => bail!("{message}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn aapt2_path() -> Result<PathBuf> {
|
||||
let path = build_tools_dir()?.join("aapt2");
|
||||
if !path.is_file() {
|
||||
bail!("aapt2 not found at {}", path.display());
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `llvm-strip` ships with the NDK, not the SDK, so this looks in the two
|
||||
/// places an NDK is normally installed on top of an explicit
|
||||
/// `$ANDROID_NDK_HOME`. Only the slim-APK pipeline needs it -- an app with
|
||||
/// no native libraries never reaches this.
|
||||
pub fn llvm_strip_path() -> Result<PathBuf> {
|
||||
static PATH: OnceLock<Result<PathBuf, String>> = OnceLock::new();
|
||||
match PATH.get_or_init(|| {
|
||||
let roots: Vec<PathBuf> = std::env::var_os("ANDROID_NDK_HOME")
|
||||
.map(PathBuf::from)
|
||||
.into_iter()
|
||||
.chain(newest_child(&sdk_root().join("ndk")))
|
||||
.chain(newest_child(&home_dir().join("Android")).filter(|path| {
|
||||
path.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().starts_with("android-ndk-"))
|
||||
}))
|
||||
.collect();
|
||||
|
||||
for root in &roots {
|
||||
let path = root.join("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip");
|
||||
if path.is_file() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"llvm-strip not found -- looked under {}. Install an NDK, or point \
|
||||
$ANDROID_NDK_HOME at one. Only apps with native libraries need it.",
|
||||
if roots.is_empty() {
|
||||
"no NDK install (checked $ANDROID_NDK_HOME, $ANDROID_HOME/ndk, ~/Android)"
|
||||
.to_string()
|
||||
} else {
|
||||
roots
|
||||
.iter()
|
||||
.map(|root| root.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
},
|
||||
))
|
||||
}) {
|
||||
Ok(path) => Ok(path.clone()),
|
||||
Err(message) => bail!("{message}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The debug keystore every locally-built debug APK is already signed with,
|
||||
/// reused to re-sign a stripped copy so it still installs over the original.
|
||||
pub fn debug_keystore_path() -> Result<PathBuf> {
|
||||
let path = home_dir().join(".android/debug.keystore");
|
||||
if !path.is_file() {
|
||||
bail!(
|
||||
"debug keystore not found at {} -- build any Android app once to have \
|
||||
Gradle create it",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Runs `cmd`, turning a nonzero exit into an error naming the program and
|
||||
/// its stderr rather than leaving it to be discovered as a corrupt output
|
||||
/// file later.
|
||||
pub fn run_checked(cmd: &mut std::process::Command) -> Result<()> {
|
||||
let program = cmd.get_program().to_string_lossy().into_owned();
|
||||
let output = cmd
|
||||
.output()
|
||||
.with_context(|| format!("failed to spawn {program}"))?;
|
||||
if !output.status.success() {
|
||||
bail!(
|
||||
"{program} failed ({}): {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Executable
+258
@@ -0,0 +1,258 @@
|
||||
#!/bin/sh
|
||||
# The service script dev-updater uses for a component that declares
|
||||
# `service: Managed("...")` instead of carrying one of its own.
|
||||
#
|
||||
# <this> --name NAME --exec "COMMAND ARGS" <subcommand>
|
||||
#
|
||||
# Installed by dev-updater into its own data directory and run exactly like
|
||||
# a project's own script, so nothing downstream can tell the two apart:
|
||||
# same subcommands, same four status words, same `logs` contract. This file
|
||||
# is not special, it is just the copy nobody has to maintain.
|
||||
#
|
||||
# It runs with its working directory already set to the component's -- the
|
||||
# same resolution a project's own script gets -- so `pwd` is the directory
|
||||
# the service should run in and COMMAND is resolved against it. That is why
|
||||
# there is no --dir.
|
||||
#
|
||||
# dev-updater's own `server/service` is a wrapper over this, so there is
|
||||
# one implementation of the init-system half rather than two that drift --
|
||||
# which they had, four functions byte-identical and half the rest the same.
|
||||
# It follows that a change here changes how *this server's own service* is
|
||||
# defined, and that the OpenRC branch nobody can test from the development
|
||||
# VM now has one place to be wrong instead of two.
|
||||
set -eu
|
||||
|
||||
NAME=
|
||||
EXEC=
|
||||
while [ $# -gt 1 ]; do
|
||||
case "$1" in
|
||||
--name) NAME=$2; shift 2 ;;
|
||||
--exec) EXEC=$2; shift 2 ;;
|
||||
*) echo "unknown option $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
SUBCOMMAND="${1:-}"
|
||||
|
||||
[ -n "$NAME" ] || { echo "--name is required" >&2; exit 2; }
|
||||
[ -n "$EXEC" ] || { echo "--exec is required" >&2; exit 2; }
|
||||
|
||||
# The directory this was run in, which is the component's own. Captured
|
||||
# before anything can change it, and absolute because a unit file cannot
|
||||
# hold a relative path.
|
||||
DIRECTORY=$(pwd)
|
||||
|
||||
# Resolved the same way a shell resolves a program name: one written with a
|
||||
# path separator is a file here, anything else is found on PATH. Split on
|
||||
# whitespace deliberately -- the same limit the config format has, and for
|
||||
# the same reason.
|
||||
# shellcheck disable=SC2086 # word splitting is what turns EXEC into argv
|
||||
set -- $EXEC
|
||||
PROGRAM=$1
|
||||
shift
|
||||
# Kept as one string because both managers want it that way; the
|
||||
# `${VAR:+ }` at the use sites is what stops a service with no arguments
|
||||
# getting a trailing space welded onto its ExecStart.
|
||||
ARGUMENTS=$*
|
||||
case "$PROGRAM" in
|
||||
*/*) PROGRAM="$DIRECTORY/${PROGRAM#./}" ;;
|
||||
esac
|
||||
|
||||
detect() {
|
||||
if command -v systemctl >/dev/null 2>&1 &&
|
||||
systemctl --user show-environment >/dev/null 2>&1; then
|
||||
echo systemd
|
||||
elif command -v rc-service >/dev/null 2>&1; then
|
||||
# `rc-service --user --help` is the real probe for whether this
|
||||
# OpenRC has user services at all, but it *fails when
|
||||
# XDG_RUNTIME_DIR is unset* -- so probing first reports "no service
|
||||
# manager here" for a machine that has one and is merely missing a
|
||||
# variable, sending the reader to look for something that is
|
||||
# installed. Only trust the probe once the variable is set; without
|
||||
# it, say openrc and let the check below name the real problem.
|
||||
if [ -n "${XDG_RUNTIME_DIR:-}" ] && ! rc-service --user --help >/dev/null 2>&1; then
|
||||
echo none
|
||||
else
|
||||
echo openrc
|
||||
fi
|
||||
else
|
||||
echo none
|
||||
fi
|
||||
}
|
||||
|
||||
MANAGER=$(detect)
|
||||
if [ "$MANAGER" = none ]; then
|
||||
echo "No user-service manager here: this needs systemd with a user bus," >&2
|
||||
echo "or OpenRC 0.60+ (older ones have no --user). Run $NAME by hand." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$MANAGER" = openrc ] && [ -z "${XDG_RUNTIME_DIR:-}" ]; then
|
||||
echo "XDG_RUNTIME_DIR is unset, and OpenRC stores user-service state in it." >&2
|
||||
echo "Set it at login (elogind or pam_xdg) and try again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SYSTEMD_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/$NAME.service"
|
||||
OPENRC_UNIT="${XDG_CONFIG_HOME:-$HOME/.config}/rc/init.d/$NAME"
|
||||
|
||||
LOG_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater/services/$NAME"
|
||||
LOG="$LOG_DIR/$NAME.log"
|
||||
PREVIOUS_LOG="$LOG.1"
|
||||
|
||||
rotate_log() {
|
||||
mkdir -p "$LOG_DIR"
|
||||
[ -f "$LOG" ] && mv -f "$LOG" "$PREVIOUS_LOG"
|
||||
: > "$LOG"
|
||||
}
|
||||
|
||||
installed() {
|
||||
case "$MANAGER" in
|
||||
systemd) [ -f "$SYSTEMD_UNIT" ] ;;
|
||||
openrc) [ -f "$OPENRC_UNIT" ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
do_install() {
|
||||
if [ ! -x "$PROGRAM" ] && ! command -v "$PROGRAM" >/dev/null 2>&1; then
|
||||
echo "No built program at $PROGRAM -- build it first." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$LOG_DIR"
|
||||
case "$MANAGER" in
|
||||
systemd)
|
||||
mkdir -p "$(dirname "$SYSTEMD_UNIT")"
|
||||
cat > "$SYSTEMD_UNIT" <<UNIT
|
||||
[Unit]
|
||||
Description=$NAME (installed by dev-updater)
|
||||
|
||||
[Service]
|
||||
ExecStart=$PROGRAM${ARGUMENTS:+ $ARGUMENTS}
|
||||
Restart=on-failure
|
||||
WorkingDirectory=$DIRECTORY
|
||||
StandardOutput=append:$LOG
|
||||
StandardError=append:$LOG
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
UNIT
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable "$NAME" >/dev/null
|
||||
;;
|
||||
openrc)
|
||||
mkdir -p "$(dirname "$OPENRC_UNIT")"
|
||||
cat > "$OPENRC_UNIT" <<UNIT
|
||||
#!/sbin/openrc-run
|
||||
name="$NAME"
|
||||
description="$NAME (installed by dev-updater)"
|
||||
command="$PROGRAM"
|
||||
command_args="$ARGUMENTS"
|
||||
command_background=true
|
||||
directory="$DIRECTORY"
|
||||
pidfile="\${XDG_RUNTIME_DIR}/$NAME.pid"
|
||||
output_log="$LOG"
|
||||
error_log="$LOG"
|
||||
UNIT
|
||||
chmod +x "$OPENRC_UNIT"
|
||||
rc-update --user add "$NAME" >/dev/null 2>&1 || true
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
installed || return 0
|
||||
case "$MANAGER" in
|
||||
systemd)
|
||||
systemctl --user disable --now "$NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$SYSTEMD_UNIT"
|
||||
systemctl --user daemon-reload
|
||||
;;
|
||||
openrc)
|
||||
rc-service --user "$NAME" stop >/dev/null 2>&1 || true
|
||||
rc-update --user del "$NAME" >/dev/null 2>&1 || true
|
||||
rm -f "$OPENRC_UNIT"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
control() {
|
||||
installed || { echo "$NAME is not installed" >&2; exit 1; }
|
||||
case "$MANAGER" in
|
||||
systemd) systemctl --user "$1" "$NAME" ;;
|
||||
openrc) rc-service --user "$NAME" "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "$SUBCOMMAND" in
|
||||
install) do_install ;;
|
||||
uninstall) do_uninstall ;;
|
||||
start | restart)
|
||||
rotate_log
|
||||
control "$SUBCOMMAND"
|
||||
;;
|
||||
stop) control "$SUBCOMMAND" ;;
|
||||
logs)
|
||||
[ -f "$LOG" ] && echo "$LOG"
|
||||
[ -f "$PREVIOUS_LOG" ] && echo "$PREVIOUS_LOG"
|
||||
exit 0
|
||||
;;
|
||||
status)
|
||||
if ! installed; then
|
||||
echo not-installed
|
||||
exit 0
|
||||
fi
|
||||
case "$MANAGER" in
|
||||
systemd)
|
||||
if systemctl --user --quiet is-active "$NAME"; then
|
||||
echo running
|
||||
elif systemctl --user --quiet is-failed "$NAME"; then
|
||||
echo failed
|
||||
else
|
||||
echo stopped
|
||||
fi
|
||||
;;
|
||||
openrc)
|
||||
# The exit code, not the text. `rc-service status` prints
|
||||
# its status line to *stderr*, so the obvious check --
|
||||
# discard stderr, grep stdout for "crashed" -- throws away
|
||||
# the very word it is looking for, matches nothing, and
|
||||
# reports a service that fell over as `stopped`. That is
|
||||
# precisely the lie the `failed` state was added to
|
||||
# prevent, and it was measured on OpenRC 0.63.3 rather
|
||||
# than reasoned about.
|
||||
#
|
||||
# The codes also distinguish "could not find out", which
|
||||
# no amount of reading the text can: an uninitialised user
|
||||
# softlevel makes every call fail, and that is not a state
|
||||
# the service is in.
|
||||
#
|
||||
# Assigned through `|| code=$?` because `set -e` would
|
||||
# otherwise kill this script before it could read a code:
|
||||
# every answer except "running" is a non-zero exit.
|
||||
code=0
|
||||
rc-service --user "$NAME" status >/dev/null 2>&1 || code=$?
|
||||
case "$code" in
|
||||
0) echo running ;;
|
||||
3) echo stopped ;;
|
||||
32) echo failed ;;
|
||||
*)
|
||||
if [ ! -f "${XDG_RUNTIME_DIR:-}/openrc/softlevel" ]; then
|
||||
echo "OpenRC has no user softlevel at" \
|
||||
"${XDG_RUNTIME_DIR:-\$XDG_RUNTIME_DIR}/openrc/softlevel," \
|
||||
"so rc-service --user cannot answer for $NAME." >&2
|
||||
else
|
||||
echo "rc-service could not report on $NAME (exit $code)." >&2
|
||||
fi
|
||||
# Non-zero, and no status word: this server shows
|
||||
# "couldn't check", which is the truth. Printing a
|
||||
# state here would be inventing one.
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 --name N --exec C install|uninstall|start|stop|restart|status|logs" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,541 @@
|
||||
//! Driving a project's long-running server through the script it carries.
|
||||
//!
|
||||
//! This server knows nothing about systemd or OpenRC and deliberately
|
||||
//! never will: which init system is present, and how a unit gets written
|
||||
//! into it, is knowledge that belongs where the service does. A project
|
||||
//! declares one script and this runs `<script> <subcommand>` -- `install`,
|
||||
//! `uninstall`, `start`, `stop`, `restart`, `status`, `logs`.
|
||||
//!
|
||||
//! `status` is the one with a contract, because five answers have to be
|
||||
//! told apart and three of them are not failures: see [`ServiceState`].
|
||||
//!
|
||||
//! Nothing here ever runs on the manifest path. Asking a service manager
|
||||
//! costs a process spawn, and the manifest is fetched on every open,
|
||||
//! resume and Refresh -- so the answer is fetched in the background and
|
||||
//! read from [`ServiceChecks`], exactly as `git::RemoteChecks` does for
|
||||
//! remotes.
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::{Command, Component, Service};
|
||||
|
||||
/// What a service script says about its service.
|
||||
///
|
||||
/// Four states rather than a boolean, because each is a different thing
|
||||
/// to offer and a different thing to say. "Not installed" gets an Install
|
||||
/// button and no controls; "stopped" gets Start; "failed" gets Start too,
|
||||
/// but must not be *described* as stopped.
|
||||
///
|
||||
/// `Failed` exists because its absence made the card lie. A service that
|
||||
/// fell over is not stopped -- stopped is a state somebody chose, and
|
||||
/// reading "stopped" about a crash sends you looking for who stopped it.
|
||||
/// The only alternative a script had was to exit non-zero, which reads as
|
||||
/// "couldn't check" and is equally wrong: it found out perfectly well, it
|
||||
/// just had no word for the answer.
|
||||
///
|
||||
/// A fifth answer, "the script could not tell us", is still the `Err`
|
||||
/// case rather than a variant: that one is not a state the service is in,
|
||||
/// it is this server failing to find out.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ServiceState {
|
||||
Running,
|
||||
Stopped,
|
||||
/// Installed, not running, and not because anybody asked -- it exited
|
||||
/// non-zero or was killed.
|
||||
Failed,
|
||||
NotInstalled,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
/// The word a script prints on stdout. Deliberately words rather than
|
||||
/// exit codes: a script that returns 3 has to be read against a table
|
||||
/// nobody remembers, and the exit status is wanted for the separate
|
||||
/// question of whether the script worked at all.
|
||||
fn parse(output: &str) -> Option<Self> {
|
||||
match output.trim() {
|
||||
"running" => Some(Self::Running),
|
||||
"stopped" => Some(Self::Stopped),
|
||||
"failed" => Some(Self::Failed),
|
||||
"not-installed" => Some(Self::NotInstalled),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The command that drives this component's service, whichever way it
|
||||
/// declared one.
|
||||
///
|
||||
/// The single place the two variants of [`Service`] become the same thing.
|
||||
/// Everything downstream takes a command and runs `<command> <subcommand>`,
|
||||
/// so nothing but this function knows that a built-in script exists -- and
|
||||
/// a component that switches from its own script to the built-in one
|
||||
/// changes nothing anywhere else.
|
||||
///
|
||||
/// `None` covers both a component that is not a server and one that
|
||||
/// declares no service, which are the same thing to every caller: there
|
||||
/// is nothing to drive.
|
||||
pub fn driver(key: &str, component: &Component) -> Option<Command> {
|
||||
match component.service()? {
|
||||
Service::Script(script) if !script.is_empty() => Some(script.clone()),
|
||||
Service::Managed(run) if !run.is_empty() => Some(Command::from_words(vec![
|
||||
crate::shipped::service_default()
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
"--name".to_string(),
|
||||
unit_name(key, component.name()),
|
||||
"--exec".to_string(),
|
||||
run.to_line(),
|
||||
])),
|
||||
// Declared empty, which says the same as not declaring it.
|
||||
Service::Script(_) | Service::Managed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a managed service is called to its service manager.
|
||||
///
|
||||
/// The project key and the component, because a service manager's names
|
||||
/// are one flat namespace across every project on the machine and
|
||||
/// "backend" is not a name two projects can share. A project that wants to
|
||||
/// choose its own name carries its own script, which is one of the things
|
||||
/// that is for.
|
||||
fn unit_name(key: &str, component: &str) -> String {
|
||||
format!("{key}-{component}")
|
||||
}
|
||||
|
||||
/// How long a service command is given before it is given up on.
|
||||
///
|
||||
/// Same reasoning as the git remote timeout: a script that hangs -- on a
|
||||
/// password prompt it should never have shown, most likely -- must not
|
||||
/// hold a slot forever. Longer than a status check needs, because
|
||||
/// `install` may be writing units and enabling them.
|
||||
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Runs one subcommand of `script`, returning its stdout.
|
||||
///
|
||||
/// Stdin is closed rather than inherited. A script that prompts for a
|
||||
/// password gets end-of-file and fails, instead of hanging until the
|
||||
/// timeout with a card stuck on "installing" -- the same bargain git's
|
||||
/// `BatchMode` makes.
|
||||
pub fn run(
|
||||
script: &Command,
|
||||
project: &Path,
|
||||
cwd: Option<&Path>,
|
||||
subcommand: &str,
|
||||
) -> Result<String, String> {
|
||||
let mut child = script
|
||||
.to_process(project, cwd, &[subcommand])?
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to run the service script: {err}"))?;
|
||||
|
||||
let deadline = std::time::Instant::now() + TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("reading the service script's output: {err}"))?;
|
||||
return if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
// The script's own words, which is what the card shows
|
||||
// -- it knows why it could not do the thing and this
|
||||
// does not.
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{subcommand} failed ({})", output.status)
|
||||
} else {
|
||||
first_line(&stderr)
|
||||
})
|
||||
};
|
||||
}
|
||||
Ok(None) if std::time::Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!(
|
||||
"{subcommand} took longer than {}s and was stopped -- a service script must \
|
||||
never wait for input",
|
||||
TIMEOUT.as_secs()
|
||||
));
|
||||
}
|
||||
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(50)),
|
||||
Err(err) => return Err(format!("waiting on the service script: {err}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one subcommand and does **not** wait for it, in its own process
|
||||
/// group.
|
||||
///
|
||||
/// For the one case where the caller is the target: this server asking its
|
||||
/// own manager to restart it. Both halves matter.
|
||||
///
|
||||
/// *Not waiting*, because the thing being waited for is a restart of this
|
||||
/// process -- there is no result to collect, and blocking a thread on it
|
||||
/// only creates something for the stop to interrupt.
|
||||
///
|
||||
/// *Its own process group*, because on OpenRC `restart` is a shell script
|
||||
/// doing stop-then-start, run as a child of the very process it is
|
||||
/// stopping. Waiting on it deadlocks: this server's shutdown waits for its
|
||||
/// children, and the restart's stop phase waits for this server to exit.
|
||||
/// Neither moves, `start-stop-daemon` gives up with "1 process refused to
|
||||
/// stop", and the restart aborts *before* the start ever runs -- which is
|
||||
/// what left one service parked in `stopping` with the phone unable to
|
||||
/// reach anything.
|
||||
///
|
||||
/// Reproduced on OpenRC 0.63.3, and the reproduction is worth knowing:
|
||||
/// **it only bites a server that shuts down gracefully.** A test process
|
||||
/// that dies instantly on SIGTERM passes -- the restart child is orphaned
|
||||
/// and finishes the job -- so the toy version of this test says everything
|
||||
/// is fine. Anything that drains its work first, which is every real
|
||||
/// server, deadlocks.
|
||||
///
|
||||
/// systemd does not have the problem at all, because there a restart is a
|
||||
/// job the daemon owns and the client is free to die. That difference is
|
||||
/// precisely why this was not caught here.
|
||||
pub fn spawn_detached(
|
||||
script: &Command,
|
||||
project: &Path,
|
||||
cwd: Option<&Path>,
|
||||
subcommand: &str,
|
||||
) -> Result<(), String> {
|
||||
script
|
||||
.to_process(project, cwd, &[subcommand])?
|
||||
.stdin(std::process::Stdio::null())
|
||||
// Inherited, so whatever the script says about a failed restart
|
||||
// lands in this server's log -- the one place it can still be read
|
||||
// afterwards.
|
||||
.process_group(0)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|err| format!("failed to run the service script: {err}"))
|
||||
}
|
||||
|
||||
/// Asks `script` what state its service is in.
|
||||
pub fn status(
|
||||
script: &Command,
|
||||
project: &Path,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<ServiceState, String> {
|
||||
let output = run(script, project, cwd, "status")?;
|
||||
ServiceState::parse(&output).ok_or_else(|| {
|
||||
format!("status printed {output:?}, not running, stopped, failed or not-installed")
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a component's own log files are, newest first.
|
||||
///
|
||||
/// The script both arranges the logging and reports it: neither service
|
||||
/// manager writes a file by default -- systemd goes to the journal,
|
||||
/// OpenRC's backgrounded output goes nowhere -- so a unit has to be
|
||||
/// written to redirect, and only the script knows how. That is the whole
|
||||
/// point of asking it: this server needs no service-manager-specific code
|
||||
/// at all, it just gets paths.
|
||||
///
|
||||
/// **Not supporting logs is a first-class answer.** A script that prints
|
||||
/// nothing, or exits non-zero, means "no logs from me" and the card
|
||||
/// simply offers no button. An older script that has never heard of
|
||||
/// `logs` falls through to its usage case and exits non-zero, which lands
|
||||
/// in the same place -- so this is additive and no script has to change
|
||||
/// before the server does.
|
||||
///
|
||||
/// The paths are reported rather than the contents. What to do with a log
|
||||
/// -- how much of it, which generation -- is this server's business and a
|
||||
/// phone's, not the script's.
|
||||
pub fn logs(script: &Command, project: &Path, cwd: Option<&Path>) -> Vec<PathBuf> {
|
||||
let Ok(output) = run(script, project, cwd, "logs") else {
|
||||
return Vec::new();
|
||||
};
|
||||
output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| {
|
||||
// Relative paths resolve against the project, the same rule
|
||||
// the script's own command follows.
|
||||
let path = Path::new(line);
|
||||
if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
project.join(path)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A script's failures run to a paragraph; a card gets a line. The rest is
|
||||
/// in this server's log.
|
||||
fn first_line(text: &str) -> String {
|
||||
text.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.unwrap_or(text)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What each server component was last found to be doing, refreshed off
|
||||
/// the request path.
|
||||
///
|
||||
/// The typed face of [`crate::checks`], which is where the mechanism
|
||||
/// lives: the list must not wait on a process spawn, so the answer lands
|
||||
/// on the next look and the card says it is still being worked out until
|
||||
/// it does. Keyed by project key and component name, because the actions
|
||||
/// are per component.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct ServiceChecks(Arc<crate::checks::Checks<(String, String), Answer>>);
|
||||
|
||||
/// What is known about one component, as one value.
|
||||
///
|
||||
/// Both parts come from the same trip, because both cost the same process
|
||||
/// spawn and the manifest must not pay it per request. They are kept
|
||||
/// together rather than as two checks so that a status that failed cannot
|
||||
/// leave the logs looking like they belong to a different moment.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Answer {
|
||||
state: Option<ServiceState>,
|
||||
/// The log files this component's script reported, newest first. The
|
||||
/// card only needs to know *whether* there are logs; reading one is an
|
||||
/// explicit tap and can pay for its own ask.
|
||||
logs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// One component to ask about: which project it belongs to, where that
|
||||
/// project is, and the script.
|
||||
pub struct Target {
|
||||
pub key: String,
|
||||
pub component: String,
|
||||
pub project: PathBuf,
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub script: Command,
|
||||
}
|
||||
|
||||
impl ServiceChecks {
|
||||
pub fn state(&self, key: &str, component: &str) -> Option<ServiceState> {
|
||||
self.0.answer(&id(key, component)).and_then(|it| it.state)
|
||||
}
|
||||
|
||||
pub fn is_checking(&self, key: &str, component: &str) -> bool {
|
||||
self.0.is_checking(&id(key, component))
|
||||
}
|
||||
|
||||
/// The log files last reported for this component. Empty before
|
||||
/// anything has been asked, and for a script that offers none.
|
||||
pub fn logs(&self, key: &str, component: &str) -> Vec<PathBuf> {
|
||||
self.0
|
||||
.answer(&id(key, component))
|
||||
.map(|it| it.logs)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn error(&self, key: &str, component: &str) -> Option<String> {
|
||||
self.0.error(&id(key, component))
|
||||
}
|
||||
|
||||
/// Asks every target that isn't already being asked. Returns at once.
|
||||
pub fn refresh(&self, targets: Vec<Target>) {
|
||||
for target in targets {
|
||||
let id = id(&target.key, &target.component);
|
||||
self.0.start(id, move |previous| {
|
||||
let state = status(&target.script, &target.project, target.cwd.as_deref());
|
||||
// Same trip, because it is the same spawn cost.
|
||||
let logs = logs(&target.script, &target.project, target.cwd.as_deref());
|
||||
if let Err(err) = &state {
|
||||
tracing::warn!("asking {}'s {} failed: {err}", target.key, target.component);
|
||||
}
|
||||
// A failed status keeps the previous one and records why,
|
||||
// so the card can qualify what it shows rather than
|
||||
// passing a stale state off as current -- while the logs,
|
||||
// which were found, are kept either way.
|
||||
crate::checks::Report {
|
||||
answer: Some(Answer {
|
||||
state: state
|
||||
.as_ref()
|
||||
.ok()
|
||||
.copied()
|
||||
.or_else(|| previous.and_then(|previous: Answer| previous.state)),
|
||||
logs,
|
||||
}),
|
||||
error: state.err(),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Records a state this server just caused, so the card reflects an
|
||||
/// action without waiting for the next background check. Leaves the
|
||||
/// logs alone: they are still the ones that were found.
|
||||
pub fn mark(&self, key: &str, component: &str, state: ServiceState) {
|
||||
self.0
|
||||
.update(id(key, component), |answer| answer.state = Some(state));
|
||||
}
|
||||
|
||||
/// Forgets a project's components, for one being removed.
|
||||
pub fn forget(&self, key: &str) {
|
||||
self.0.retain(|(project, _)| project != key);
|
||||
}
|
||||
}
|
||||
|
||||
fn id(key: &str, component: &str) -> (String, String) {
|
||||
(key.to_string(), component.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn server(service: Option<Service>) -> Component {
|
||||
Component::Server {
|
||||
name: "backend".to_string(),
|
||||
build: Command::default(),
|
||||
cwd: None,
|
||||
stale_when: None,
|
||||
service,
|
||||
built_from: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The one place the two variants become the same thing, so this is
|
||||
/// where it is worth pinning down what each turns into.
|
||||
#[test]
|
||||
fn both_variants_resolve_to_one_command_to_run() {
|
||||
// A project's own script is passed through untouched -- it is
|
||||
// already the thing the contract describes.
|
||||
let own = Command::from_line("server/service");
|
||||
assert_eq!(
|
||||
driver("app", &server(Some(Service::Script(own.clone())))),
|
||||
Some(own)
|
||||
);
|
||||
|
||||
// Managed becomes the built-in script with the component's
|
||||
// identity and command as arguments, so the subcommand the caller
|
||||
// appends still lands last.
|
||||
let managed = driver(
|
||||
"app",
|
||||
&server(Some(Service::Managed(Command::from_line(
|
||||
"target/release/ai-server --port 8080",
|
||||
)))),
|
||||
)
|
||||
.expect("a managed component has a driver");
|
||||
let (program, arguments) = managed.split_first().expect("a program");
|
||||
assert_eq!(
|
||||
program,
|
||||
&crate::shipped::service_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
);
|
||||
assert_eq!(
|
||||
arguments,
|
||||
[
|
||||
"--name",
|
||||
// The key as well as the component: a service manager's
|
||||
// names are one namespace across every project here.
|
||||
"app-backend",
|
||||
"--exec",
|
||||
"target/release/ai-server --port 8080",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A server with no service, and one whose declaration is empty, are
|
||||
/// the same answer to every caller: there is nothing to drive.
|
||||
#[test]
|
||||
fn nothing_to_drive_is_none_however_it_was_said() {
|
||||
assert_eq!(driver("app", &server(None)), None);
|
||||
assert_eq!(
|
||||
driver("app", &server(Some(Service::Script(Command::default())))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
driver("app", &server(Some(Service::Managed(Command::default())))),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// The property the OpenRC failure turned on: the script this server
|
||||
/// asks to restart it must not be something this server then waits on,
|
||||
/// or the two wait for each other and the start never happens. Its own
|
||||
/// process group is what makes that structurally true rather than
|
||||
/// remembered.
|
||||
///
|
||||
/// Tested by having the child report its own group, because that is
|
||||
/// the thing that decides whether a group-directed signal reaches it.
|
||||
/// Nothing here can test OpenRC itself -- this machine has systemd --
|
||||
/// so this pins the mechanism rather than the outcome.
|
||||
#[test]
|
||||
fn a_detached_child_is_outside_this_process_group() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let script = dir.path().join("report");
|
||||
std::fs::write(
|
||||
&script,
|
||||
"#!/bin/sh\nps -o pgid= -p $$ | tr -d ' ' > \"$(dirname \"$0\")/pgid\"\n",
|
||||
)
|
||||
.expect("write");
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).expect("chmod");
|
||||
|
||||
spawn_detached(
|
||||
&Command::from_line(&script.to_string_lossy()),
|
||||
dir.path(),
|
||||
None,
|
||||
"restart",
|
||||
)
|
||||
.expect("spawn");
|
||||
|
||||
let reported = dir.path().join("pgid");
|
||||
let mut waited = 0;
|
||||
while !reported.is_file() && waited < 100 {
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
waited += 1;
|
||||
}
|
||||
let child_group: i32 = std::fs::read_to_string(&reported)
|
||||
.expect("the detached child should have reported its group")
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("a process group id");
|
||||
|
||||
// Read the same way the child reported its own, so the two
|
||||
// numbers are comparable and nothing new is depended on for it.
|
||||
let ours: i32 = String::from_utf8_lossy(
|
||||
&std::process::Command::new("ps")
|
||||
.args(["-o", "pgid=", "-p", &std::process::id().to_string()])
|
||||
.output()
|
||||
.expect("ps")
|
||||
.stdout,
|
||||
)
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("our own process group id");
|
||||
assert_ne!(
|
||||
child_group, ours,
|
||||
"a child in our own group is one the stop would kill with us"
|
||||
);
|
||||
}
|
||||
|
||||
/// The three words and nothing else. A script printing something else
|
||||
/// has a bug, and saying so beats picking whichever state is nearest.
|
||||
#[test]
|
||||
fn only_the_three_words_are_states() {
|
||||
assert_eq!(ServiceState::parse("running"), Some(ServiceState::Running));
|
||||
assert_eq!(
|
||||
ServiceState::parse(" stopped\n"),
|
||||
Some(ServiceState::Stopped)
|
||||
);
|
||||
assert_eq!(ServiceState::parse("failed"), Some(ServiceState::Failed));
|
||||
assert_eq!(
|
||||
ServiceState::parse("not-installed"),
|
||||
Some(ServiceState::NotInstalled)
|
||||
);
|
||||
|
||||
assert_eq!(ServiceState::parse("active"), None);
|
||||
assert_eq!(ServiceState::parse("Running"), None);
|
||||
assert_eq!(ServiceState::parse(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Shell scripts compiled into this binary and written out at startup.
|
||||
//!
|
||||
//! Two of them so far: the service script a component gets when it
|
||||
//! declares `Managed`, and the wrapper that reports a Gradle build's
|
||||
//! progress. They are here for the same reason — the knowledge in them is
|
||||
//! shared by every project this server builds, so it belongs in the server
|
||||
//! rather than copied into each project's own scripts, where it drifts.
|
||||
//!
|
||||
//! Shipped as *scripts* rather than implemented in Rust because both do
|
||||
//! things a shell is the right tool for and because both must be runnable
|
||||
//! by hand: debugging a service script by running it is how the OpenRC
|
||||
//! branch of it was ever tested at all.
|
||||
//!
|
||||
//! Written out at every start rather than when missing. The file is a copy
|
||||
//! of what is compiled in, so the only question worth asking is whether it
|
||||
//! matches *this* binary, and rewriting answers it without having to ask —
|
||||
//! which is the whole point of shipping them, since a stale copy is the
|
||||
//! drift they exist to prevent, arriving by a slower route.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Drives a service for a component declaring `Managed`; see
|
||||
/// `crate::service`.
|
||||
const SERVICE_DEFAULT: &str = include_str!("service-default.sh");
|
||||
|
||||
/// Wraps a build command that cannot report its own progress; see the
|
||||
/// script's own header for which can and which cannot.
|
||||
const BUILD_PROGRESS: &str = include_str!("build-progress.sh");
|
||||
|
||||
/// Where a shipped script is written.
|
||||
///
|
||||
/// Generated output rather than configuration, so under `XDG_DATA_HOME`
|
||||
/// beside the logs, on the same reasoning `crate::logs` uses.
|
||||
pub fn path(name: &str) -> PathBuf {
|
||||
crate::logs::data_dir().join(name)
|
||||
}
|
||||
|
||||
/// The service script's path, named once so callers do not repeat the
|
||||
/// string.
|
||||
pub fn service_default() -> PathBuf {
|
||||
path("service-default")
|
||||
}
|
||||
|
||||
/// The build-progress wrapper's path, which is handed to build commands as
|
||||
/// `DEV_UPDATER_PROGRESS`.
|
||||
pub fn build_progress() -> PathBuf {
|
||||
path("build-progress")
|
||||
}
|
||||
|
||||
/// Writes every shipped script out, executable. Call once at startup.
|
||||
pub fn install() -> std::io::Result<()> {
|
||||
write(&service_default(), SERVICE_DEFAULT)?;
|
||||
write(&build_progress(), BUILD_PROGRESS)
|
||||
}
|
||||
|
||||
fn write(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
// Truncating rather than appending, and the mode is only applied at
|
||||
// creation -- so it is set explicitly afterwards for the case where
|
||||
// the file already existed.
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o700)
|
||||
.open(path)?;
|
||||
file.write_all(contents.as_bytes())?;
|
||||
drop(file);
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Produces a sibling `<name>.slim.apk` for an APK with native `.so`
|
||||
//! debug symbols stripped out of every `lib/**/*.so` (everything else
|
||||
//! copied byte-for-byte, via `ZipWriter::raw_copy_file` so untouched
|
||||
//! entries are never decompressed/recompressed), re-signed with the same
|
||||
//! debug key so it still installs cleanly.
|
||||
//!
|
||||
//! Cached against the source APK's (mtime, size) in a `.slim-stamp`
|
||||
//! sidecar, so it's only regenerated when the underlying build actually
|
||||
//! changes.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::sdk::{self, run_checked};
|
||||
|
||||
const DEBUG_KEYSTORE_PASS: &str = "android";
|
||||
const DEBUG_KEY_ALIAS: &str = "androiddebugkey";
|
||||
|
||||
/// The path an app should actually be served from: `raw_path` itself when
|
||||
/// `strip` is off, or its cached `.slim.apk` otherwise (running the strip
|
||||
/// pipeline first if the cache is stale).
|
||||
///
|
||||
/// For the download itself, which is the one request that has to have the
|
||||
/// exact bytes. Anything that only wants to *describe* the download wants
|
||||
/// [`serveable_now`] instead.
|
||||
pub async fn resolve_serveable_path(raw_path: &Path, strip: bool) -> Result<PathBuf> {
|
||||
if !strip {
|
||||
return Ok(raw_path.to_path_buf());
|
||||
}
|
||||
let raw_path = raw_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || strip_debug_symbols(&raw_path))
|
||||
.await
|
||||
.context("strip task panicked")?
|
||||
}
|
||||
|
||||
/// The path an app would be served from *as things stand*, doing no work:
|
||||
/// the slim copy if one has already been produced, otherwise the raw APK.
|
||||
///
|
||||
/// The manifest uses this rather than [`resolve_serveable_path`] because
|
||||
/// stripping is seconds of zip walking, `llvm-strip` and re-signing, and
|
||||
/// the manifest is fetched on every open, resume and Refresh. Generating a
|
||||
/// build artifact to answer a question about sizes is the wrong trade: a
|
||||
/// card is worth an approximate number, never a stall.
|
||||
///
|
||||
/// So the size shown just after a rebuild is the previous slim copy's,
|
||||
/// until a download regenerates it. That is a far closer answer than the
|
||||
/// unstripped size, which is the only other thing available without doing
|
||||
/// the work.
|
||||
pub fn serveable_now(raw_path: &Path, strip: bool) -> PathBuf {
|
||||
if !strip {
|
||||
return raw_path.to_path_buf();
|
||||
}
|
||||
let slim = with_suffix(raw_path, ".slim.apk");
|
||||
if slim.is_file() {
|
||||
slim
|
||||
} else {
|
||||
raw_path.to_path_buf()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_debug_symbols(src_path: &Path) -> Result<PathBuf> {
|
||||
let slim_path = with_suffix(src_path, ".slim.apk");
|
||||
let stamp_path = with_suffix(src_path, ".slim-stamp");
|
||||
|
||||
let metadata =
|
||||
std::fs::metadata(src_path).with_context(|| format!("stat {}", src_path.display()))?;
|
||||
let stamp = format!(
|
||||
"{}:{}",
|
||||
metadata.modified()?.duration_since(UNIX_EPOCH)?.as_nanos(),
|
||||
metadata.len(),
|
||||
);
|
||||
if slim_path.is_file()
|
||||
&& std::fs::read_to_string(&stamp_path).ok().as_deref() == Some(stamp.as_str())
|
||||
{
|
||||
return Ok(slim_path);
|
||||
}
|
||||
|
||||
let llvm_strip = sdk::llvm_strip_path()?;
|
||||
let build_tools = sdk::build_tools_dir()?;
|
||||
|
||||
tracing::info!(
|
||||
"stripping native debug symbols from {} ({} bytes)...",
|
||||
src_path.display(),
|
||||
metadata.len(),
|
||||
);
|
||||
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let unaligned_path = tmp.path().join("unaligned.apk");
|
||||
strip_native_libs(src_path, &unaligned_path, &llvm_strip, tmp.path())?;
|
||||
|
||||
let aligned_path = tmp.path().join("aligned.apk");
|
||||
run_checked(
|
||||
Command::new(build_tools.join("zipalign"))
|
||||
.arg("-f")
|
||||
.arg("-p")
|
||||
.arg("4")
|
||||
.arg(&unaligned_path)
|
||||
.arg(&aligned_path),
|
||||
)?;
|
||||
|
||||
run_checked(
|
||||
Command::new(build_tools.join("apksigner"))
|
||||
.arg("sign")
|
||||
.arg("--ks")
|
||||
.arg(sdk::debug_keystore_path()?)
|
||||
.arg("--ks-pass")
|
||||
.arg(format!("pass:{DEBUG_KEYSTORE_PASS}"))
|
||||
.arg("--key-pass")
|
||||
.arg(format!("pass:{DEBUG_KEYSTORE_PASS}"))
|
||||
.arg("--ks-key-alias")
|
||||
.arg(DEBUG_KEY_ALIAS)
|
||||
.arg("--out")
|
||||
.arg(&slim_path)
|
||||
.arg(&aligned_path),
|
||||
)?;
|
||||
|
||||
std::fs::write(&stamp_path, &stamp)?;
|
||||
tracing::info!(
|
||||
"stripped -> {} ({} bytes)",
|
||||
slim_path.display(),
|
||||
std::fs::metadata(&slim_path)?.len(),
|
||||
);
|
||||
Ok(slim_path)
|
||||
}
|
||||
|
||||
/// Rewrites `src_path` into `dst_path`, running every `lib/**/*.so` entry
|
||||
/// through `llvm-strip --strip-debug` and copying everything else as-is.
|
||||
fn strip_native_libs(
|
||||
src_path: &Path,
|
||||
dst_path: &Path,
|
||||
llvm_strip: &Path,
|
||||
tmp_dir: &Path,
|
||||
) -> Result<()> {
|
||||
let src_file =
|
||||
std::fs::File::open(src_path).with_context(|| format!("open {}", src_path.display()))?;
|
||||
let mut archive = zip::ZipArchive::new(src_file).context("read apk as zip")?;
|
||||
|
||||
let dst_file = std::fs::File::create(dst_path)
|
||||
.with_context(|| format!("create {}", dst_path.display()))?;
|
||||
let mut writer = zip::ZipWriter::new(dst_file);
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.with_context(|| format!("read entry {i}"))?;
|
||||
let name = file.name().to_string();
|
||||
|
||||
if !(name.starts_with("lib/") && name.ends_with(".so")) {
|
||||
writer
|
||||
.raw_copy_file(file)
|
||||
.with_context(|| format!("copy {name}"))?;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut options =
|
||||
zip::write::SimpleFileOptions::default().compression_method(file.compression());
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
options = options.unix_permissions(mode);
|
||||
}
|
||||
if let Some(modified) = file.last_modified() {
|
||||
options = options.last_modified_time(modified);
|
||||
}
|
||||
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data)
|
||||
.with_context(|| format!("read {name}"))?;
|
||||
|
||||
let so_path = tmp_dir.join(name.replace('/', "_"));
|
||||
std::fs::write(&so_path, &data)?;
|
||||
run_checked(Command::new(llvm_strip).arg("--strip-debug").arg(&so_path))
|
||||
.with_context(|| format!("strip {name}"))?;
|
||||
let stripped = std::fs::read(&so_path)?;
|
||||
|
||||
writer
|
||||
.start_file(&name, options)
|
||||
.with_context(|| format!("start {name}"))?;
|
||||
writer
|
||||
.write_all(&stripped)
|
||||
.with_context(|| format!("write {name}"))?;
|
||||
}
|
||||
|
||||
writer.finish().context("finish zip")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
||||
let mut s = path.as_os_str().to_owned();
|
||||
s.push(suffix);
|
||||
PathBuf::from(s)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/bin/sh
|
||||
# Builds both halves and gets the server service running again.
|
||||
#
|
||||
# ./start.sh
|
||||
#
|
||||
# The "put it back the way it should be" script: run it after a pull, or
|
||||
# whenever the running server and the checkout have drifted apart. Every
|
||||
# step is safe to repeat, so running it when nothing is wrong is a no-op
|
||||
# that takes as long as a cached build.
|
||||
#
|
||||
# The service is reinstalled rather than only installed when missing. The
|
||||
# unit file is generated from the service script, so a pull that changes
|
||||
# how the service is defined leaves an installed unit that is out of date
|
||||
# -- and that unit is what decides the working directory the server starts
|
||||
# in, which is where it looks for its own checkout. Rewriting it every time
|
||||
# is the only way running this actually guarantees the state it claims to.
|
||||
#
|
||||
# The service steps go through the freshly built binary's `--service`,
|
||||
# which derives the name and the script from the same declaration the
|
||||
# running server reads. So there is one definition of this service rather
|
||||
# than a bootstrap copy of it here.
|
||||
set -eu
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# The binary decides how its own service is named and invoked, so nothing
|
||||
# here has to. It is built by the step above, which is what makes asking it
|
||||
# possible at all -- and it means this script cannot drift out of step with
|
||||
# `service::driver` the way a second copy of the name and arguments would.
|
||||
SERVICE="./server/target/release/dev-updater --service"
|
||||
|
||||
# This service used to be installed under its own name by a script of its
|
||||
# own. Left in place, that unit would still start a second copy at boot,
|
||||
# which then loses the race for the port -- so it goes before the new one
|
||||
# arrives. Harmless once it is gone; this is a no-op on every later run.
|
||||
LEGACY="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/dev-updater.service"
|
||||
LEGACY_OPENRC="${XDG_CONFIG_HOME:-$HOME/.config}/rc/init.d/dev-updater"
|
||||
if [ -f "$LEGACY" ] || [ -f "$LEGACY_OPENRC" ]; then
|
||||
echo "==> Removing the old dev-updater unit, which this service replaces"
|
||||
if [ -f "$LEGACY" ]; then
|
||||
systemctl --user disable --now dev-updater >/dev/null 2>&1 || true
|
||||
rm -f "$LEGACY"
|
||||
systemctl --user daemon-reload
|
||||
fi
|
||||
if [ -f "$LEGACY_OPENRC" ]; then
|
||||
rc-service --user dev-updater stop >/dev/null 2>&1 || true
|
||||
rc-update --user del dev-updater >/dev/null 2>&1 || true
|
||||
rm -f "$LEGACY_OPENRC"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Server first, then the APK: if the APK build fails, the phone keeps being
|
||||
# offered the one it already had rather than half of a matched pair.
|
||||
./build-self.sh
|
||||
|
||||
echo
|
||||
echo "==> Installing the service"
|
||||
$SERVICE install
|
||||
|
||||
# Told apart because restart on a service somebody stopped on purpose is a
|
||||
# different thing from starting one that is merely down.
|
||||
case "$($SERVICE status)" in
|
||||
running)
|
||||
echo "==> Restarting into the build just made"
|
||||
$SERVICE restart
|
||||
;;
|
||||
*)
|
||||
echo "==> Starting"
|
||||
$SERVICE start
|
||||
;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "==> the service is $($SERVICE status)"
|
||||
@@ -0,0 +1,85 @@
|
||||
# Test projects
|
||||
|
||||
Small fake projects for exercising Dev Updater against something other than
|
||||
Dev Updater. They are apps in the sense the server cares about -- a path
|
||||
with a declaration and a build that produces an APK -- and nothing else.
|
||||
|
||||
The point of having them is control. Dev Updater's job is installing *other*
|
||||
projects' builds, so trying it out needs something to install, and a real
|
||||
project only ever does what it happens to do: it builds, or it doesn't, and
|
||||
you cannot ask it to fail on the third component, produce two variants, or
|
||||
run a service that writes a coloured log. These can be asked for any of
|
||||
that.
|
||||
|
||||
## Why they are not picked up automatically
|
||||
|
||||
`scan_roots` in `server/src/discover.rs` descends two levels below each
|
||||
configured repo root, **and stops at any directory carrying a
|
||||
`.dev-updater.ron`** -- a declaration says "this directory is the project",
|
||||
so what is underneath is that project's own layout rather than more projects
|
||||
to offer. This checkout carries one at its root, so with `/home/bob/repos`
|
||||
as a root the scan considers `dev-updater` itself and descends no further.
|
||||
Nothing in here is ever suggested.
|
||||
|
||||
To work with them, add `.../dev-updater/test-projects` as a repo root of its
|
||||
own from the app's settings; each directory below is then a suggestion. Or
|
||||
type one project's path into the Add screen, which is the escape hatch for
|
||||
anything the scan does not reach.
|
||||
|
||||
## The `app/` directory each one builds into
|
||||
|
||||
`find_apks` matches APKs up to **two** directories below a project root, and
|
||||
it is run against the *project* path, not a component's `cwd`. A test
|
||||
project building into `test-projects/<name>/build/outputs/apk/` would
|
||||
therefore also be found from the repository root -- offered as a build of
|
||||
Dev Updater itself, and, being the newest, served as the default. Somebody
|
||||
pressing Update on the Dev Updater card would get a stub app.
|
||||
|
||||
One more level down (`test-projects/<name>/app/build/outputs/apk/`) is out of
|
||||
reach from the root while staying within reach from the test project, which
|
||||
is why `lib/build-apk.sh` writes there. It is also what a real Gradle project
|
||||
looks like, so nothing about it reads as a workaround. Check it after
|
||||
changing any of this:
|
||||
|
||||
```sh
|
||||
cd <repo root> && for p in build/outputs/apk/*/*.apk */build/outputs/apk/*/*.apk \
|
||||
*/*/build/outputs/apk/*/*.apk; do [ -f "$p" ] && echo "$p"; done
|
||||
```
|
||||
|
||||
Only `app/androidApp/build/outputs/apk/debug/androidApp-debug.apk` should
|
||||
appear.
|
||||
|
||||
## What is here
|
||||
|
||||
| project | what it is for |
|
||||
|---|---|
|
||||
| `hello-app` | The plain case: one APK, one variant. Declares **no** `resources:`, so Uninstall has to show its data and config toggles disabled and say why. |
|
||||
| `two-variants` | Builds `debug` and `release`, so the phone gets a variant picker and `resolve_apk` has something to fall back from. |
|
||||
| `breakable` | `touch breakable/break-the-build` and its next build fails, in colour, on stderr. `rm` it and it stops. |
|
||||
| `service-and-app` | A `Server` beside an `Apk`: two components building at once, the whole service contract, a runtime log that is not this server's own, and a `resources:` declaration giving Uninstall real paths. |
|
||||
|
||||
All four are unaccepted when first added, so each is also a run through the
|
||||
acceptance gate.
|
||||
|
||||
## The apps themselves
|
||||
|
||||
One screen showing the app's label, its package, and when the installed copy
|
||||
landed -- `lib/StubActivity.java`, shared by all of them, because what
|
||||
differs between these projects is what Dev Updater has to do with them, not
|
||||
what the app is. The install time is `PackageInfo.lastUpdateTime`, which is
|
||||
the exact value the freshness check compares an APK's mtime against, so the
|
||||
screen shows what the card is reasoning about.
|
||||
|
||||
They are built by `lib/build-apk.sh` -- aapt2, javac, d8, apksigner, about
|
||||
two seconds -- rather than by Gradle. A Gradle daemon is around a gigabyte
|
||||
resident and four of these would be four daemons, which is how this machine
|
||||
ran itself out of memory on 2026-08-30; and a fixture you wait forty seconds
|
||||
for stops getting used. The cost is that the script is a small build system,
|
||||
which is why it is one file and should stay one: a test project that needs
|
||||
more than a screen with its own name on it wants a real project.
|
||||
|
||||
The signing key is generated once at
|
||||
`$XDG_DATA_HOME/dev-updater/test-projects/debug.keystore`, outside the
|
||||
repository -- the checkout is a mount shared with the host, and a stable key
|
||||
is what lets a rebuild install over the copy already on a device. Delete it
|
||||
to produce a signature-mismatch install failure on purpose.
|
||||
@@ -0,0 +1,15 @@
|
||||
// A test project for Dev Updater, not a real app. See ../README.md.
|
||||
//
|
||||
// The one that can be made to fail. Everything about a failed build is
|
||||
// awkward to produce with a real project and easy here: the card's failure
|
||||
// line, the build-log tab opening instead of the runtime one, a component
|
||||
// failing without stopping its siblings, and coloured compiler output
|
||||
// surviving the trip to the phone.
|
||||
label: "Test: Breakable",
|
||||
|
||||
components: [
|
||||
Apk(
|
||||
name: "app",
|
||||
build: "./build.sh",
|
||||
),
|
||||
],
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Builds this test app -- unless ./break-the-build exists, in which case it
|
||||
# fails instead.
|
||||
#
|
||||
# touch break-the-build # the next build fails
|
||||
# rm break-the-build # and then stops failing
|
||||
#
|
||||
# A marker file rather than an environment variable, because the point is to
|
||||
# change the outcome of a build somebody else starts: Dev Updater runs this
|
||||
# from a service with its own environment, and the phone is where the button
|
||||
# is. The file is gitignored, so the checked-in state is "works".
|
||||
#
|
||||
# The failure writes colour on stderr on purpose. A real compiler marks its
|
||||
# own errors that way, the app renders the escapes rather than stripping
|
||||
# them (AnsiLog.kt), and this is the cheapest way to have something to look
|
||||
# at that is not Dev Updater's own build.
|
||||
set -eu
|
||||
|
||||
if [ -f break-the-build ]; then
|
||||
echo "@@progress 1/3"
|
||||
echo "==> Compiling"
|
||||
printf '\033[1;31merror\033[0m: cannot borrow `the_kettle` as mutable more than once\n' >&2
|
||||
printf ' \033[1;34m-->\033[0m src/main.rs:12:5\n' >&2
|
||||
printf '\033[1;31merror\033[0m: could not compile `breakable` (1 error)\n' >&2
|
||||
echo "break-the-build is present, so this build failed on purpose." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec ../lib/build-apk.sh \
|
||||
--package com.example.dutest.breakable \
|
||||
--label "Test Breakable"
|
||||
@@ -0,0 +1,17 @@
|
||||
// A test project for Dev Updater, not a real app. See ../README.md.
|
||||
//
|
||||
// The plain case: one APK component, one variant, and nothing said about
|
||||
// resources -- which is itself the thing being tested here, since an
|
||||
// uninstall dialog for this project has to show its data and config toggles
|
||||
// disabled and say *why*, distinguishably from a project whose resources
|
||||
// could not be read.
|
||||
label: "Test: Hello",
|
||||
|
||||
components: [
|
||||
Apk(
|
||||
name: "app",
|
||||
// The list form rather than a line, because the label has a space
|
||||
// in it and the line form splits on whitespace.
|
||||
build: ["../lib/build-apk.sh", "--package", "com.example.dutest.hello", "--label", "Test Hello"],
|
||||
),
|
||||
],
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Template. build-apk.sh substitutes __PACKAGE__ and __LABEL__; the SDK
|
||||
levels are passed to aapt2 on the command line rather than written
|
||||
here, so there is one place they are set for every test app. -->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="__PACKAGE__">
|
||||
<application android:label="__LABEL__">
|
||||
<activity
|
||||
android:name="com.example.dutest.stub.StubActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.example.dutest.stub;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.os.Bundle;
|
||||
import android.view.Gravity;
|
||||
import android.widget.TextView;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The whole of every test app: one screen naming which app this is and when
|
||||
* the installed copy landed.
|
||||
*
|
||||
* One class shared by all of them rather than one per project, because what
|
||||
* differs between the test projects is what Dev Updater has to *do* with
|
||||
* them -- how many variants they build, whether the build fails, whether
|
||||
* they declare a service -- and none of that is a difference in the app.
|
||||
* The manifest names this class fully qualified, so each APK can carry its
|
||||
* own application id while the code keeps one package.
|
||||
*
|
||||
* It shows lastUpdateTime deliberately: that is the exact value Dev
|
||||
* Updater's freshness check compares an APK's mtime against, so the screen
|
||||
* shows what the card is reasoning about rather than a separate version
|
||||
* string that could agree with it by luck.
|
||||
*/
|
||||
public class StubActivity extends Activity {
|
||||
@Override
|
||||
protected void onCreate(Bundle state) {
|
||||
super.onCreate(state);
|
||||
TextView view = new TextView(this);
|
||||
view.setGravity(Gravity.CENTER);
|
||||
view.setTextSize(20f);
|
||||
view.setLineSpacing(0f, 1.3f);
|
||||
view.setPadding(64, 64, 64, 64);
|
||||
// Catppuccin Mocha Base and Text, matching the updater's own theme
|
||||
// so a screenshot of one does not look like a different machine.
|
||||
view.setBackgroundColor(0xFF1E1E2E);
|
||||
view.setTextColor(0xFFCDD6F4);
|
||||
view.setText(describe());
|
||||
setContentView(view);
|
||||
}
|
||||
|
||||
private String describe() {
|
||||
CharSequence label = getApplicationInfo().loadLabel(getPackageManager());
|
||||
return label + "\n\n" + getPackageName() + "\n\ninstalled " + installedAt();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the package manager says this copy was installed, or why that
|
||||
* could not be read -- never a stand-in that reads like an answer.
|
||||
*/
|
||||
private String installedAt() {
|
||||
try {
|
||||
PackageInfo info = getPackageManager().getPackageInfo(getPackageName(), 0);
|
||||
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
.format(new Date(info.lastUpdateTime));
|
||||
} catch (Exception failure) {
|
||||
return "unknown (" + failure.getClass().getSimpleName() + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/bin/sh
|
||||
# Builds one test app's APK. Run from the test project's own directory,
|
||||
# which is where Dev Updater runs a component's build from.
|
||||
#
|
||||
# ../lib/build-apk.sh --package com.example.dutest.hello --label "Hello" \
|
||||
# [--variant debug]
|
||||
#
|
||||
# Deliberately not Gradle, which is the obvious way to build an Android app
|
||||
# and the wrong one here for two reasons. A Gradle daemon is around a
|
||||
# gigabyte resident, and several test projects each holding one is how this
|
||||
# machine ran itself out of memory on 2026-08-30 -- the emulators alone are
|
||||
# already close to the limit. And a Gradle build takes tens of seconds to do
|
||||
# what this does in about two, which matters because the thing under test is
|
||||
# Dev Updater, not the app: a fixture you wait for stops getting used.
|
||||
#
|
||||
# What it costs is that this is a small build system rather than a
|
||||
# declaration. It is kept to one file, and the whole of it is: link a
|
||||
# manifest, compile one class, dex it, add it, align, sign. Nothing here is
|
||||
# a general Android build and it should not grow into one -- a test project
|
||||
# needing more than a screen with its own name on it wants a real project.
|
||||
#
|
||||
# The signing key lives outside the repository
|
||||
# ($XDG_DATA_HOME/dev-updater/test-projects/debug.keystore) because the
|
||||
# repository is a mount shared with the host, and because a stable key is
|
||||
# what lets a rebuilt APK install *over* the copy already on the emulator
|
||||
# rather than being refused for a signature mismatch. Deleting it is how you
|
||||
# produce that refusal on purpose.
|
||||
set -eu
|
||||
|
||||
PACKAGE=
|
||||
LABEL=
|
||||
VARIANT=debug
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--package) PACKAGE=$2; shift 2 ;;
|
||||
--label) LABEL=$2; shift 2 ;;
|
||||
--variant) VARIANT=$2; shift 2 ;;
|
||||
*) echo "usage: $0 --package ID --label TEXT [--variant NAME]" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PACKAGE" ] || { echo "$0: --package is required" >&2; exit 2; }
|
||||
[ -n "$LABEL" ] || { echo "$0: --label is required" >&2; exit 2; }
|
||||
|
||||
LIB=$(cd "$(dirname "$0")" && pwd)
|
||||
PROJECT=$(pwd)
|
||||
NAME=$(basename "$PROJECT")
|
||||
|
||||
# Same cascade as app/build-apk.sh: the host and this VM do not keep the SDK
|
||||
# in the same place, and the ambient ANDROID_HOME points at one with no
|
||||
# build-tools under it.
|
||||
if [ -n "${ANDROID_HOME:-}" ] && [ -d "${ANDROID_HOME}/build-tools" ]; then
|
||||
SDK="$ANDROID_HOME"
|
||||
elif [ -n "${ANDROID_SDK_ROOT:-}" ] && [ -d "${ANDROID_SDK_ROOT}/build-tools" ]; then
|
||||
SDK="$ANDROID_SDK_ROOT"
|
||||
elif [ -d "$HOME/Android/Sdk/build-tools" ]; then
|
||||
SDK="$HOME/Android/Sdk"
|
||||
else
|
||||
echo "No Android SDK with build-tools found. Set ANDROID_HOME to one." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Newest of whatever is installed, rather than a pinned version this script
|
||||
# would have to be edited to follow.
|
||||
TOOLS="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -n1)"
|
||||
PLATFORM="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -n1)"
|
||||
JAR="$PLATFORM/android.jar"
|
||||
[ -f "$JAR" ] || { echo "No android.jar under $PLATFORM -- install a platform." >&2; exit 1; }
|
||||
|
||||
MIN_SDK=24
|
||||
TARGET_SDK=$(basename "$PLATFORM" | sed 's/^android-//; s/\..*//')
|
||||
|
||||
KEYSTORE="${XDG_DATA_HOME:-$HOME/.local/share}/dev-updater/test-projects/debug.keystore"
|
||||
|
||||
# Six steps, counted out for the progress bar. Emitted directly rather than
|
||||
# through $DEV_UPDATER_PROGRESS: that wrapper exists to count Gradle tasks
|
||||
# for a build that cannot report its own, and this one knows exactly what it
|
||||
# is doing. The format is the same either way -- see server/src/build_state.rs.
|
||||
STEPS=6
|
||||
step() {
|
||||
echo "@@progress $1/$STEPS"
|
||||
echo "==> $2"
|
||||
}
|
||||
|
||||
BUILD="$PROJECT/app/build"
|
||||
GEN="$BUILD/gen"
|
||||
OUT="$BUILD/outputs/apk/$VARIANT"
|
||||
# `app/` is not decoration. Dev Updater matches APKs at up to two directories
|
||||
# below a project root, so a test project building straight into
|
||||
# test-projects/<name>/build/ would also be found from the repository root --
|
||||
# i.e. offered as a build of Dev Updater itself, and being newest, served as
|
||||
# the default. One more level puts it out of that reach while keeping it one
|
||||
# level below the test project, where its own patterns find it.
|
||||
rm -rf "$GEN"
|
||||
mkdir -p "$GEN/classes" "$OUT"
|
||||
|
||||
step 1 "Writing the manifest for $PACKAGE"
|
||||
sed -e "s|__PACKAGE__|$PACKAGE|" -e "s|__LABEL__|$LABEL|" \
|
||||
"$LIB/AndroidManifest.xml" >"$GEN/AndroidManifest.xml"
|
||||
|
||||
step 2 "Linking resources (aapt2)"
|
||||
"$TOOLS/aapt2" link \
|
||||
-I "$JAR" \
|
||||
--manifest "$GEN/AndroidManifest.xml" \
|
||||
--min-sdk-version "$MIN_SDK" \
|
||||
--target-sdk-version "$TARGET_SDK" \
|
||||
-o "$GEN/linked.apk"
|
||||
|
||||
step 3 "Compiling StubActivity"
|
||||
javac -nowarn -Xlint:-options --release 17 \
|
||||
-classpath "$JAR" -d "$GEN/classes" "$LIB/StubActivity.java"
|
||||
|
||||
step 4 "Dexing"
|
||||
find "$GEN/classes" -name '*.class' -print0 | xargs -0 \
|
||||
"$TOOLS/d8" --release --lib "$JAR" --min-api "$MIN_SDK" --output "$GEN"
|
||||
|
||||
step 5 "Packaging and aligning"
|
||||
(cd "$GEN" && jar --update --file linked.apk classes.dex)
|
||||
"$TOOLS/zipalign" -p -f 4 "$GEN/linked.apk" "$GEN/aligned.apk"
|
||||
|
||||
step 6 "Signing"
|
||||
if [ ! -f "$KEYSTORE" ]; then
|
||||
echo " (generating a signing key at $KEYSTORE)"
|
||||
mkdir -p "$(dirname "$KEYSTORE")"
|
||||
chmod 700 "$(dirname "$KEYSTORE")"
|
||||
keytool -genkeypair -keystore "$KEYSTORE" \
|
||||
-storepass android -keypass android -alias test \
|
||||
-keyalg RSA -keysize 2048 -validity 10000 \
|
||||
-dname "CN=dev-updater test projects" >/dev/null
|
||||
fi
|
||||
"$TOOLS/apksigner" sign \
|
||||
--ks "$KEYSTORE" --ks-pass pass:android --key-pass pass:android \
|
||||
--ks-key-alias test --min-sdk-version "$MIN_SDK" \
|
||||
--out "$OUT/$NAME-$VARIANT.apk" "$GEN/aligned.apk"
|
||||
|
||||
echo "@@progress $STEPS/$STEPS"
|
||||
echo "==> Built $OUT/$NAME-$VARIANT.apk"
|
||||
@@ -0,0 +1,38 @@
|
||||
// A test project for Dev Updater, not a real app. See ../README.md.
|
||||
//
|
||||
// The two-component case: a server on the build machine beside an APK for
|
||||
// the phone. That pairing is what Dev Updater itself is, so it is the shape
|
||||
// most worth having a second of -- a project where breaking something
|
||||
// cannot take the updater down with it.
|
||||
//
|
||||
// What it exercises that a one-component project cannot: two builds running
|
||||
// at once with a bar and a timing each, the project area at the bottom
|
||||
// holding only what belongs to the whole project, and the whole service
|
||||
// contract -- install, start, stop, status, restart, and a runtime log that
|
||||
// is not this server's own.
|
||||
label: "Test: Service + App",
|
||||
|
||||
// Declared, so the Uninstall dialog has real paths to show and its data and
|
||||
// config toggles are enabled. `hello-app` deliberately declares nothing,
|
||||
// which is the other half of that test.
|
||||
resources: Ron("resources.ron"),
|
||||
|
||||
components: [
|
||||
Server(
|
||||
name: "daemon",
|
||||
build: "./build-daemon.sh",
|
||||
// Managed, so Dev Updater's own service script drives it and this
|
||||
// project needs no systemd or OpenRC knowledge of its own. The
|
||||
// unit is named `<key>-daemon` after the key this project is added
|
||||
// under.
|
||||
//
|
||||
// The leading `./` is load-bearing: service-default.sh resolves a
|
||||
// program containing a slash against the working directory and
|
||||
// looks anything else up on PATH.
|
||||
service: Managed("./serve.sh"),
|
||||
),
|
||||
Apk(
|
||||
name: "app",
|
||||
build: ["../lib/build-apk.sh", "--package", "com.example.dutest.service", "--label", "Test Service"],
|
||||
),
|
||||
],
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# "Builds" this project's daemon, which is a shell script and so needs no
|
||||
# building at all. What it actually does is take a few seconds and report
|
||||
# progress, because that is the thing worth having: a component slow enough
|
||||
# to watch a bar move on, next to an APK component that finishes in two
|
||||
# seconds. A card with one instant component and one slow one is how you see
|
||||
# whether the per-component timings and the concurrent build really are per
|
||||
# component.
|
||||
set -eu
|
||||
|
||||
STEPS=8
|
||||
i=0
|
||||
while [ "$i" -lt "$STEPS" ]; do
|
||||
i=$((i + 1))
|
||||
echo "@@progress $i/$STEPS"
|
||||
echo "==> Pretending to compile part $i of $STEPS"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
chmod +x ./serve.sh
|
||||
echo "==> Daemon ready"
|
||||
@@ -0,0 +1,10 @@
|
||||
// Where this test project keeps its state, in the shape Dev Updater reads
|
||||
// (`ResourceFacts` in server/src/config.rs) -- the body of the struct, no
|
||||
// outer parentheses, which is the house rule for every RON file here.
|
||||
//
|
||||
// Only `name` is given, so the data and config directories follow from it:
|
||||
// $XDG_DATA_HOME/dutest-service and $XDG_CONFIG_HOME/dutest-service. That
|
||||
// is the ordinary case, and it keeps both paths under the XDG directories,
|
||||
// which matters because Uninstall removes a path wherever it points and the
|
||||
// dialog showing it is the only guard.
|
||||
name: "dutest-service",
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# The long-running half of this test project: a service that does nothing
|
||||
# except say so, once every ten seconds, forever.
|
||||
#
|
||||
# It writes into its own data directory -- the one resources.ron names -- so
|
||||
# that Uninstall's "remove data" toggle has something real to remove, and so
|
||||
# that the directory exists to be shown in the dialog. Its *log* is not
|
||||
# written here: a managed service's output is captured by Dev Updater's
|
||||
# service script, which is what the runtime log tab reads.
|
||||
set -eu
|
||||
|
||||
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/dutest-service"
|
||||
mkdir -p "$DATA"
|
||||
|
||||
echo "started at $(date -Is), writing to $DATA"
|
||||
count=0
|
||||
while true; do
|
||||
count=$((count + 1))
|
||||
echo "$(date -Is) tick $count" >>"$DATA/ticks.log"
|
||||
# Colour, so the runtime log tab has escapes to render too -- the same
|
||||
# reason breakable/build.sh writes them.
|
||||
printf 'tick \033[1;32m%s\033[0m -- still here\n' "$count"
|
||||
sleep 10
|
||||
done
|
||||
@@ -0,0 +1,15 @@
|
||||
// A test project for Dev Updater, not a real app. See ../README.md.
|
||||
//
|
||||
// Builds the same app twice under different variant directories, which is
|
||||
// the only thing this one is for: with more than one build present the
|
||||
// phone gets a variant picker, `resolve_apk` has something to fall back
|
||||
// from when a chosen variant is deleted, and the manifest has to report the
|
||||
// size of the one actually being served.
|
||||
label: "Test: Two Variants",
|
||||
|
||||
components: [
|
||||
Apk(
|
||||
name: "app",
|
||||
build: "./build.sh",
|
||||
),
|
||||
],
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# Builds this test app twice, once per variant.
|
||||
#
|
||||
# Two runs of the shared builder rather than a flag on it: what is being
|
||||
# tested is Dev Updater seeing two builds under one project, and the
|
||||
# simplest thing that produces that is building twice. They are byte-for-
|
||||
# byte the same app -- the variant is the directory name, which is where
|
||||
# `discover.rs` reads it from.
|
||||
set -eu
|
||||
for variant in debug release; do
|
||||
../lib/build-apk.sh \
|
||||
--package com.example.dutest.variants \
|
||||
--label "Test Variants" \
|
||||
--variant "$variant"
|
||||
done
|
||||
+1
Submodule vendor/wg-app-link added at f95bc77f7b.
Reference in new issue
Block a user