Skip to content

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

Merged
Sycatle merged 7 commits into
devfrom
feat/installable-shells
Aug 25, 2026
Merged

Make the applications installable, and point them at a server#49
Sycatle merged 7 commits into
devfrom
feat/installable-shells

Conversation

@Sycatle

@Sycatle Sycatle commented Aug 25, 2026

Copy link
Copy Markdown
Owner

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:8787
in api.ts, pinned a second time by the shell's connect-src, and guarded so the
two 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.

Commit What it changes
298d5b4 The address is asked for, reached, validated in Rust, and stored beside the session
3027909 A manifest, icons for every platform, an unread badge
cc6fe13 The worker caches, and the entry point stays fresh
72d7954 Installers for the three desktop systems, attested on a tag
5c58578 The installed binary is whispee, not desktop
887dd7f Two defects a real browser's console reported
cf6f79a Two claims about push had expired

Added after the first review pass

The deploy/ stack was brought up and exposed over an HTTPS tunnel — the first time this client
has been loaded from a public HTTPS origin
— and a browser console reported two things that
reading the files had not.

frame-ancestors was protecting nothing. The policy reaches the browser in a <meta http-equiv>, where the specification says that directive is ignored. 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. The directive stays in
csp.ts: it is not wrong, only undelivered, and becomes the stronger of the two the day a
deployment sends the policy as a header.

apple-mobile-web-app-capable is deprecated. Chrome asks for mobile-web-app-capable; Safari
reads only the Apple spelling. Both are declared now.

And two claims in ROADMAP.md had expired. "Device-side registration needs a Tauri plugin that
does 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 .deb was built on this machine. docs/BUILD.md said "these builds have
only 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 keyed desktop, and a launcher whose
Exec and StartupWMClass both said desktop. 5c58578 is that fix, and it
also repairs scripts/release.sh, which copied a path that no longer exists — a
failure 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 /v1 is, and a cold
load with no network renders the application rather than an error page.

The healing check failed first, and that mattered. A corrected index.html
did not take over. The cause was the test server sending no Cache-Control, so
Chrome 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 check
passes against a server with no headers at all. deploy/Caddyfile still sets
them; two locks, not one.

scripts/verify-web.sh: 232 of 232 files matched. The six new files enter the
published manifest without disturbing it.

438 Rust tests, 730 client tests, clippy and tsc clean.

The refusal that had to be answered

public/sw.js refused to cache, in as many words, and the refusal was right: a
worker 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 read
from 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 and
which index.html names — or is not code: the emoji files go to JSON.parse, the
fonts to a shaper. crypto_wasm_bg.wasm and pdfjs/wasm/* are excluded for being
both executable and stably named, which is the pair of properties that would let a
cached copy outlive its correction.

push.test.ts stopped asserting the file contains no caches and started running
it in node:vm with a worker's globals, checking what it decides.

The residual cost is in the worker's header, notifications.ts, ROADMAP.md and
THREAT-MODEL.md: offline, the application starts from the last index.html this
browser received.

What widened, and why it is affordable

The shell's 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 apps/desktop/src/lib.rs makes for the
application existing. The web build keeps connect-src 'self'.

The compensation is that the address goes through a parser first. server.rs
refuses 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.2 would
store an address the browser engine then refuses to contact, with no error naming
the cause.

csp.test.ts carries the allowance and fails without it. That was checked by
removing it.

What this does not solve, stated plainly

  • A packaged build still has no background wake-up path. No FCM, no APNs, and
    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.
  • Nothing reaches a watch, and nothing can before the line above.
  • Windows will show SmartScreen and macOS will ask for a right-click. The
    installers carry a Sigstore attestation and no platform signature.
  • The .dmg is Apple Silicon only.
  • The window was never clicked through. The binary launches and stays up, and
    the command table in it contains server_url and server_set — the bug class
    ROADMAP.md records is closed — but nobody has typed an address into a real
    window and signed in.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XZwsb96ynxuRsx8BweJWvc

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.

@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: 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".

Comment on lines +201 to +206
- name: Build the installers
working-directory: apps/desktop
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
cargo tauri build --bundles ${{ matrix.bundles }} \

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 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

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

Comment on lines +144 to +146
#[tauri::command]
pub fn server_set(url: String, vault: State<'_, Vault>) -> Result<String, String> {
crate::server::write(&vault.paths.server(), &url)

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 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.
@Sycatle
Sycatle merged commit 1ece2f6 into dev Aug 25, 2026
5 checks passed
@Sycatle
Sycatle deleted the feat/installable-shells branch August 25, 2026 22:14
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