Make the applications installable, and point them at a server - #49
Conversation
Every packaged build — Linux, Windows, macOS, Android, iOS — carried `http://127.0.0.1:8787` compiled into `api.ts`, pinned a second time by the shell's `connect-src`, and guarded by `csp.test.ts` so the two could not drift. An application that reaches only a server on the same machine is a demonstration; it is not something anybody can install. `api.ts` said as much: "a desktop build aimed at another server would set `__WHISPEE_API__` from the native side. Nothing does today." This is the caller that was missing. The address is asked for once, reached before it is kept, validated in Rust, and stored beside the session. `server.rs` is where the rules live, in the process the page cannot skip: https anywhere, plain http only to loopback, no credentials, no path, no query. Its three loopback hosts are the same three the shell's policy names, and the comment on each says why — a validator that accepted `127.0.0.2` would store an address the browser engine then refuses to contact, with no error naming the cause. `connect-src` gains `https:` and `wss:`, with no host, because a policy cannot name an origin it will only learn at run time. That width is affordable here and nowhere else: this policy governs JavaScript packaged inside the installed binary, which is the whole argument `lib.rs` makes for the application existing. The web build keeps `connect-src 'self'`. `csp.test.ts` carries the allowance and fails without it, which was checked by removing it. Changing the address later is not offered. This device is attested by an account key the other server has never heard of, so a "switch server" control would be a control that silently discards an identity. The settings show it, read-only, next to the erasure that is the real exit. Along the way: the mobile workflows stopped freezing an API address into a bundle that no longer reads one, and four documents stopped describing a `VITE_API_URL` that was removed with the verifiable build.
There was no manifest and not one application icon. That is not a cosmetic gap: **Safari refuses a push subscription from a site that has not been added to the home screen**, so until now there was no version of this client that could be woken on iOS at all. `ROADMAP.md` already knew the sentence; nothing had acted on it. `navigator.setAppBadge` is the same story in miniature — it does nothing for a page that is not installed. Every icon comes from `apps/desktop/icons/icon.png`, the 512-pixel source that was already here, so there is no second asset to keep in step. The three that differ from it differ for a reason written where it applies: the maskable icon is opaque and its mark is smaller, because a launcher crops it to whatever shape it likes; the Apple touch icon is opaque because iOS composites a transparent one onto black; the tab icon carries three sizes because a tab shows 16. `icon.ico` and `icon.icns` are generated from the same source and committed too. Nothing builds for Windows or macOS yet — that is the next commit — and producing them at build time would put an unreproducible step between the source and the artefact. The manifest declares one `theme_color` where `index.html` declares two. The light one wins, because the splash screen of an installed application is painted before any script has run and therefore before anything knows which scheme the reader prefers. The seam that costs is one light flash on a dark phone, at launch. A manifest cannot carry comments, so that argument lives beside the `<link>` that names it. `dist` goes from 226 files to 232.
The worker refused to cache, and the refusal was right: a worker that caches the shell keeps a copy of the application alive across visits, so a server that served a hostile bundle once keeps its victim after it is fixed. Meanwhile every visit re-downloaded nine megabytes of emoji data and every fingerprinted asset, and a cold start with no network showed nothing at all. The objection is about `index.html`, and that is what is answered. The entry point is fetched every time — and **revalidated**, with `cache: "no-cache"`, rather than read out of the browser's own HTTP cache, so the property does not rest on a reverse proxy somebody else configured. `deploy/Caddyfile` sets that header and says why; a deployment that does not still heals on the next load. That was measured: served without any cache header, the corrected `index.html` takes over immediately. What is answered from the cache first is either addressed by its content — `/assets/`, which Vite fingerprints, and which `index.html` names — or is not code at all: the emoji files go to `JSON.parse`, the fonts to a shaper. A stale one draws the wrong picture; it does not run. `crypto_wasm_bg.wasm` and `pdfjs/wasm/*` are excluded for being both executable and stably named, which is exactly the pair of properties that would let a cached copy outlive its correction. `/v1` is never touched. `push.test.ts` stopped asserting that the file contains no `caches` and started running it: `node:vm` with the globals a worker has, then the decisions are checked directly, offline fallback and revalidation included. A real Chrome over CDP confirmed the rest — registration at boot, an installable manifest, a cold offline boot that renders the application, and the healing above. The residual cost is written into the worker's header, `notifications.ts`, `ROADMAP.md` and `THREAT-MODEL.md` rather than left to be discovered: offline, this starts from the last `index.html` this browser received.
The bundle configuration produced a `.deb` and an AppImage, there was no `icon.ico` and no `icon.icns`, and no workflow built a desktop binary at all — `release.yml` published the web manifest and nothing else. Windows and macOS had never been built. Six targets now, and a matrix job that builds them on a tag and attaches them to the same release as the manifest, each with a `SHA256SUMS` and a Sigstore attestation. The argument for attestation over signature is the one this repository already makes about its own Ed25519 key: a signature says a key-holder approved the bytes, an attestation says which source and which workflow produced them, and the key here lives in the repository whose output it vouches for. They are unsigned by the platforms, and that has a price at the door: SmartScreen on Windows, right-click → Open on macOS. It is in `README.md` and in the workflow's own header rather than left for somebody to meet. The `.dmg` is Apple Silicon only, `macos-14` being an arm64 runner; Linux builds on 22.04 so the binaries start on something older than the newest Ubuntu. The version is taken from the tag with `--config` rather than from `tauri.conf.json`: two files already carry one, and a third place to bump is a third place to forget. The Caddyfile stops letting `sw.js` and the manifest be cached, for the reason one level up from `index.html`: a stale worker keeps its routing rules after they have been corrected. The worker does not depend on that header — it asks for the entry point with `cache: "no-cache"` itself — so this is the second lock, not the only one. `README.md`, `ROADMAP.md` and `BUILD.md` say the rest, including the reversal worth stating plainly: with the web client installable and no FCM or APNs written, a site added to a phone's home screen is notified where the native application is not. And that nothing reaches a watch, which waits entirely on the same missing piece.
The first `.deb` ever built on this machine — `BUILD.md` said none of them had been — installed `/usr/bin/desktop`, an icon keyed `desktop`, and a launcher entry whose `Exec` and `StartupWMClass` both read `desktop`. The binary takes the crate's name, and the crate is called `desktop` because one crate serves desktop, Android and iOS. A name that generic on a shared `PATH` waits for a second package to collide with, and a window class nothing matches is a taskbar entry that never groups with its own launcher. An explicit `[[bin]]` renames the artefact and leaves the package alone, so every `-p desktop` in the workflows still means what it did. `scripts/release.sh` copied `target/release/desktop`, which no longer exists; it copies `target/release/whispee` now. That path would have failed on the first signed release after this change, which is a long way from where the cause is. Rebuilt and checked: `/usr/bin/whispee`, four `whispee.png` icons, `Exec=whispee`. And `.gitignore` learns about `release/web/`, which `release-web.sh` writes and then refuses to run beside, so the script worked once and blocked itself afterwards.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5c58578931
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| - name: Build the installers | ||
| working-directory: apps/desktop | ||
| shell: bash | ||
| run: | | ||
| if [[ "$GITHUB_REF" == refs/tags/v* ]]; then | ||
| cargo tauri build --bundles ${{ matrix.bundles }} \ |
There was a problem hiding this comment.
Install web dependencies before building installers
In every matrix entry this runs after a fresh checkout but without pnpm install, while tauri.conf.json makes cargo tauri build invoke pnpm --dir web build. I reproduced that command from a clean checkout: it exits with TS2688, reports that node_modules is missing, and suggests installing dependencies. pnpm run --help also describes the command only as running an existing package script, not installing its dependencies. Consequently both tagged releases and manual rehearsals fail before producing any installer; add the same frozen-lockfile install step used by the Android, iOS, and test workflows.
Useful? React with 👍 / 👎.
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| gh release upload "${GITHUB_REF_NAME}" release/desktop/* --clobber |
There was a problem hiding this comment.
Publish platform-specific checksum manifests
All three matrix jobs create an asset named SHA256SUMS, but this command uploads them concurrently with --clobber. The checked gh release upload --help states that --clobber deletes and re-uploads existing assets of the same name, so whichever platform finishes last replaces the other platforms' checksum files (and concurrent delete/upload operations can race). The resulting release therefore cannot provide the promised checksums for all six installers; give each manifest a platform-specific name or merge them in a dependent publication job.
Useful? React with 👍 / 👎.
| #[tauri::command] | ||
| pub fn server_set(url: String, vault: State<'_, Vault>) -> Result<String, String> { | ||
| crate::server::write(&vault.paths.server(), &url) |
There was a problem hiding this comment.
Remove the saved server when erasing an identity
For a packaged user who chooses “Erase this identity” in order to leave the selected server—the new setup screen explicitly describes erasure as the only exit—the erasure path only calls session_clear; this API provides URL read/write commands but no way to delete server.txt. After reload, configuredServer() still returns the old origin, so the setup screen is skipped and the user cannot select another server without uninstalling the application or manually editing private app data. Include removal of the stored server in the identity-erasure flow.
Useful? React with 👍 / 👎.
…d not Both found by pointing a browser at the `deploy/` stack over a tunnel, which is the first time this client has been loaded from a public HTTPS origin. **`frame-ancestors` was protecting nothing.** The policy reaches the browser in a `<meta http-equiv>`, and the specification says that directive is ignored there. `csp.ts` declared it, `index.html` carried it, and the deployment had no defence against being framed at all. `deploy/Caddyfile` now sends `X-Frame-Options: DENY` — one header, saying the one thing the meta cannot say, rather than a third hand-maintained copy of the whole policy, which is the drift the note beside it refuses. The directive stays in `csp.ts`, with the reason written there: it is not wrong, only undelivered, and it becomes the stronger of the two the day a deployment sends the policy as a header. **`apple-mobile-web-app-capable` is deprecated**, and Chrome asks for `mobile-web-app-capable`. Safari reads only the Apple spelling, so both are declared; dropping either loses a platform. Neither would have been noticed by reading the files.
**"Device-side registration needs a Tauri plugin that does not exist."** It does now. `tauri-plugin-notifications` published 0.5.0-rc.11 on 2026-06-30 with twenty thousand downloads and FCM and APNs delivery announced outright, and three others sit beside it. What actually blocks FCM and APNs is narrower and worth naming precisely: APNs cannot be exercised without a paid Apple Developer membership — a free personal team is not offered the Push Notifications capability, and the simulator receives no remote push — and there is no Android device here. The wall moved from "the tooling does not exist" to "nothing here can run it", which is smaller and honest. **"UnifiedPush is not specified, and it is not planned."** The first half was wrong and the entry was in the wrong list: UnifiedPush endpoints are Web Push endpoints, authenticated with the same VAPID signature `vapid.rs` already mints, so it has a fix inside this design rather than outside it. It leaves "What will not be resolved". And a correction to what I said an hour ago, before reading the specification: it is **not** free. The Android specification requires the body be RFC 8291 content of between one and 4096 bytes, and an empty POST — exactly what this server sends, and the stated reason Web Push was affordable at all — is not a legal UnifiedPush message. So the content-encryption half is needed after all, plus the `p256dh` and `auth` columns `0011_push.sql` never had. The cost is written down so nobody re-estimates it from scratch: `p256`, `hkdf`, `sha2` and `aes-gcm` are already dependencies of this server, so it is one cargo feature, one module and one migration. And the wake-up stays empty of meaning — one constant encrypted byte satisfies the minimum and tells the distributor nothing. Two adjacent lines went stale in the same hour. "There is no physical device here" is now about native builds only; a real notch and a real virtual keyboard belong to the web client, which an iPhone can load from a tunnelled `deploy/` stack. And iOS push is untested "for want of one" no longer — it is simply the next thing to run, and the thing that decides how much APNs is worth buying.
An analysis of the experience per device produced one finding that dominated the
rest: only the web client served over HTTPS worked end to end. Every packaged
build — Linux, Windows, macOS, Android, iOS — was wired to
http://127.0.0.1:8787in
api.ts, pinned a second time by the shell'sconnect-src, and guarded so thetwo could not drift. And there was no manifest and no application icon at all,
which is not cosmetic: Safari refuses a push subscription from a site that has not
been added to the home screen, so no version of this client could ever have been
woken on iOS.
Five commits, in order. Each one is readable on its own.
298d5b43027909cc6fe1372d79545c58578whispee, notdesktop887dd7fcf6f79aAdded after the first review pass
The
deploy/stack was brought up and exposed over an HTTPS tunnel — the first time this clienthas been loaded from a public HTTPS origin — and a browser console reported two things that
reading the files had not.
frame-ancestorswas protecting nothing. The policy reaches the browser in a<meta http-equiv>, where the specification says that directive is ignored.csp.tsdeclared it,index.htmlcarried it, and the deployment had no defence against being framed at all.deploy/Caddyfilenow sendsX-Frame-Options: DENY— one header saying the one thing the metacannot say, rather than a third hand-maintained copy of the whole policy. The directive stays in
csp.ts: it is not wrong, only undelivered, and becomes the stronger of the two the day adeployment sends the policy as a header.
apple-mobile-web-app-capableis deprecated. Chrome asks formobile-web-app-capable; Safarireads only the Apple spelling. Both are declared now.
And two claims in
ROADMAP.mdhad expired. "Device-side registration needs a Tauri plugin thatdoes not exist" — several exist, so the wall moved from "the tooling does not exist" to "APNs needs
a paid Apple membership and there is no Android device here". And "UnifiedPush is not specified" —
it is, precisely, and its endpoints are Web Push endpoints, so it leaves What will not be
resolved. The correction also records what it actually costs: not nothing, since the Android
specification forbids the empty body this server sends, so RFC 8291 is needed after all.
What was measured, not assumed
The
.debwas built on this machine.docs/BUILD.mdsaid "these builds haveonly ever run in CI. None of them has been run locally." That is no longer true,
and the first one immediately produced a defect no CI check would have reported:
it installed
/usr/bin/desktop, an icon keyeddesktop, and a launcher whoseExecandStartupWMClassboth saiddesktop.5c58578is that fix, and italso repairs
scripts/release.sh, which copied a path that no longer exists — afailure that would have surfaced on the first signed release, a long way from its
cause.
A real Chrome, over the DevTools protocol. Thirteen assertions: the manifest
parses and is installable in shape, the maskable icon is declared, iOS is told it
is web-app capable, the worker registers at boot, one cache generation exists,
fingerprinted assets are kept, the WASM is not, nothing under
/v1is, and a coldload with no network renders the application rather than an error page.
The healing check failed first, and that mattered. A corrected
index.htmldid not take over. The cause was the test server sending no
Cache-Control, soChrome answered from its own HTTP cache — a harness artefact, but it exposed a
real dependency: the property would have rested on each operator's reverse proxy.
The worker now asks for the entry point with
cache: "no-cache", and the checkpasses against a server with no headers at all.
deploy/Caddyfilestill setsthem; two locks, not one.
scripts/verify-web.sh: 232 of 232 files matched. The six new files enter thepublished manifest without disturbing it.
438 Rust tests, 730 client tests, clippy and
tscclean.The refusal that had to be answered
public/sw.jsrefused to cache, in as many words, and the refusal was right: aworker that caches the shell keeps a hostile bundle alive after it is fixed.
What it was right about is
index.html. That is fetched every time and readfrom the cache only when there is no network. What is answered from the cache
first is either addressed by its content —
/assets/, which Vite fingerprints andwhich
index.htmlnames — or is not code: the emoji files go toJSON.parse, thefonts to a shaper.
crypto_wasm_bg.wasmandpdfjs/wasm/*are excluded for beingboth executable and stably named, which is the pair of properties that would let a
cached copy outlive its correction.
push.test.tsstopped asserting the file contains nocachesand started runningit in
node:vmwith a worker's globals, checking what it decides.The residual cost is in the worker's header,
notifications.ts,ROADMAP.mdandTHREAT-MODEL.md: offline, the application starts from the lastindex.htmlthisbrowser received.
What widened, and why it is affordable
The shell's
connect-srcgainshttps:andwss:, with no host, because a policycannot name an origin it will only learn at run time. That width is affordable
here and nowhere else: this policy governs JavaScript packaged inside the installed
binary, which is the whole argument
apps/desktop/src/lib.rsmakes for theapplication existing. The web build keeps
connect-src 'self'.The compensation is that the address goes through a parser first.
server.rsrefuses anything that is not a bare origin, and refuses plain HTTP to anywhere but
loopback — and its three loopback hosts are the same three the policy names, which
the comment on each side says out loud. A validator accepting
127.0.0.2wouldstore an address the browser engine then refuses to contact, with no error naming
the cause.
csp.test.tscarries the allowance and fails without it. That was checked byremoving it.
What this does not solve, stated plainly
a Tauri webview has no service worker. Since the web client became installable,
the ranking is inverted on a phone: the site on the home screen is notified and
the native application is not.
installers carry a Sigstore attestation and no platform signature.
.dmgis Apple Silicon only.the command table in it contains
server_urlandserver_set— the bug classROADMAP.mdrecords is closed — but nobody has typed an address into a realwindow and signed in.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XZwsb96ynxuRsx8BweJWvc