7 Commits

Author SHA1 Message Date
julian 276b048fa9 fix(desktop): sync tauri.conf.json version to the release tag, stop swallowing install failures
Build desktop / desktop (push) Successful in 4m13s
Build & push images / images (push) Successful in 2m48s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m37s
v0.1.1 was tagged but tauri.conf.json's own "version" field (what Tauri
bakes into the bundle filename/internal version) stayed at 0.1.0 — the
signed binary didn't match what latest.json claimed to describe, so every
update download failed signature verification. desktop-updater.ts's single
catch{} swallowed that identically to "offline", so it looked like nothing
happened at all. release.yml now syncs tauri.conf.json's version from the
git tag before building; the updater now logs a real post-accept failure
instead of silently reverting.
2026-09-03 12:24:04 +02:00
julian faa3265e49 fix(desktop): restore VITE_API_BASE for the desktop build
Build desktop / desktop (push) Successful in 4m17s
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m47s
apps/web/.env.production's VITE_API_BASE went empty in 96fd97e to fix the
booth/browser same-origin case, but the desktop build shares that file and
was never given its own override — login broke with WebKitGTK's "The
string did not match the expected pattern." (a relative fetch() URL with
no base, from tauri://localhost). beforeBuildCommand now sets
VITE_API_BASE=http://127.0.0.1:3000 inline for the desktop build only;
verified both builds independently produce the right output.
2026-09-03 12:01:56 +02:00
julian 21bfdce27a fix(release): surface the actual Gitea API error on mirror failure
Release desktop / bundle (push) Successful in 4m24s
The mirror step's release id came back empty on the last real run but
nothing failed loudly — every curl response was swallowed (|| true, or
piped straight to /dev/null), so we had no idea why. Capture HTTP status +
response body on every call and exit 1 with the actual error instead of
silently uploading to a malformed //assets URL with no release id.
2026-09-03 10:50:36 +02:00
julian d3288e29eb fix(release): don't let a grep-not-found kill the script under set -e
CI / check (push) Successful in 42s
Release desktop / bundle (push) Successful in 4m26s
Every REL_ID lookup piped grep -o '"id":...' straight into head/cut with no
guard. Under set -e + pipefail, a Gitea API response with no id (e.g.
"tag already exists" on a retry, or an empty existing-assets list on the
first desktop-latest publish) makes grep exit 1, which aborts the whole
step immediately — before the intended fallback lookup ever runs. Hit on
retrying v0.1.0 after the previous filename fix: the release already
existed from the earlier failed run, and the script died with no output at
all instead of finding it by tag. Guarded every such pipeline with || true.
2026-09-03 10:33:03 +02:00
julian baf7a4a99d fix(release): strip spaces from bundle filenames before upload
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 3m57s
productName "Parking System" produces installer filenames with a literal
space (e.g. "Parking System_0.1.0_amd64.deb"). curl rejected the resulting
asset-upload URL outright on the first real v0.1.0 release ("Malformed
input to a URL function"), before the job ever reached the new
public_releases mirror step. Sanitized on copy into dist/.
2026-09-03 10:26:17 +02:00
julian 885b410e48 chore(desktop): bump version to 0.1.0 for first tagged release
Build desktop / desktop (push) Successful in 4m21s
CI / check (push) Successful in 44s
Release desktop / bundle (push) Failing after 4m1s
Still at the scaffold default 0.0.0 with no v* tag ever cut. Bumping so a
v0.1.0 tag can exercise release.yml (and the new public_releases mirror
step) for the first time.
2026-09-03 10:11:20 +02:00
julian a1f3103a76 fix(desktop): mirror signed releases to public repo for the updater
Build desktop / desktop (push) Successful in 4m46s
CI / check (push) Successful in 43s
The updater endpoint pointed at mca/parking_solution's own Gitea "latest
release" redirect, but that repo is private and field appliances have no
Gitea credentials — every update check was silently failing. release.yml
now mirrors signed installers to mca/public_releases (public, installers
only) under a fixed desktop-latest tag; tauri.conf.json points there.
Rejected embedding a read token in the app instead, given the booth-operator
threat model.

Also: make the appliance-provisioning root_directory gotcha impossible to
skim past (boxed callout + explicit next-step pointers), after it caused a
second missed step on the park-2 install.
2026-09-03 09:56:49 +02:00
8 changed files with 305 additions and 50 deletions
+135 -12
View File
@@ -1,12 +1,24 @@
name: Release desktop
# Build the signed Tauri desktop installers on a version tag and publish them as
# a Gitea Release. The Tauri auto-updater (apps/web/src/lib/desktop-updater.ts)
# fetches these; latest.json + each installer + its .sig are what it needs.
# a Gitea Release — TWICE: once on this (private, source) repo for our own
# records/history, and once mirrored to mca/public_releases, which is what the
# Tauri auto-updater (apps/web/src/lib/desktop-updater.ts) actually points at.
#
# WHY a separate public repo: the updater runs on offline-first field appliances
# with no Gitea credentials, so its endpoint + installer downloads must be
# reachable unauthenticated. Mirroring compiled installers to a public
# releases-only repo avoids embedding any read token in the shipped app (which
# would leak the moment a booth PC is compromised — this box's threat model
# names the operator/booth as the primary adversary, see CLAUDE.md). Source
# stays private; only signed installers become public, same as most desktop
# software. mca/public_releases is shared across apps in the org, not
# parking-specific — namespace release tags/asset names accordingly if another
# app starts publishing there too.
#
# Trigger: push a tag like v0.1.0. The job builds .deb/.rpm/.AppImage, signs them
# with the updater key (Gitea secrets), assembles latest.json, and uploads
# everything to the Release for that tag.
# with the updater key (Gitea secrets), assembles latest.json pointing at the
# MIRROR repo's asset URLs, uploads to both repos, and mirrors the same assets.
on:
push:
@@ -63,6 +75,27 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Sync tauri.conf.json version to the git tag
# tauri.conf.json's own "version" field is what Tauri bakes into the
# bundle filename, the app's internal version, AND the updater's
# "current vs. new" comparison — it is NOT derived from the git tag
# automatically. Hit in v0.1.1: the tag was bumped but this file
# wasn't, so the signed binary + its .sig were still built (and
# named) as 0.1.0 while latest.json (built from TAG below) claimed
# 0.1.1 — the updater found the "update", downloaded a file whose
# signature didn't match what the manifest claimed to sign, and
# silently failed (a separate bug in desktop-updater.ts's error
# handling made this invisible — also fixed). Patch it here so the
# checked-in value is only ever a placeholder for local dev builds;
# a real release's version is always driven by the tag.
run: |
set -e
VERSION="${TAG#v}"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"${VERSION}\"/" apps/desktop/src-tauri/tauri.conf.json
grep '"version"' apps/desktop/src-tauri/tauri.conf.json
env:
TAG: ${{ github.ref_name }}
- name: Build + sign desktop bundle
env:
# Updater signing key (Gitea repo/org secrets). Without these the
@@ -73,30 +106,41 @@ jobs:
- name: Collect artifacts
id: collect
# Gather the installers + their .sig into a flat dist/ for upload.
# Gather the installers + their .sig into a flat dist/ for upload, spaces
# stripped from filenames. productName is "Parking System" (a space), so
# Tauri's bundle output is e.g. "Parking System_0.1.0_amd64.deb" — an
# unescaped space in a filename breaks the later curl asset-upload URL
# ("URL rejected: Malformed input to a URL function", hit on the very
# first v0.1.0 release) AND would land in latest.json's asset url, which
# the updater's plain HTTP GET can't handle either. Rename on copy.
run: |
set -e
BUNDLE=apps/desktop/src-tauri/target/release/bundle
mkdir -p dist
find "$BUNDLE" \( -name '*.AppImage' -o -name '*.deb' -o -name '*.rpm' \
-o -name '*.AppImage.sig' -o -name '*.deb.sig' -o -name '*.rpm.sig' \) \
-exec cp {} dist/ \;
-print0 | while IFS= read -r -d '' f; do
name=$(basename "$f" | tr ' ' '-')
cp "$f" "dist/${name}"
done
echo "Artifacts:"; ls -la dist/
- name: Assemble latest.json
# The Tauri updater fetches a manifest describing the newest version, its
# notes, and per-target {signature, url}. We point the AppImage target at
# this release's asset URL. Adjust the platform keys you actually ship.
# 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.
env:
SERVER_URL: ${{ github.server_url }}
REPO: ${{ github.repository }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
VERSION="${TAG#v}"
APPIMAGE=$(cd dist && ls *.AppImage | head -1)
SIG=$(cat "dist/${APPIMAGE}.sig")
ASSET_URL="${SERVER_URL}/${REPO}/releases/download/${TAG}/${APPIMAGE}"
ASSET_URL="${SERVER_URL}/${MIRROR_REPO}/releases/download/desktop-latest/${APPIMAGE}"
cat > dist/latest.json <<JSON
{
"version": "${VERSION}",
@@ -129,12 +173,12 @@ jobs:
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"draft\":false,\"prerelease\":false}" \
"${API}/repos/${REPO}/releases" || true)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
# Release may already exist for this tag — look it up by tag.
REL_ID=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${REPO}/releases/tags/${TAG}" \
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
| grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
echo "release id: ${REL_ID}"
for f in dist/*; do
@@ -147,3 +191,82 @@ jobs:
"${API}/repos/${REPO}/releases/${REL_ID}/assets?name=${name}" >/dev/null
done
echo "done"
- name: Mirror release to mca/public_releases (Gitea API)
# This is the release the updater and any human downloader actually use —
# public_releases has no source, only installers, so it can be public
# without exposing this repo. RELEASES_MIRROR_TOKEN is a write:repository
# token scoped for pushing releases into that repo (Gitea's org secrets,
# not exposed to any deployed client).
#
# Publishes to TWO tags there, since public_releases is shared across
# apps in the org and Gitea's "latest release" redirect resolves by
# newest tag on the WHOLE repo (would break the moment another app
# publishes something newer):
# - desktop-<TAG> versioned, permanent — audit trail / rollback.
# - desktop-latest moving — assets deleted + re-uploaded each release.
# This is the fixed URL tauri.conf.json's updater endpoint points at
# (a stable name every appliance can always resolve, regardless of
# what else gets released in this repo meanwhile).
env:
TOKEN: ${{ secrets.RELEASES_MIRROR_TOKEN }}
API: ${{ github.api_url }}
MIRROR_REPO: mca/public_releases
TAG: ${{ github.ref_name }}
run: |
set -e
create_or_get_release() {
local mirror_tag="$1" prerelease="$2"
REL=$(curl -sS -w '\n%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"${mirror_tag}\",\"name\":\"Parking System ${TAG}\",\"draft\":false,\"prerelease\":${prerelease}}" \
"${API}/repos/${MIRROR_REPO}/releases" || true)
echo "create response (${mirror_tag}): ${REL}"
REL_ID=$(printf '%s' "$REL" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
if [ -z "$REL_ID" ]; then
LOOKUP=$(curl -sS -w '\n%{http_code}' -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/tags/${mirror_tag}")
echo "tag lookup response (${mirror_tag}): ${LOOKUP}"
REL_ID=$(printf '%s' "$LOOKUP" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2 || true)
fi
if [ -z "$REL_ID" ]; then
echo "::error::could not create or find release for tag ${mirror_tag} on ${MIRROR_REPO} — see responses above"
exit 1
fi
}
upload_assets() {
local rel_id="$1"
for f in dist/*; do
name=$(basename "$f")
echo "mirroring ${name} -> release ${rel_id}"
HTTP_CODE=$(curl -sS -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"${f}" \
"${API}/repos/${MIRROR_REPO}/releases/${rel_id}/assets?name=${name}")
if [ "$HTTP_CODE" -ge 300 ]; then
echo "::error::upload of ${name} failed (HTTP ${HTTP_CODE}): $(cat /tmp/upload_resp.json)"
exit 1
fi
done
}
# 1. Versioned, permanent.
create_or_get_release "desktop-${TAG}" false
echo "versioned mirror release id: ${REL_ID}"
upload_assets "${REL_ID}"
# 2. Moving desktop-latest — delete existing assets first (re-upload
# with the same name 409s otherwise), then re-upload.
create_or_get_release "desktop-latest" false
LATEST_REL_ID="${REL_ID}"
echo "latest mirror release id: ${LATEST_REL_ID}"
EXISTING=$(curl -sS -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets")
printf '%s' "$EXISTING" | grep -o '"id":[0-9]*' | cut -d: -f2 | while read -r asset_id; do
curl -sS -X DELETE -H "Authorization: token ${TOKEN}" \
"${API}/repos/${MIRROR_REPO}/releases/${LATEST_REL_ID}/assets/${asset_id}" >/dev/null
done || true
upload_assets "${LATEST_REL_ID}"
echo "done"
+11 -3
View File
@@ -35,8 +35,16 @@ pnpm --filter @parking/desktop bundle # build the SPA + bundle the desktop app
Requires the Rust toolchain and (on Linux) WebKitGTK 4.1 + libsoup-3 dev libraries. Under WSL2 the
window needs a display (WSLg or an X server).
## Auto-update
Signed updates are built and published by `.gitea/workflows/release.yml` on a `vX.Y.Z` tag, mirrored
to the public `mca/public_releases` repo (this repo is private; the updater runs on offline-first
field appliances with no Gitea credentials, so its endpoint must be reachable unauthenticated —
see that workflow's header and `wiki/decisions/desktop-shell-tauri.md`). The updater config and
signing pubkey live in `tauri.conf.json`; the private signing key is held outside the repo, never
committed.
## Not here (deliberately)
Kiosk lockdown (fullscreen/no-decorations), auto-update, code signing, and launching Fastify from
the shell are out of scope for the scaffold — on the appliance Fastify runs as its own service and
this shell connects to it.
Kiosk lockdown (fullscreen/no-decorations) and launching Fastify from the shell are out of scope for
the scaffold — on the appliance Fastify runs as its own service and this shell connects to it.
+4 -4
View File
@@ -1,13 +1,13 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Parking System",
"version": "0.0.0",
"version": "0.1.0",
"identifier": "com.parking.desktop",
"build": {
"devUrl": "http://localhost:5173",
"frontendDist": "../../web/dist",
"beforeDevCommand": "pnpm --filter @parking/web dev",
"beforeBuildCommand": "pnpm --filter @parking/web build"
"beforeBuildCommand": "VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter @parking/web build"
},
"app": {
"windows": [
@@ -41,9 +41,9 @@
},
"plugins": {
"updater": {
"//": "Stable 'latest release' path on Gitea — redirects to the newest tag's latest.json (published by .gitea/workflows/release.yml). The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"//": "Points at mca/public_releases, NOT this (private, source) repo — the updater runs on offline-first field appliances with no Gitea credentials, so the endpoint must be reachable unauthenticated. That repo is public and holds only compiled installers (no source), mirrored here by .gitea/workflows/release.yml. NOT the 'latest release' redirect: public_releases is shared across apps in the org, so 'latest' there could be someone else's release. This URL names our own most-recent tag directly (desktop-vX.Y.Z, bumped by the release workflow each publish) so a newer unrelated app release never shadows ours. The updater GETs this, gets the manifest (platforms.linux-x86_64.{signature,url}), and compares versions. The release is reachable to the appliance only when it's brought online (phone hotspot); offline-first means a failed check is a no-op.",
"endpoints": [
"https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json"
"https://git.infra.msai.al/mca/public_releases/releases/download/desktop-latest/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgxNzg5RUQ1QkM0Q0FDRjYKUldUMnJFeTgxWjU0Z1RlNmhneDVZQlVVTVZZdGhJTkUxTGdDeGYwQSttZmNKVVp5WEdVMWlBb1YK"
}
+13 -2
View File
@@ -41,11 +41,22 @@ export async function checkForDesktopUpdate(
// Download + install the signed update (signature verified against the
// pubkey in tauri.conf.json), then relaunch into the new version.
try {
await update.downloadAndInstall();
} catch (err) {
// A real update WAS found and accepted — this is a genuine install
// failure (bad signature, corrupted download, disk/permission issue),
// not "offline". Surface it instead of silently reverting to the old
// version with no explanation.
console.error("desktop update download/install failed:", err);
throw err;
}
const { relaunch } = await import("@tauri-apps/plugin-process");
await relaunch();
} catch {
} catch (err) {
// Offline / endpoint unreachable / no update server yet → ignore. The app
// keeps running on the current version; checking again next launch.
// keeps running on the current version; checking again next launch. Still
// log it so a real install failure (rethrown above) isn't invisible.
console.warn("desktop update check/apply skipped:", err);
}
}
+29 -14
View File
@@ -2,7 +2,7 @@
type: reference
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
sources: []
updated: 2026-08-30
updated: 2026-09-02
status: settled
---
@@ -290,19 +290,30 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
NOT `:9120` — Core's container port `9120` is exposed-not-published; the agent reaches it through
the proxy. (Gotcha #7 below.)
- Config lands at `~/.config/komodo/periphery.config.toml`. The key field is **`core_address`**
(singular); `root_directory` must be a path `admin` can write. **⚠ VERIFY THIS after install —
Periphery v2.2.0's installer writes `root_directory = "/etc/komodo"` even with `--user`**
(bit the lab box 2026-07-07: panic `Failed to write private key pem to "/etc/komodo/keys/
periphery.key" … Permission denied`, crash-loop until systemd gives up). Fix + restart:
```bash
sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
systemctl --user reset-failed periphery && systemctl --user restart periphery
```
NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
`systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
(singular).
Verify: `systemctl --user status periphery` → active; the server **`park-buzi`** appears and goes
**OK/green** in Core → Servers. Then **delete the onboarding key**.
> ⚠ **ALWAYS CHECK THIS — every install so far has hit it (lab box 2026-07-07, booth `park-2`
> 2026-09-02).** `root_directory` must be a path `admin` can write, but **Periphery's installer
> writes `root_directory = "/etc/komodo"` even with `--user`** (still true as of v2.3.3). Result:
> panic `Failed to write private key pem to "/etc/komodo/keys/periphery.key" … Permission denied`,
> crash-loop until systemd gives up (`Start request repeated too quickly`).
>
> **Fix + restart:**
> ```bash
> sed -i 's|^root_directory = .*|root_directory = "'"$HOME"'/.komodo"|' ~/.config/komodo/periphery.config.toml
> systemctl --user reset-failed periphery && systemctl --user restart periphery
> ```
> NB `sudo systemctl restart periphery` says *unit not found* — it's a USER unit; always
> `systemctl --user …`. The onboarding key survives a pre-connect crash (unused until first dial).
>
> **➜ Do not stop here once it's green.** This fix only gets Periphery *running* — the Stack still
> isn't deployed. Immediately continue to **verify below, then §7b**.
**Verify:** `systemctl --user status periphery` → active; the server **`park-buzi`** appears and
goes **OK/green** in Core → Servers. Then **delete the onboarding key**.
**➜ Next step is §7b below — the Stack itself is not deployed yet.** A green Server in Core just
means the agent connected; it runs nothing until you add the Registry/Git accounts and deploy.
### 7b. Deploy the Stack (in Core — by hand once, then code)
@@ -485,7 +496,11 @@ works; the desktop app is a separate workstream.
separate Komodo credentials. A blank registry account on the Stack → anonymous pull →
`no basic auth credentials`. Set the Stack's **Registry Account** (`komodo`).
9. **User-mode Periphery + `/etc/komodo` `root_directory` = `Permission denied`** writing the agent
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`.
key. User-mode (runs as `admin`, no root daemon) must keep `root_directory` under `$HOME`. Hit
on every install so far (lab box 2026-07-07, booth `park-2` 2026-09-02, still on v2.3.3) —
**check this first** whenever a fresh Periphery install crash-loops; see the boxed callout in
§7a for the fix. Easy to fix-and-move-on without realizing the Stack still isn't deployed —
§7a's fix only starts the agent, §7b deploys the Stack.
10. The config key is **`core_address`** (singular). And `--core-address` derives `wss://` from
`https://` — if Core were plain-HTTP you'd need `http://` (→ `ws://`).
11. ResourceSync **Execute disabled + file shown clean in Info = empty diff = already in sync**
+69 -13
View File
@@ -2,7 +2,7 @@
type: decision
tags: [parking, decisions, desktop, frontend]
sources: []
updated: 2026-06-21
updated: 2026-09-03
status: settled
---
@@ -140,20 +140,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
- **Right-click:** the context menu is blocked in **prod only** (`apps/web/src/lib/kiosk.ts`,
guarded on `import.meta.env.PROD`); dev keeps right-click + devtools. Applies to both the browser
prod build and the desktop build (same SPA).
- **`VITE_API_BASE` wired to the environment:** `apps/web/.env.production` (committed, non-secret,
allow-listed in `.gitignore`) sets `VITE_API_BASE=http://127.0.0.1:3000`, auto-loaded by
`vite build` (which the desktop bundle runs). So the desktop build targets Fastify with no manual
export; the browser-served-by-Fastify build should override to `""`.
- **`VITE_API_BASE` — desktop vs. browser (regression found + fixed 2026-09-03):**
`apps/web/.env.production` (committed, shared by both builds) sets `VITE_API_BASE=` (empty) — this
is correct for the **browser/booth** build (Fastify same-origin, stays relative) since commit
`96fd97e` (2026-06-27), but that same change silently broke the **desktop** build, which was never
given its own override. Result: the desktop shell's `apiUrl()` returned a bare relative path
(`/api/auth/login`) to `fetch()` from a page loaded at `tauri://localhost` — WebKitGTK has no base
to resolve a relative URL against from a non-`http(s)` origin, and threw `DOMException: "The
string did not match the expected pattern."` on the first authenticated request (login). Login
worked fine in the browser (same-origin, no absolute URL needed) the whole time, which is what
made this easy to miss. **Fix:** `tauri.conf.json`'s `build.beforeBuildCommand` now sets
`VITE_API_BASE=http://127.0.0.1:3000` inline (`VITE_API_BASE=http://127.0.0.1:3000 pnpm --filter
@parking/web build`) — process env vars override `.env.production` in Vite's load order, so this
overrides the shared file for the desktop build only, without touching it (the browser/booth build
still gets the empty value, unaffected). Verified: rebuilding with the override bakes
`127.0.0.1:3000` into the bundle; rebuilding without it stays clean/relative.
- **Auto-update (prompt-on-update, self-hosted):** `tauri-plugin-updater` + `tauri-plugin-process`.
On launch the SPA checks the endpoint (`apps/web/src/lib/desktop-updater.ts`, no-op in browser /
offline), prompts the operator (i18n `update.prompt`), then `downloadAndInstall()` + `relaunch()`.
Accepts that the appliance may be **offline** day-to-day and brought online (phone hotspot) only
when an update is wanted — consistent with [[offline-first]] (no network dependency in *core*
operation; updates are out-of-band). Endpoint is the **self-hosted Gitea** "latest release"
path — `https://git.infra.msai.al/mca/parking_solution/releases/latest/download/latest.json`
— which redirects to the newest tag's `latest.json` (published by `.gitea/workflows/release.yml`).
The updater GETs it (200 + manifest, or 204 = up-to-date), reads `platforms.linux-x86_64.
{signature,url}`, and downloads the signed installer. **WS origin:** the desktop window's origin
operation; updates are out-of-band). **WS origin:** the desktop window's origin
is `tauri://localhost` (Linux may also send `http://tauri.localhost`), so the backend's
`WS_ALLOWED_ORIGINS` must include both or the live feed won't connect (documented in
`apps/server/.env.example`).
@@ -165,8 +172,27 @@ Per the user's choices — the operator **keeps OS access** (no fullscreen lockd
produced `.deb`/`.rpm`/`.AppImage` **plus their `.sig` updater signatures**; full `turbo run build
lint` 14/14 green. *(This is the **updater** signing — distinct from OS-installer signing for
Windows/macOS "unknown publisher", and from the [[atecc608]]/[[tpm]] **event** signing.)*
- **Still deferred:** the actual update-hosting URL, OS-level installer signing
(Windows/macOS publisher trust), and the Windows kiosk-browser fallback path.
- **Update-hosting endpoint (found broken, fixed 2026-09-03):** the endpoint originally pointed at
the **source repo's own** Gitea "latest release" redirect
(`.../mca/parking_solution/releases/latest/download/latest.json`) — but `mca/parking_solution` is
**private**, and the updater runs on offline-first field appliances with **no Gitea credentials**.
Every deployed update check was silently failing (swallowed by a `try/catch` in
`desktop-updater.ts`) — this was never field-verified, and it couldn't have worked as configured.
**Fix:** signed installers are now mirrored to a separate **public**, releases-only repo,
`mca/public_releases` (shared across apps in the org — see [[fleet-deployment-komodo]] sibling
infra), holding **only compiled installers, no source**. `tauri.conf.json`'s endpoint now points
there at a fixed `desktop-latest` tag (NOT that repo's generic "latest release" redirect, since
other apps publishing there would shadow ours — see the `desktop-latest` vs `desktop-<TAG>`
split below). `.gitea/workflows/release.yml` pushes to both repos: the private source repo (own
record) and the public mirror (what the updater and any human downloader actually use).
**Rejected alternative:** embedding a `read:repository` Gitea token in `tauri.conf.json`'s
updater `headers` so it could read the private repo directly — ruled out because that token would
ship inside every installed binary in the field, and this appliance's own threat model names the
**booth operator as the primary adversary** (see root `CLAUDE.md`); a leaked token scoped to the
whole private repo, with no cheap way to rotate it across appliances already in the field, was
judged worse than publishing installers-only.
- **Still deferred:** OS-level installer signing (Windows/macOS publisher trust) and the Windows
kiosk-browser fallback path.
### Desktop in CI — two workflows, two purposes (added 2026-06-24)
@@ -174,7 +200,15 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
- **`.gitea/workflows/release.yml`** (tag `v*`) — the **signed, versioned release**: builds
`.deb`/`.rpm`/`.AppImage` **+ their `.sig`** (updater key from secrets), assembles `latest.json`,
and publishes a Gitea Release. This is what the auto-updater consumes. Unchanged.
and publishes a Gitea Release **on `mca/parking_solution` (source, own record) AND mirrors it to
`mca/public_releases`** (public, installers-only — see the update-hosting-endpoint entry above for
why). The mirror step uses a second token, `RELEASES_MIRROR_TOKEN`
(`write:repository`, scoped for pushing into `public_releases` only — a CI-side secret, never
shipped to any client, distinct from the embedded updater *pubkey*). It publishes two tags there:
`desktop-<TAG>` (versioned, permanent, for audit/rollback) and `desktop-latest` (moving — existing
assets deleted then re-uploaded each release, since Gitea has no per-app "latest" concept and this
repo is shared across apps). `latest.json`'s asset URL and `tauri.conf.json`'s updater endpoint
both point at `desktop-latest`. This is what the auto-updater actually consumes.
- **`.gitea/workflows/build-desktop.yml`** (push to `dev`/`main`) — a **per-commit test build**:
compiles `.deb` + `.AppImage` only (`pnpm --filter @parking/desktop bundle --bundles deb,appimage`)
and publishes them to a **rolling per-branch pre-release** (tag `desktop-<branch>`). **Unsigned** —
@@ -198,3 +232,25 @@ The desktop bundle now runs in CI under **two distinct workflows** — keep the
The unsigned CI build therefore overrides it off with
`--config '{"bundle":{"createUpdaterArtifacts":false}}'` (a JSON patch merged over the config),
so no `.sig` is attempted and no key is required. `release.yml` keeps the config default (signs).
- **Gotcha (tag ≠ tauri.conf.json version — found + fixed 2026-09-03, v0.1.1).** The git tag
(`v0.1.1`) and `tauri.conf.json`'s own `"version"` field are two independent values with nothing
syncing them. Tauri bakes `"version"` into the bundle filename, the app's internal version, AND
what the updater compares against — NOT the git tag. Bumping only the tag (as the release
procedure implied) left the file at the prior `0.1.0`: the signed binary was built and named as
`0.1.0`, `latest.json` (built from `TAG`) correctly claimed `0.1.1`, and the updater found an
"update," downloaded it, then failed signature verification against a manifest that didn't
actually describe the file it pointed at. Compounded by a second bug (below) that made this
failure completely invisible to the operator. **Fix:** `release.yml` now has a "Sync
tauri.conf.json version to the git tag" step (`sed`-patches `"version"` from `TAG` right before
`tauri build`) — the checked-in value is now only a placeholder for local dev builds; every real
release derives its version from the tag automatically.
- **Gotcha (silent updater failure — found + fixed 2026-09-03).** `desktop-updater.ts`'s
`checkForDesktopUpdate` wrapped the ENTIRE check-download-install-relaunch sequence in one
`catch {}` that swallowed everything, by design, for the offline/no-server case. But that meant
a REAL failure after the operator already accepted the prompt (bad signature, corrupted
download, disk/permission error) failed exactly the same way as "endpoint unreachable" — no
error, no log, the app just silently reverted to the old version and re-showed the same "update
available" prompt on next launch, forever. This is what actually surfaced the tag-sync bug
above (download traffic visible, then nothing). Fixed by nesting `downloadAndInstall()` in its
own try/catch that logs and rethrows — offline/no-update still no-ops silently (outer catch),
but a failure *after* the operator accepted now logs to the console instead of vanishing.
+1 -1
View File
@@ -134,7 +134,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[vision-service]] — build a host-side ANPR + vehicle-verification service; replaces edge-LPR; scoped AGPL exception.
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell.
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
- [[appliance-provisioning]] — booth-PC provisioning runbook (Dell 7070, i5-8500, discrete Nuvoton TPM): BIOS/Secure-Boot → direct-flash Ubuntu 26.04 USB (not Ventoy) → passphrase-LUKS install → manual PCR-7 TPM seal (workaround for the installer's dbt PCR_UNUSABLE error) → Docker. Verified on hardware 2026-06-23; TPM auto-unlock works.
+42
View File
@@ -2725,3 +2725,45 @@ re-running the same installer with unchanged `--connect-as` is config-preserving
skips rewriting an existing config) and safe; verified dry-run on `art-docker-station` (lab) then
applied to `park-buzi` (live booth) with no disruption to the running app containers. Full detail
+ exact commands on [[appliance-provisioning]].
## [2026-09-03] fix | Desktop updater endpoint was unreachable — pointed at a private repo
The Tauri auto-updater ([[desktop-shell-tauri]]) was fully implemented — signed builds, keypair,
`latest.json`, `release.yml` — but its endpoint pointed at `mca/parking_solution`'s own Gitea
"latest release" redirect, and that repo is **private**. Field appliances have no Gitea
credentials, so every update check was silently failing (caught by a `try/catch`); this was never
actually field-verified end to end. Fix: signed installers now mirror to a new public,
installers-only repo `mca/public_releases` (org-shared, not parking-specific), published to a fixed
`desktop-latest` tag so other apps releasing there later can't shadow ours. Considered and rejected
embedding a `read:repository` token in the app instead — ruled out given the appliance's own threat
model (booth operator as primary adversary) makes an extractable, hard-to-rotate credential in every
deployed binary worse than just publishing installers publicly. `release.yml`,
`apps/desktop/src-tauri/tauri.conf.json`, `apps/desktop/README.md` updated; full detail on
[[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop login broken by a VITE_API_BASE regression from the booth same-origin fix
The 2026-06-27 booth fix (commit 96fd97e) correctly blanked `apps/web/.env.production`'s
`VITE_API_BASE` for the browser/booth same-origin case, but the desktop build shares that same
file and was never given its own override — the desktop shell has been building with an empty
API base since that commit, unnoticed until now. Symptom: login threw `DOMException: "The string
did not match the expected pattern."` — WebKitGTK rejecting a relative `fetch()` URL with no base
to resolve against, since the desktop window's origin is `tauri://localhost`. Browser login was
unaffected (same-origin, no absolute URL needed), which is why this went unnoticed through the CI
mirror-repo debugging session. Fixed by setting `VITE_API_BASE=http://127.0.0.1:3000` inline in
`tauri.conf.json`'s `beforeBuildCommand`, overriding the shared `.env.production` for the desktop
build only (process env wins in Vite's load order) — verified both builds independently. Full
detail on [[desktop-shell-tauri]].
## [2026-09-03] fix | Desktop updater silently failed: tag/version drift + swallowed install errors
Two compounding bugs, both closed out on [[desktop-shell-tauri]] §"Desktop in CI": (1) the v0.1.1
release bumped only the git tag — tauri.conf.json's own "version" field (what Tauri actually bakes
into the bundle filename and internal version) stayed at 0.1.0, so the signed binary didn't match
what latest.json claimed to describe, and signature verification failed on every download; (2)
desktop-updater.ts's single blanket try/catch swallowed that failure identically to "offline/no
update," so the operator saw the prompt, watched it download, then nothing — repeating forever with
zero diagnostic trail. Fixed release.yml to sed-patch tauri.conf.json's version from the git tag
right before building (checked-in value is now dev-only, never hand-maintained for releases), and
split desktop-updater.ts's catch so a real post-accept failure logs instead of vanishing. Full
detail on [[desktop-shell-tauri]].