Skip to content

Commit 7e5b833

Browse files
authored
feat(updater): in-app auto-update via signed GitHub releases (#144)
* fix(tauri): sync app version with package.json Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): add tauri-plugin-updater dependency Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): register updater plugin with GitHub manifest endpoint Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): mount tauri-plugin-updater on app builder Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(config): add [updater] section with auto_check and interval Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): add updater core with state, poller, commands Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): swap tray icon and menu when update available Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): add useUpdater hook for state and actions Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(tip-bar): support suppressed prop for update overlap Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): add UpdateBanner component for Settings Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): add UpdateFooterBar for chat surface Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): show update banner in Settings window Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): swap TipBar for UpdateFooterBar when update available Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(settings): add Check now and last-checked rows in About tab Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): add notes_url fallback to GitHub release tag Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * docs: document [updater] config and release signing process Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * fix(updater): clamp snooze input + dedupe sidecar filename Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(release): sign updater artifacts in CI, drop local key requirement Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> * feat(updater): replace tray-update icon with Thuki mascot variant Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com> --------- Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent 76f9180 commit 7e5b833

38 files changed

Lines changed: 2232 additions & 34 deletions

.github/workflows/release-please.yml

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,43 @@ jobs:
103103
codesign --deep --force --sign - src-tauri/target/release/bundle/macos/Thuki.app
104104
codesign --verify --verbose src-tauri/target/release/bundle/macos/Thuki.app
105105
106+
- name: Pack and sign updater payload
107+
env:
108+
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
109+
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
110+
TAG: ${{ needs.release-please.outputs.tag_name }}
111+
working-directory: src-tauri/target/release/bundle/macos
112+
run: |
113+
VERSION="${TAG#v}"
114+
PAYLOAD="Thuki_${VERSION}_aarch64.app.tar.gz"
115+
tar czf "$PAYLOAD" Thuki.app
116+
# Sign the payload with the project ed25519 key. The CLI reads the
117+
# key + password from TAURI_SIGNING_PRIVATE_KEY[_PASSWORD] env vars.
118+
bunx --bun @tauri-apps/cli signer sign "$PAYLOAD"
119+
120+
- name: Generate updater manifest
121+
env:
122+
TAG: ${{ needs.release-please.outputs.tag_name }}
123+
working-directory: src-tauri/target/release/bundle/macos
124+
run: |
125+
VERSION="${TAG#v}"
126+
PAYLOAD="Thuki_${VERSION}_aarch64.app.tar.gz"
127+
SIG="$(cat "${PAYLOAD}.sig")"
128+
PUB_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
129+
cat > latest.json <<EOF
130+
{
131+
"version": "${VERSION}",
132+
"notes": "https://github.com/quiet-node/thuki/releases/tag/${TAG}",
133+
"pub_date": "${PUB_DATE}",
134+
"platforms": {
135+
"darwin-aarch64": {
136+
"signature": "${SIG}",
137+
"url": "https://github.com/quiet-node/thuki/releases/download/${TAG}/${PAYLOAD}"
138+
}
139+
}
140+
}
141+
EOF
142+
106143
- name: Install create-dmg
107144
run: brew install create-dmg
108145

@@ -128,9 +165,16 @@ jobs:
128165
129166
rm -rf /tmp/thuki-dmg-src
130167
131-
- name: Upload release asset
168+
- name: Upload release assets
132169
env:
133170
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
171+
TAG: ${{ needs.release-please.outputs.tag_name }}
172+
working-directory: src-tauri/target/release/bundle
134173
run: |
135-
gh release upload ${{ needs.release-please.outputs.tag_name }} \
136-
src-tauri/target/release/bundle/dmg/Thuki.dmg
174+
VERSION="${TAG#v}"
175+
PAYLOAD="Thuki_${VERSION}_aarch64.app.tar.gz"
176+
gh release upload "$TAG" \
177+
"dmg/Thuki.dmg" \
178+
"macos/${PAYLOAD}" \
179+
"macos/${PAYLOAD}.sig" \
180+
"macos/latest.json"

docs/configurations.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ router_timeout_s = 45
7777
[debug]
7878
# Records every chat conversation and /search session to disk for later inspection.
7979
trace_enabled = false
80+
81+
[updater]
82+
# Poll for new Thuki releases at startup and on a recurring interval.
83+
auto_check = true
84+
# Hours between background checks. Bound to 1..168.
85+
check_interval_hours = 24
86+
# URL of the signed update manifest. Override only when mirroring releases.
87+
manifest_url = "https://github.com/quiet-node/thuki/releases/latest/download/latest.json"
8088
```
8189

8290
## Reading the reference tables
@@ -184,6 +192,18 @@ Records every chat conversation and `/search` session as JSON-Lines under `app_d
184192
| :-------------- | :------ | :------- | :-------------- | :----- | :--------------------------------------------------------------------------- |
185193
| `trace_enabled` | `false` | Yes ||| Records every chat conversation and `/search` session to disk for debugging. |
186194

195+
### `[updater]`
196+
197+
Controls how Thuki polls for new releases. The actual download, signature verification, and binary swap are handled by the bundled Tauri updater plugin against a signed manifest hosted on GitHub Releases. The manifest is verified against an ed25519 public key compiled into the app, so a hijacked release cannot push a malicious binary to existing installs.
198+
199+
| Field | Default | Tunable? | Why not tunable | Bounds | Description |
200+
| :--------------------- | :----------------------------------------------------------------------------------- | :------- | :-------------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
201+
| `auto_check` | `true` | Yes | n/a | n/a | Whether Thuki polls for updates automatically. When false, only the "Check now" button in Settings triggers a check. The tray badge and Settings banner still appear if a check finds an update. |
202+
| `check_interval_hours` | `24` | Yes | n/a | `1..168` | Hours between automatic background checks. Raise to spend less bandwidth on update polling; lower to surface new releases sooner. The interval also gates the startup check after a freshly resumed session. |
203+
| `manifest_url` | GitHub releases default | Yes | n/a | n/a | URL of the signed update manifest. Override only when mirroring releases (for example, an internal release feed). Empty values fall back to the default URL. |
204+
| `MAX_UPDATER_SNOOZE_HOURS` | `8760` | No | Defense-in-depth bound on `hours` arriving from the frontend IPC; prevents `u64` arithmetic in the snooze handlers from wrapping if a hostile or buggy caller supplies an extreme value. | n/a | Maximum number of hours a "snooze update" request can defer the next nag. Caps at one year so the deadline math cannot overflow even in the worst case. |
205+
| `DEFAULT_UPDATER_STATE_FILENAME` | `"updater_state.json"` | No | Internal sidecar filename used for snooze persistence next to `config.toml`; not meaningful to expose and easy to break by typo. | n/a | Filename of the JSON sidecar that records snooze deadlines so they survive app restarts. Lives in the same directory as `config.toml`. |
206+
187207
### `[activation]` (not in TOML)
188208

189209
Settings for the double-tap-Control hotkey that opens Thuki, plus the macOS Accessibility permission check. None of these are user-tunable: the hotkey listener runs in a low-level system thread that cannot read live config, so changing them would require restructuring the keyboard plumbing.

docs/release-process.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Releasing Thuki
2+
3+
Thuki ships signed updates to existing installs through the bundled Tauri updater. Releases are fully automated: the GitHub Actions workflow builds, signs, and publishes everything when a release-please PR merges.
4+
5+
## Day-to-day: nothing to do
6+
7+
Releases happen automatically. Land conventional-commit PRs into `main`. release-please opens a release PR. Merging that PR cuts a tag, which triggers the build workflow. The workflow produces:
8+
9+
- `Thuki.dmg` (fresh-install download)
10+
- `Thuki_<version>_aarch64.app.tar.gz` (updater payload, ad-hoc-signed `.app` inside)
11+
- `Thuki_<version>_aarch64.app.tar.gz.sig` (ed25519 signature for the payload)
12+
- `latest.json` (the manifest the in-app updater polls)
13+
14+
All four are uploaded to the GitHub release. Existing v0.7.x installs detect the new version on their next 24-hour check and offer to install in place.
15+
16+
## Where the signing key lives
17+
18+
The ed25519 private key is stored in **GitHub Actions secrets**, not on any developer laptop:
19+
20+
- `TAURI_SIGNING_PRIVATE_KEY`: contents of the private key file.
21+
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`: empty for the current key, kept as a secret for future password-protected rotations.
22+
23+
The matching public key is committed to the repo at `src-tauri/tauri.conf.json` under `plugins.updater.pubkey`. Every Thuki binary verifies updates against that public key. An attacker who replaces a release file cannot also forge a valid signature without the private key, so the swap is rejected and the running app keeps its current version.
24+
25+
A backup copy of both keys lives in the private `quiet-node/thuki-confidential` repo. That copy is the disaster-recovery anchor: if GitHub Actions secrets ever get wiped, restore from the backup; if the backup is ever compromised, rotate the keypair (which orphans every existing install at its current version, so do this only as a last resort).
26+
27+
## Local development: no keys required
28+
29+
`bun run build:all` and `bun run validate-build` produce an unsigned `.app` bundle. Devs can launch it, test production behavior, and verify everything compiles. The signing step is gated behind `bun run build:release`, which is only invoked by CI.
30+
31+
There is nothing to set up on your laptop. No env vars, no key files, no `.zshrc.local` overrides. New contributors clone the repo and start working.
32+
33+
## Cutting a release manually (rare)
34+
35+
If for some reason a release must be cut outside of CI (incident response, rolling back a bad release-please commit, etc.), the procedure is:
36+
37+
1. Restore the keypair from `quiet-node/thuki-confidential` to a temporary location.
38+
2. Export the env vars in the shell that runs the build:
39+
40+
```bash
41+
export TAURI_SIGNING_PRIVATE_KEY="$(cat /path/to/restored/thuki-updater.key)"
42+
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
43+
```
44+
45+
3. Bump versions in `package.json` and `src-tauri/tauri.conf.json` to match.
46+
4. Build the signed payload:
47+
48+
```bash
49+
bun run build:release
50+
```
51+
52+
5. Codesign the inner `.app` with `codesign --deep --force --sign - <Thuki.app>`.
53+
6. Hand-craft `latest.json` (see template below) and upload it alongside the `.tar.gz`, `.sig`, and `Thuki.dmg` to the GitHub release.
54+
7. Securely delete the restored key from the temporary location.
55+
56+
This path is documented for completeness only. CI is the supported path.
57+
58+
## `latest.json` template
59+
60+
```json
61+
{
62+
"version": "0.8.0",
63+
"notes": "https://github.com/quiet-node/thuki/releases/tag/v0.8.0",
64+
"pub_date": "2026-05-08T12:00:00Z",
65+
"platforms": {
66+
"darwin-aarch64": {
67+
"signature": "<contents of Thuki_0.8.0_aarch64.app.tar.gz.sig>",
68+
"url": "https://github.com/quiet-node/thuki/releases/download/v0.8.0/Thuki_0.8.0_aarch64.app.tar.gz"
69+
}
70+
}
71+
}
72+
```
73+
74+
The `signature` field is the entire content of the matching `.sig` file as a single string. Do not strip whitespace.
75+
76+
## Verify a release
77+
78+
After a release publishes, fetch the manifest:
79+
80+
```bash
81+
curl -sL https://github.com/quiet-node/thuki/releases/latest/download/latest.json | jq .
82+
```
83+
84+
Check that `version` matches the new tag, `url` resolves, and `signature` matches the contents of the `.sig` file in the release assets.
85+
86+
For an end-to-end smoke test, install the previous version on a clean macOS account, leave it open for 24 hours (or trigger Settings → Check now), and confirm the in-app banner picks up the new version and installs cleanly.
87+
88+
## Rollback
89+
90+
The updater never moves backwards on its own. If a release is bad, publish a higher version that reverts the change.
91+
92+
If a release ships with an invalid signature, existing installs reject the payload and surface an "update verification failed" message. They keep running on their current version. Re-cut the release with a valid signature, increment the patch version, and re-publish.
93+
94+
## Apple Developer Program note
95+
96+
Thuki does not require Apple Developer Program membership. The app is ad-hoc signed at build time. Auto-updates work because the Tauri updater downloads the payload via the application process, so no quarantine attribute is set on the swapped binary and Gatekeeper does not re-prompt at relaunch. First-install Gatekeeper friction (right-click, Open) still applies for users downloading the `.app` directly from a release page.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"generate:commands": "bun scripts/generate-commands.ts",
1616
"build:frontend": "tsc && vite build",
1717
"build:backend": "tauri build --bundles app",
18+
"build:release": "tauri build --bundles app -c \"{\\\"bundle\\\":{\\\"createUpdaterArtifacts\\\":true}}\"",
1819
"build:all": "bun run build:frontend && bun run build:backend",
1920
"preview": "vite preview",
2021
"tauri": "tauri",

0 commit comments

Comments
 (0)