diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 0a35150..4583de8 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -129,8 +129,23 @@ jobs: # The Tauri updater fetches a manifest describing the newest version, its # notes, and per-target {signature, url}. The URL points at the MIRROR # repo (mca/public_releases) — that's the unauthenticated endpoint field - # appliances actually reach; see the workflow header for why. Adjust the - # platform keys you actually ship. + # appliances actually reach; see the workflow header for why. + # + # ONE ENTRY PER INSTALLER TYPE — this is what made every in-app update + # v0.1.0→v0.1.6 fail. tauri-plugin-updater looks up + # `{os}-{arch}-{installer}` FIRST (linux-x86_64-deb / -rpm / -appimage, + # from the running app's detected bundle type) and only then the bare + # `linux-x86_64`. The booths run the .deb, and the manifest used to + # carry ONLY `linux-x86_64` → the AppImage. So a .deb install found the + # "update", downloaded the AppImage, verified its signature fine, then + # handed the bytes to install_deb(), which checks they're a .deb + # (infer::archive::is_deb) and bails with InvalidUpdaterFormat — after + # the download, before any relaunch, with the error swallowed client- + # side until v0.1.6. Now each installer gets its own signed asset; the + # bare key stays for an AppImage install. .deb/.rpm updates run + # `pkexec dpkg -i` / `rpm -U`, so the operator sees a polkit password + # prompt — intended: updating a root-installed package IS an admin + # action on this box (see wiki/decisions/desktop-shell-tauri.md). env: SERVER_URL: ${{ github.server_url }} MIRROR_REPO: mca/public_releases @@ -138,22 +153,41 @@ jobs: run: | set -e VERSION="${TAG#v}" - APPIMAGE=$(cd dist && ls *.AppImage | head -1) - SIG=$(cat "dist/${APPIMAGE}.sig") - ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}" - cat > dist/latest.json < /tmp/latest.js <<'JS' + const fs = require("fs"); + const [version, tag, base] = process.argv.slice(2); + const files = fs.readdirSync("dist"); + const pick = (ext) => files.find((f) => f.endsWith(ext)); + const entry = (f) => ({ + signature: fs.readFileSync(`dist/${f}.sig`, "utf8").trim(), + url: `${base}/${f}`, + }); + const deb = pick(".deb"), rpm = pick(".rpm"), appimage = pick(".AppImage"); + if (!deb || !appimage) { + console.error(`missing bundle in dist/: deb=${deb} appimage=${appimage}`); + process.exit(1); } - JSON + const platforms = { + "linux-x86_64-deb": entry(deb), + ...(rpm ? { "linux-x86_64-rpm": entry(rpm) } : {}), + "linux-x86_64": entry(appimage), + }; + fs.writeFileSync( + "dist/latest.json", + JSON.stringify( + { + version, + notes: `Parking System ${tag}`, + pub_date: new Date().toISOString().replace(/\.\d+Z$/, "Z"), + platforms, + }, + null, + 2, + ) + "\n", + ); + JS + node /tmp/latest.js "${VERSION}" "${TAG}" "${ASSET_BASE}" echo "latest.json:"; cat dist/latest.json - name: Create release + upload assets (Gitea API) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d3db707..afefcce 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,6 +44,14 @@ see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The upd signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never committed. +**The manifest carries one entry per installer type** (`linux-x86_64-deb`, `linux-x86_64-rpm`, +and bare `linux-x86_64` for AppImage). The updater picks the entry matching how the running app +was installed — a `.deb` install will only ever accept a signed `.deb`. Booths run the `.deb`, +so an in-app update ends in a **polkit password prompt** (`pkexec dpkg -i`): that is expected, +and it is the right gate — the package lives in `/usr/bin`, root-owned, and the operator is not +supposed to be able to replace it silently. Cancel the prompt and the app keeps running the old +version; the failure is logged to the server's Logs viewer. + ## Release gate — run the REAL bundle locally before tagging `tauri dev` loads the SPA from `http://localhost:5173`, a plain http origin. The shipped bundle diff --git a/wiki/decisions/desktop-shell-tauri.md b/wiki/decisions/desktop-shell-tauri.md index 3cd320b..bbdc816 100644 --- a/wiki/decisions/desktop-shell-tauri.md +++ b/wiki/decisions/desktop-shell-tauri.md @@ -399,7 +399,8 @@ a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connec it as `Authorization: Bearer` (fastify-jwt would accept it) — that puts the session token in JS, which HttpOnly exists to prevent; the ticket keeps it out. Verified locally with an 11-case handshake script: ticket/no-cookie → 101 + hello; reused/bogus/absent → 401; ticket + bad Origin - → 403; cookie path unchanged. + → 403; cookie path unchanged. **Field-verified 2026-09-04:** v0.1.6 on the park-2 booth against + image `stage-8fa66c9` shows **LIVE** — the first desktop build to do so. - **Desktop client logs had never reached `app_logs`.** `logger.ts`'s flush read the CSRF token from `document.cookie` (null on desktop — the same jar split as above), so every `POST /api/logs` from the desktop 403'd under `requireAuth`→`assertCsrf`, and the flush drops @@ -420,3 +421,37 @@ a fresh handshake every 10 s (use-live-feed's capped backoff), i.e. every connec cookie-jar split *cannot* reproduce in dev mode. The pre-tag gate is now: build the bundle locally, run the AppImage against a local server, log in, confirm **LIVE**, do one mutation, and confirm a desktop-sourced row appears in the Logs viewer (`apps/desktop/README.md`). + +### In-app update never worked: the manifest only described the AppImage, the booths run the .deb (2026-09-04) + +Every self-update attempt from v0.1.0 through v0.1.6 ended the same way — prompt, download +traffic, then nothing, and the same prompt again next launch. The version-sync (v0.1.2) and +error-logging fixes were real but not the cause. **Root cause:** `tauri-plugin-updater` resolves +the download target as `{os}-{arch}-{installer}` **first** (`linux-x86_64-deb` here — the +bundler stamps `__TAURI_BUNDLE_TYPE_VAR_DEB` into the `.deb`'s binary, verified with `strings` +on a local build), then falls back to bare `{os}-{arch}`. `release.yml`'s `latest.json` carried +**only** `linux-x86_64`, pointing at the **AppImage**. So a `.deb` install found the update, +downloaded the AppImage, verified its signature (which was correct — for the AppImage), then +handed the bytes to `install_deb()`, whose first line checks `infer::archive::is_deb(bytes)` and +returns `InvalidUpdaterFormat`. Before v0.1.6 that error never reached the server (the desktop +log channel was itself broken — see the previous section), so it looked like a silent no-op. +Sources: `tauri-plugin-updater-2.10.1/src/updater.rs` (`get_urls`, `install_inner`, +`install_deb`), `tauri-utils/src/platform.rs` (`bundle_type`). + +- **Fix:** `latest.json` now carries one signed entry per installer — `linux-x86_64-deb`, + `linux-x86_64-rpm` (when built), and bare `linux-x86_64` for the AppImage — assembled by a + small Node script in the workflow (the `.sig` files for `.deb`/`.rpm` were already being + produced and uploaded, just never referenced). +- **What a booth update now looks like:** prompt → download → **polkit password dialog** + (`pkexec dpkg -i`) → relaunch into the new version. The prompt is deliberate, not a wart: the + package is root-owned in `/usr/bin`, and under the [[threat-model]] the operator must not be + able to replace the app silently; whoever brings the box online for an update is the admin. + Cancelling the dialog leaves the old version running and logs + `desktop_update_install_failed` to `app_logs`. +- **Rejected:** switching booths to the AppImage so updates need no privilege. It would work + (the updater rewrites the AppImage in place), but the binary would then be operator-writable, + it needs FUSE on the appliance image, and launcher/autostart integration becomes manual — + three regressions to avoid one password prompt. +- **Judgment note for the retrospective:** three fixes were shipped against this symptom + without reading the updater's install path once. The whole chain is ~60 lines of vendored + Rust in `~/.cargo/registry`; it names the exact failure (`InvalidUpdaterFormat`). diff --git a/wiki/log.md b/wiki/log.md index ca6fd27..b5e3bde 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2860,3 +2860,16 @@ uses the unauthenticated /health (extended with app: "parking-system") instead o plugin does set Origin itself, the http-scope "quirk" is URLPattern default-port semantics) and added a local-AppImage pre-tag gate to the desktop README, since tauri dev cannot reproduce any of these origin-dependent bugs. Full detail on [[desktop-shell-tauri]]. + +## [2026-09-04] fix | Desktop in-app update never worked: latest.json described only the AppImage, booths run the .deb + +tauri-plugin-updater looks up `{os}-{arch}-{installer}` first (linux-x86_64-deb — the bundler +stamps the installer type into the binary; verified with strings on a local .deb) and only then +bare linux-x86_64. release.yml's latest.json carried only the bare key → the AppImage, so every +.deb install downloaded the AppImage, passed signature verification, then failed install_deb()'s +is_deb check with InvalidUpdaterFormat — invisible until v0.1.6 fixed the desktop log channel. +This, not version drift or swallowed errors, is why v0.1.0→…→v0.1.6 never self-updated. +latest.json now has one signed entry per installer (deb, rpm, AppImage); a .deb update ends in a +polkit password prompt (pkexec dpkg -i), which is the intended admin gate on a root-installed +package. README + [[desktop-shell-tauri]] updated. First real test: tag v0.1.7 and accept the +prompt on the v0.1.6 booth.