Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,12 @@ jobs:
# init` produces a full Gradle project, and a generated project that drifts from its source
# is a source of silent errors. Regenerating costs a few seconds.
#
# `VITE_API_URL` is frozen into the bundle **and** into the CSP — see `csp()` in
# `vite.config.ts`. Absent, it would produce a client pointing at the phone's own loopback,
# that is, at nothing.
# No API address is passed. The bundle carries none — the client asks its own origin on the
# web, and the packaged application asks the person installing it (`apps/desktop/src/
# server.rs`). Freezing one in here used to produce an application pointing at the phone's
# own loopback, that is, at nothing.
- name: Generate the Android project
working-directory: apps/desktop
env:
VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }}
run: cargo tauri android init

# The camera scans the pairing square, and Android refuses `getUserMedia` without this
Expand All @@ -142,8 +141,6 @@ jobs:

- name: Build the APK
working-directory: apps/desktop
env:
VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }}
run: cargo tauri android build --debug --apk --target aarch64

- uses: actions/upload-artifact@v6
Expand Down
8 changes: 2 additions & 6 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,24 +80,20 @@ jobs:

# Same choice as for Android: the Xcode project is generated on every build rather than
# committed, so it cannot drift from `tauri.conf.json`.
# No API address is passed: the bundle carries none, and the packaged application asks the
# person installing it (`apps/desktop/src/server.rs`).
- name: Generate the Xcode project
working-directory: apps/desktop
env:
VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }}
run: cargo tauri ios init

- name: Build for the simulator
if: ${{ !inputs.device }}
working-directory: apps/desktop
env:
VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }}
run: cargo tauri ios build --debug --target aarch64-sim

- name: Build for a device
if: ${{ inputs.device }}
working-directory: apps/desktop
env:
VITE_API_URL: ${{ vars.VITE_API_URL || 'http://127.0.0.1:8787' }}
run: cargo tauri ios build --debug

- uses: actions/upload-artifact@v6
Expand Down
131 changes: 131 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,22 @@
# A manifest is a claim about a build somebody can install. `dev` moves several times a day and
# nothing deploys from it; a manifest per commit would be a list of hashes nobody could act on,
# and would make the released ones harder to find.
#
# # The desktop job, and why the binaries are unsigned
#
# Windows code signing is a certificate somebody rents; Apple notarisation is a developer account
# somebody rents. Neither was bought, so the installers carry no platform signature: Windows shows
# SmartScreen, macOS asks for a right-click. That is a real cost at the door and it is written into
# `README.md` rather than left to be discovered.
#
# What replaces it is the same thing that carries the web manifest: `SHA256SUMS` per platform,
# attested through Sigstore to this commit and this workflow. It is a weaker promise about *who
# vouches* and a stronger one about *what was built* — a signature says a key-holder approved the
# bytes, an attestation says which source produced them.
#
# The repository's own Ed25519 key (`scripts/release.sh`, `release/whispee.pub`) stays what it is:
# the reproducible local build. `verify-release.sh` already states its limit — the key lives in the
# repository, so whoever controls the repository can replace it.
name: Release

on:
Expand Down Expand Up @@ -113,3 +129,118 @@ jobs:
the same claim." \
release/web/WEB-SHA256SUMS \
release/web/BUILD-INFO

desktop:
name: Desktop installers
# The release has to exist before anything can be added to it, and `web` is what creates it.
# On `workflow_dispatch` this job still builds — the point of a rehearsal is to find out
# whether it builds — and publishes nothing, exactly like `web`.
needs: web

strategy:
# One platform failing must not cancel the other two: a broken macOS build is not a reason
# to lose a Windows installer that was already compiling.
fail-fast: false
matrix:
include:
# **22.04 rather than `ubuntu-latest`, and it is a compatibility decision.** A binary
# linked against 24.04's glibc refuses to start on anything older, and "works on the
# newest Ubuntu only" is not what a `.deb` is for.
- os: ubuntu-22.04
bundles: deb,rpm,appimage
artefacts: target/release/bundle/{deb/*.deb,rpm/*.rpm,appimage/*.AppImage}
- os: windows-latest
bundles: msi,nsis
artefacts: target/release/bundle/{msi/*.msi,nsis/*.exe}
# **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
bundles: dmg
artefacts: target/release/bundle/dmg/*.dmg

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v5

# Tauri links against the system webview, and without these `glib-sys`'s build script fails
# on `pkg-config` before anything is compiled. Same list as the `desktop` job in `test.yml`.
- name: Tauri's system dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev librsvg2-dev patchelf

- name: Toolchain
run: rustup toolchain install

- uses: Swatinem/rust-cache@v2

# The same pinning as the `web` job above, and for a related reason: the interface inside
# these binaries is the same bundle the manifest describes, so it is built the same way.
- uses: actions/setup-node@v5
with:
node-version-file: apps/web/.nvmrc
package-manager-cache: false

- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@11.22.0 --activate

- name: Install the Tauri CLI
run: cargo install tauri-cli --version "^2" --locked

# **The version comes from the tag, not from `tauri.conf.json`.**
#
# Two files already carry a version — the manifest and `apps/desktop/Cargo.toml` — and a
# third place to bump is a third place to forget. Taking it from the ref means an installer
# is named after the release it is attached to, by construction. On a rehearsal there is no
# tag, so the file's own value stands.
- name: Build the installers
working-directory: apps/desktop
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
cargo tauri build --bundles ${{ matrix.bundles }} \
Comment on lines +201 to +206

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

--config "{\"version\": \"${GITHUB_REF_NAME#v}\"}"
else
cargo tauri build --bundles ${{ matrix.bundles }}
fi

# Collected into one directory so the hash file, the attestation and the upload all name the
# same paths — three globs that have to agree is three chances for one of them to quietly
# match nothing.
- name: Collect and hash
shell: bash
run: |
shopt -s globstar nullglob
mkdir -p release/desktop
cp ${{ matrix.artefacts }} release/desktop/
cd release/desktop
# Refuse an empty directory loudly. A release carrying a `SHA256SUMS` with no files
# beside it is worse than a failed job: it looks like a successful publication.
[ -n "$(ls -A)" ] || { echo "no bundle was produced" >&2; exit 1; }
sha256sum * > SHA256SUMS
cat SHA256SUMS

# The bundles themselves, not the hash file. On the web side the manifest *is* the artefact,
# because the bytes being checked live on somebody else's server; here the bytes are what is
# downloaded, so that is what the provenance has to bind.
- uses: actions/attest-build-provenance@v3
with:
# The hash file is excluded rather than attested alongside: it describes the bundles, and
# a provenance statement about a list of hashes is one indirection away from the thing
# somebody actually runs.
subject-path: |
release/desktop/*
!release/desktop/SHA256SUMS

- name: Publish
if: startsWith(github.ref, 'refs/tags/')
shell: bash
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 👍 / 👎.

5 changes: 2 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,8 @@ jobs:
# remove.
#
# `vite build` rather than `pnpm run build`: that script runs `tsc --noEmit` first, and the
# `web` job already does. `VITE_API_URL` is left unset on purpose — it is frozen into the
# bundle and the CSP, which matters for something installed and not at all for a bundle
# that exists so a macro can find a directory.
# `web` job already does. No API address is passed, and none exists to pass: the bundle
# carries no deployment's configuration at all.
- uses: actions/setup-node@v5
with:
node-version: 22
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ pkg/
*.tsbuildinfo
dist/
release/artefacts/
# `release-web.sh` writes here, and refuses to run on a dirty tree — so leaving its own
# output tracked means it works once and then blocks itself.
release/web/
apps/desktop/gen/
.claude/scheduled_tasks.lock
.worktrees/
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,28 @@ not their equal and does not try to be.
| Disappearing messages | **On by default: seven days.** The lifetime is a group-context extension, so every member agrees on it; the server never learns it. Not enforceable on the other side — see [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md) |
| History vault | On by default, encrypted under a key derived from the recovery phrase — **and off for any conversation with a lifetime**, which is what makes disappearing mean anything. Such a conversation does not survive the loss of every device |
| Storage quota | 256 MiB per account by default, charged on vault writes and attachment uploads, credited back when a purge deletes. Envelopes are outside it: charging a sealed post would mean naming its sender — see [docs/ROADMAP.md](docs/ROADMAP.md) |
| Web, desktop | Vite 7 + React 19; Tauri 2 wraps the same build |
| Web, desktop, mobile | Vite 7 + React 19; Tauri 2 wraps the same build for Linux, Windows, macOS, Android and iOS. Each one is pointed at a server on first launch — there is no central service |
| Installable web client | A manifest, icons and a service worker that caches what is addressed by its content. It starts with no network, carries an unread badge, and — the part that is not cosmetic — is what lets iOS subscribe to push at all |
| Push notifications | Web Push, off until a deployment names a contact in `VAPID_SUBJECT`. The wake-up carries no text, no sender and no group id — the worker cannot decrypt, so it says only that something arrived |
| Verifiable web client | The bundle belongs to no deployment, so one published manifest of hashes describes every instance. CI attests it to GitHub; an extension compares what the browser actually received. See [docs/THREAT-MODEL.md](docs/THREAT-MODEL.md) § 4quinquies for what that establishes and what it does not |
| Deployment | `deploy/` — Postgres, the server, and Caddy terminating TLS on one origin. See [docs/DEPLOY.md](docs/DEPLOY.md) |
| Reproducible, signed releases | `scripts/release.sh`, `scripts/verify-release.sh` |
| Desktop installers | `.deb`, `.rpm`, AppImage, `.msi`, NSIS and `.dmg`, built by CI on a tag with a `SHA256SUMS` and a Sigstore attestation. **Unsigned by the platforms**: Windows shows SmartScreen and macOS asks for a right-click → Open. The `.dmg` is Apple Silicon only |

## What does not work

- **Push reaches browsers, not the packaged mobile app.** Web Push works end to end — a browser
subscribes, the server signs a VAPID token, a notification arrives with the tab closed — and it
is off until a deployment sets `VAPID_SUBJECT`. FCM and APNs are not written: device-side
registration needs a Tauri plugin that does not exist, so the Tauri build is only notified while
it is open. The wake-up carries no text, no sender and no group id.
- **Push reaches browsers, not the packaged mobile app** — and the installed web client is
therefore *better* at notifications on a phone than the native one, which is a reversal worth
stating plainly. Web Push works end to end: a browser subscribes, the server signs a VAPID
token, a notification arrives with the tab closed, and iOS can now subscribe because the client
is installable. FCM and APNs are not written — not for want of tooling any more, since several
Tauri push plugins now exist, but because APNs cannot be exercised without a paid Apple
Developer membership and there is no Android device here. A Tauri webview has no service worker
either, so a packaged build has **no background wake-up path at all**. The wake-up carries no
text, no sender and no group id. See [docs/ROADMAP.md](docs/ROADMAP.md).
- **Nothing reaches a watch.** An Apple Watch or a Wear OS device shows the notifications its
phone received, so this waits entirely on the line above — and even then, a notice that says
only "New message" is not much of a wrist.
- **Biometric unlock has never been executed.** The code exists; not one line of it has run.
There is no Android NDK and no physical device on the development machine, so even the
compilation of its dependency is unconfirmed.
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ publish.workspace = true
name = "desktop_lib"
crate-type = ["lib", "cdylib", "staticlib"]

# **The installed binary is `whispee`, not `desktop`.**
#
# Without this the binary takes the crate's name, and the first `.deb` ever built here proved what
# that costs: it installed `/usr/bin/desktop`, an icon keyed `desktop`, and a launcher entry whose
# `Exec` and `StartupWMClass` both said `desktop`. A name that generic on a shared `PATH` is a
# collision waiting for a second package, and a window class nothing can match is a taskbar entry
# that never groups with its own launcher.
#
# The package stays `desktop` because that is what serves desktop, Android and iOS from one crate
# and what every `-p desktop` in the workflows names. Only the artefact is renamed.
[[bin]]
name = "whispee"
path = "src/main.rs"

[build-dependencies]
tauri-build = { version = "2", features = [] }

Expand Down
Binary file added apps/desktop/icons/icon.icns
Binary file not shown.
Binary file added apps/desktop/icons/icon.ico
Binary file not shown.
22 changes: 22 additions & 0 deletions apps/desktop/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,28 @@ pub fn session_clear(vault: State<'_, Vault>) -> Result<(), String> {
}
}

/// The delivery service this installation was pointed at, or `None` before it has been.
///
/// `None` is what puts `apps/web/src/app/ServerSetup.tsx` on screen instead of the application.
/// It is a first-launch state, not a failure, which is why it is an `Option` and not an error.
#[tauri::command]
pub fn server_url(vault: State<'_, Vault>) -> Result<Option<String>, String> {
crate::server::read(&vault.paths.server()).map_err(|_| failure("unreadable address"))
}

/// Records the delivery service, and answers with the form that was stored.
///
/// The answer is the normalised address rather than `()`, so the page uses exactly what the file
/// holds: the two would otherwise differ by a trailing slash or a default port, and the client
/// would build its URLs from a string the next launch does not agree with.
///
/// The message on refusal is shown to the person typing, so it says what is wrong with the
/// address rather than that something is — see [`crate::server::normalise`].
#[tauri::command]
pub fn server_set(url: String, vault: State<'_, Vault>) -> Result<String, String> {
crate::server::write(&vault.paths.server(), &url)
Comment on lines +144 to +146

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

}

/// Installs the vault into the application.
///
/// Fails loudly if the secrets can be neither read nor created. That is deliberate: starting
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
pub mod cipher;
pub mod commands;
pub mod link;
pub mod server;
pub mod store;

/// Starts the application.
Expand Down Expand Up @@ -67,6 +68,8 @@ pub fn run() {
commands::session_load,
commands::session_save,
commands::session_clear,
commands::server_url,
commands::server_set,
commands::master_seal,
commands::master_open,
commands::master_present,
Expand Down
Loading
Loading