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:
iris committed 2026-08-31 20:31:08 -04:00
commit b0e83059a3
82 files changed
+20372

No files matched your search

+85
View File
@@ -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.
+15
View File
@@ -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",
),
],
+31
View File
@@ -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"
+17
View File
@@ -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"],
),
],
+17
View File
@@ -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>
+63
View File
@@ -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() + ")";
}
}
}
+136
View File
@@ -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
View File
@@ -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",
+24
View File
@@ -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",
),
],
+15
View File
@@ -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