dev-updater serves APKs to a phone and ai-app runs model sessions for one. Above the waterline they share nothing. Underneath they are the same program: bound to wg0 so they are not on the LAN, presenting a certificate from a CA the app pins, answering only requests carrying a token enrolled by scanning a QR off the terminal, keeping state in owner-only files outside the repo. Three modules, each extracted only after diffing the two copies and finding nothing between them but a product name and a type parameter. netif fails closed when the tunnel is down. enroll generates, stores and compares the token, and prints the QR, with the URI scheme as the one per-project part. private owns the file modes, taken from ai-app's version because it had already factored out what dev-updater still has inline in two places. Nothing is removed from either project. This is a proposal with a working core, and the README carries the measured evidence -- the enrollment scanner activity differs by its package line and nothing else, the RON format module is byte-identical, and the two copies have each drifted into holding an improvement the other lacks, which is the cost being paid today.
6.0 KiB
wg-server-app
The private link between a phone and a machine you run, extracted from the two projects that had each written it.
dev-updater serves locally-built APKs to a phone. ai-app runs model
sessions for one. They have nothing in common above the waterline — and
underneath they are the same program: a server bound to a WireGuard
interface so it is not on the LAN, presenting a certificate from a CA the
app pins, answering only requests carrying a bearer token that was enrolled
by scanning a QR code off the terminal, and keeping its state in
owner-only files outside the repo.
That link was written twice. This is it written once.
Status: proposal with a working core. Nothing has been removed from either project yet. The Rust modules here are real, tested and lint-clean; the rest of this file is the case for what should follow and what should not.
The evidence
Measured, not estimated — difflib over the two working trees on
2026-08-28:
| file | dev-updater | ai-app | identical |
|---|---|---|---|
EnrollmentScanActivity.kt |
32 lines | 32 lines | 98% |
ServerConfig.kt |
139 | 122 | 89% |
auth.rs |
250 | 230 | 83% |
PinnedCert.kt |
60 | 59 | 75% |
certs.rs |
218 | 199 | 70% |
RON format module |
— | — | byte-identical |
The percentages understate it, because the differences are almost entirely a product name and a type parameter:
EnrollmentScanActivity.ktdiffers by itspackageline. That is all.PinnedCert.ktdiffers bypackageand one string,"dev-updater-dev-ca"against"ai-app-dev-ca".certs.rsdiffers by the organisation name in the certificate, and by ai-app having factored the file-mode handling into aprivatemodule that dev-updater still has inline in two places.auth.rsdiffers by the state type the middleware is generic over (AppStateagainstSessionManager), and by dev-updater keepingprint_enrollmentinauth.rswhere ai-app keeps the same function inmain.rs.wg_addressandlocal_addressesare the same logic in both, in files that are otherwise 8% alike.
ai-app's own Cargo.toml already says the quiet part: RON is used there
because it is "the same choice, and the same house rules, as the sibling
dev-updater project's config."
Why this is worth doing, in one observation
The two copies have each drifted into holding an improvement the other lacks. Not hypothetically — as of the day this was written:
| improvement | in dev-updater | in ai-app |
|---|---|---|
private:: module owning every file mode |
no, inline twice | yes |
SharedPreferences.edit { } instead of the deprecated builder |
no | yes |
existingTokenKey() — reads the key without creating one, so a stored blob with no key means "not enrolled" rather than leaving a stray key behind |
yes | no |
hasLocalNetworkPermission — tells a denied permission apart from an unreachable server, which are identical at the socket |
yes | no |
| Catppuccin theme with colour-by-consequence buttons | yes | no, ad-hoc Color(0xFF…) per screen |
Every one of those is a fix somebody made once, in one repo, that the other will either never get or get by being written a third time. That is the cost this repo removes, and it is already being paid.
What is here
server/ — the wg-link crate. Three modules, each extracted only after
diffing the two copies and finding nothing but a name between them.
netif—wg_address(), which fails closed when the tunnel is down, andlocal_addresses()for the certificate's SANs. The product name is a parameter so the failure reads as advice rather than as a library complaining.enroll— token generation, hex-SHA-256 storage, constant-time comparison, the<scheme>://enroll?…URI, and the terminal QR. The URI scheme is the parameter, because it is what routes a scan back to the right app.private— owner-only files and directories, taken from ai-app's version, with anappend_filealongsidecreate_filebecause a transcript must never be truncated by being opened.
What should follow, and what should not
Should follow, in this order. Each is already near-identical:
certs— CA generated once and never replaced, leaf reissued every start. Oneproduct: &str.- The RON
formathouse rules — byte-identical today, so this is pure deletion. - The Kotlin
EnrollmentScanActivity,PinnedCert, and the enrollment/Keystore half ofServerConfig, as an Android library module. This is where the sharing pays most, because it is where the two copies have drifted furthest apart in both directions. - Atomic owner-only config save. Both do temp-file-then-rename with the mode set before the rename; only the schema differs.
Should not. Naming these is the point of the exercise:
- The auth middleware itself. It is generic over each project's state type. Share the primitives (
enroll), let each keep the six lines that wire them to its own state — the alternative is a trait that exists only to let one function be shared. - The HTTP clients. dev-updater's
DownloadServer.ktand ai-app'sApi.ktare 14% alike and 135 against 494 lines. They have diverged because they are genuinely different programs. A shared pinned transport underneath them may be worth it later; a shared API is not. - Config schemas. Shared house rules, separate contents.
- Anything above the link. Projects, builds, sessions, providers. If a third project would not want it, it does not belong here.
The name
wg-server-app describes the two things it was extracted from. wg-link
— the crate's name here — describes what it actually is: neither a server
nor an app, but the link between them. Renaming the repo to match is a
mv and a rename on gitea; the crate can also be renamed the other way.
Whichever is preferred, it should be one name rather than two.
Testing
./run-tests.sh. The crate is warning-clean under cargo clippy --all-targets and formatted with plain cargo fmt at its defaults, per
the house rules.