Compare commits
7 Commits
0fd66b261a
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 276b048fa9 | |||
| faa3265e49 | |||
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 |
+135
-12
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
await update.downloadAndInstall();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
@@ -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
@@ -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]].
|
||||
|
||||
Reference in New Issue
Block a user