Skip to content

Make the applications installable, and point them at a server - #50

Merged
Sycatle merged 8 commits into
mainfrom
dev
Aug 25, 2026
Merged

Make the applications installable, and point them at a server#50
Sycatle merged 8 commits into
mainfrom
dev

Conversation

@Sycatle

@Sycatle Sycatle commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Promotes dev. One piece of work, in eight commits, plus the corrections that reading a real
browser 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:8787 in api.ts, pinned a second time by the
shell's connect-src. 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.

  • The address is a value now. Asked for on first launch, reached before it is kept, validated
    in Rust (apps/desktop/src/server.rs), stored beside the session. The shell's connect-src
    gains https: and wss: — affordable there and nowhere else, because that policy governs
    JavaScript packaged inside the installed binary. The web build keeps connect-src 'self'.
  • A manifest, icons for every platform, an unread badge. dist goes from 226 files to 232, and
    verify-web.sh matches 232 of 232.
  • The worker caches, and the entry point stays fresh — fetched every time and revalidated, so
    the property does not rest on a reverse proxy somebody else configured.
  • Installers for the three desktop systems, built on a tag with a SHA256SUMS and a Sigstore
    attestation. Unsigned by the platforms: SmartScreen on Windows, right-click → Open on macOS.

What the verification actually caught

The .deb was built on this machine — BUILD.md said none of these builds ever had been — and the
first one installed /usr/bin/desktop, an icon keyed desktop, and a launcher whose Exec said
desktop. scripts/release.sh copied 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-ancestors was protecting
nothing
: the policy arrives in a <meta http-equiv>, where that directive is ignored. The
deployment had no defence against being framed. deploy/Caddyfile sends X-Frame-Options: DENY
now.

And two claims in ROADMAP.md had expired — "device-side registration needs a Tauri plugin that
does 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 tsc clean, and thirteen assertions against a real
Chrome 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

Sycatle and others added 8 commits August 25, 2026 22:05
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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +155 to +159
# **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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +23 to +26
//! 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@Sycatle
Sycatle merged commit 3b56cf6 into main Aug 25, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant