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.
…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.
Make the applications installable, and point them at a server
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1ece2f6cd2
ℹ️ 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".
| env: | ||
| GH_TOKEN: ${{ github.token }} | ||
| run: | | ||
| gh release upload "${GITHUB_REF_NAME}" release/desktop/* --clobber |
There was a problem hiding this comment.
Stop matrix jobs from overwriting SHA256SUMS
On every tagged release, all three matrix jobs create an asset named SHA256SUMS, but each file contains only that job's platform hashes. They then upload concurrently with --clobber; I checked gh release upload --help, which states that this flag deletes an existing same-named asset before uploading the replacement. Consequently the final release nondeterministically retains only one platform's checksum list, leaving the other installers unverifiable despite the workflow presenting a single release-wide checksum file. Give the files platform-specific names or aggregate all artifacts before generating and uploading one manifest.
Useful? React with 👍 / 👎.
| # **Apple Silicon only.** `macos-14` is arm64, so this produces an arm64 `.dmg` and | ||
| # nothing for an Intel Mac. Building both would mean a second runner or a universal | ||
| # binary; neither is done here, and the gap is named in `README.md` rather than | ||
| # discovered by somebody downloading a file that will not open. | ||
| - os: macos-14 |
There was a problem hiding this comment.
Select an Arm runner for the Apple Silicon build
In the inspected desktop release matrix, GitHub's macos-14 runner label is Intel x64, not Arm64; the Arm label is macos-14-arm64. This therefore produces an x86_64 DMG while the workflow and README advertise an Apple-Silicon-only artifact, so users receive a different architecture than the release documentation promises. Use the Arm runner label or build an explicit universal target.
Useful? React with 👍 / 👎.
| //! Changing server means changing account. This device is attested by an account key the other | ||
| //! server has never heard of, and its MLS groups live in the first server's tables. So there is no | ||
| //! "switch server" that keeps anything, and offering one would offer a way to silently lose an | ||
| //! identity. Erasing the device is the exit, and it already exists. |
There was a problem hiding this comment.
Clear the stored server when erasing the device
The documented escape hatch does not actually return a packaged installation to server selection: Session.forget()/clearAll() only reaches session_clear, while the newly added server.txt has no clear command. After the promised "erase this device" flow reloads, server_url still returns the old address and main.tsx skips ServerSetup, permanently forcing the new account onto the same server unless the user manually deletes application data or uninstalls. Tie removal of server.txt to the destructive erase flow, or provide an equivalent supported reset.
Useful? React with 👍 / 👎.
Promotes
dev. One piece of work, in eight commits, plus the corrections that reading a realbrowser and a real specification forced along the way.
What lands
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:8787inapi.ts, pinned a second time by theshell's
connect-src. And there was no manifest and no application icon at all, which is notcosmetic: 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.
in Rust (
apps/desktop/src/server.rs), stored beside the session. The shell'sconnect-srcgains
https:andwss:— affordable there and nowhere else, because that policy governsJavaScript packaged inside the installed binary. The web build keeps
connect-src 'self'.distgoes from 226 files to 232, andverify-web.shmatches 232 of 232.the property does not rest on a reverse proxy somebody else configured.
SHA256SUMSand a Sigstoreattestation. Unsigned by the platforms: SmartScreen on Windows, right-click → Open on macOS.
What the verification actually caught
The
.debwas built on this machine —BUILD.mdsaid none of these builds ever had been — and thefirst one installed
/usr/bin/desktop, an icon keyeddesktop, and a launcher whoseExecsaiddesktop.scripts/release.shcopied that path and would have failed on the first signed release.The stack was then exposed over an HTTPS tunnel, the first time this client has been loaded from a
public HTTPS origin, and a browser console reported that
frame-ancestorswas protectingnothing: the policy arrives in a
<meta http-equiv>, where that directive is ignored. Thedeployment had no defence against being framed.
deploy/CaddyfilesendsX-Frame-Options: DENYnow.
And two claims in
ROADMAP.mdhad expired — "device-side registration needs a Tauri plugin thatdoes not exist" (several do) and "UnifiedPush is not specified" (it is). The correction also
records what UnifiedPush really costs, against my own earlier estimate: not nothing, because the
Android specification forbids the empty body this server sends.
Suites
438 Rust tests, 730 client tests, clippy and
tscclean, and thirteen assertions against a realChrome over the DevTools protocol, including a cold offline boot.
What this does not solve
A packaged build still has no background wake-up path — no FCM, no APNs, and no service worker in a
Tauri webview. 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. Nothing reaches a watch. And
nobody has yet typed an address into a real packaged window.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XZwsb96ynxuRsx8BweJWvc