Compare commits
20 Commits
28bd838696
..
v0.1.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 7317042e8d | |||
| 439b11d16d | |||
| 276b048fa9 | |||
| faa3265e49 | |||
| 21bfdce27a | |||
| d3288e29eb | |||
| baf7a4a99d | |||
| 885b410e48 | |||
| a1f3103a76 | |||
| 0fd66b261a | |||
| dfc5a07c10 | |||
| 5aabd7a791 | |||
| 0e9b9f5d82 | |||
| 642c5f4f70 | |||
| cb9f4d4979 | |||
| ea8fe22969 | |||
| 2910672b5a | |||
| 3a176c5cc8 | |||
| 19dff97c74 | |||
| 0ed43239c3 |
@@ -92,6 +92,8 @@ jobs:
|
||||
context: .
|
||||
file: apps/server/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
BUILD_VERSION=${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}
|
||||
${{ env.REGISTRY }}/parking-server:${{ steps.meta.outputs.branch }}-${{ steps.meta.outputs.sha }}
|
||||
|
||||
+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"
|
||||
|
||||
@@ -27,3 +27,4 @@ dist/
|
||||
# Graphify knowledge-graph output (dev tool; generated, not committed)
|
||||
graphify-out/
|
||||
parking.sqlite*.bak-*
|
||||
questions.txt
|
||||
|
||||
+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.
|
||||
|
||||
Generated
+549
-8
@@ -318,6 +318,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@@ -346,10 +363,39 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -373,7 +419,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
@@ -386,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -399,6 +445,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -506,6 +561,18 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -635,6 +702,15 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.27.0"
|
||||
@@ -721,6 +797,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1034,8 +1119,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1057,8 +1144,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1209,6 +1299,25 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.14.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1298,6 +1407,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
@@ -1321,6 +1431,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1341,9 +1452,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1744,6 +1857,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1759,6 +1878,12 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2178,8 +2303,10 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2330,6 +2457,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2398,6 +2534,22 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -2407,6 +2559,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -2428,6 +2636,61 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2503,6 +2766,49 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@@ -2616,6 +2922,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -2625,7 +2932,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
@@ -2663,6 +2970,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -2745,7 +3058,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -2885,6 +3198,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@@ -2948,6 +3273,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -2955,7 +3291,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -3137,6 +3473,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -3157,6 +3504,27 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -3178,7 +3546,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -3268,7 +3636,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -3367,6 +3735,54 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"log",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7241a0c762649be8fba7dd4cc84684d0e409f26b335a978ef4dd5fe78da74ce6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"url",
|
||||
"urlpattern",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-process"
|
||||
version = "2.3.1"
|
||||
@@ -3393,7 +3809,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@@ -3410,6 +3826,26 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-websocket"
|
||||
version = "2.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca243c7f0bf935cd81123e07f82188ccb919b19fbfc74518b947eedc4619bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -3639,9 +4075,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -3652,6 +4100,22 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tungstenite",
|
||||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -3877,6 +4341,24 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -4141,6 +4623,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@@ -4206,6 +4698,24 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||
dependencies = [
|
||||
"webpki-roots 1.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -4391,6 +4901,17 @@ dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@@ -4820,6 +5341,26 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.56"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
|
||||
@@ -22,6 +22,17 @@ serde_json = "1"
|
||||
# Auto-update: prompt the operator, download a signed update, relaunch.
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
# HTTP client for the SPA's API/WS calls to the local Fastify server. The window
|
||||
# runs at tauri://localhost, which WebKitGTK treats as a secure origin — a plain
|
||||
# http://127.0.0.1:3000 fetch() from inside it is blocked as mixed content (a
|
||||
# long-standing WebKit limitation, not fixable via CSP). Routing through this
|
||||
# plugin sends the request via Tauri's Rust side instead of the webview's own
|
||||
# fetch, sidestepping the browser mixed-content check entirely.
|
||||
tauri-plugin-http = "2"
|
||||
# Same mixed-content problem as above, but for the live-feed WebSocket
|
||||
# (ws://127.0.0.1:3000 from the secure tauri://localhost origin) — HTTP and WS
|
||||
# are separate browser checks, so this needs its own plugin.
|
||||
tauri-plugin-websocket = "2"
|
||||
|
||||
[features]
|
||||
# Used by `tauri dev`/CLI for hot-reload of the Rust side.
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"updater:default",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"websocket:default",
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "http://127.0.0.1:3000" },
|
||||
{ "url": "http://localhost:3000" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ pub fn run() {
|
||||
// endpoint + signing pubkey live in tauri.conf.json.
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
// Routes the SPA's fetch()/WS calls to the local Fastify server through
|
||||
// Tauri's native HTTP client — see the Cargo.toml comment on why the
|
||||
// webview's own fetch() can't reach http://127.0.0.1:3000 directly.
|
||||
.plugin(tauri_plugin_http::init())
|
||||
// Live-feed WebSocket — same mixed-content reason as the HTTP plugin
|
||||
// above, but WS needs its own plugin (separate browser check).
|
||||
.plugin(tauri_plugin_websocket::init())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running the Parking System desktop shell");
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -47,6 +47,11 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
# ---- runtime: slim, non-root ----
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
# Set by CI to "<branch>-<short-sha>" (e.g. "stage-28bd838"), matching the same string used
|
||||
# as the Komodo Stack's TAG (komodo/resources.toml) — so the version shown in the app is the
|
||||
# same string an admin would look up there. Empty/absent on a local `docker build` (dev only).
|
||||
ARG BUILD_VERSION=""
|
||||
ENV BUILD_VERSION=$BUILD_VERSION
|
||||
ENV NODE_ENV=production
|
||||
RUN apk add --no-cache libstdc++ # better-sqlite3 native runtime
|
||||
RUN addgroup -S app && adduser -S -G app app
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq, siteConfig } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
|
||||
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
let workDir: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||
target = join(workDir, "target");
|
||||
process.env.BACKUP_KEY = KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
delete process.env.BACKUP_KEY;
|
||||
});
|
||||
|
||||
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackupService — persisted status survives a restart", () => {
|
||||
it("a fresh instance sees the previous instance's last success", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
expect(first.status().lastSuccessAt).toBeNull();
|
||||
const result = await first.run("manual");
|
||||
|
||||
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||
// production this would be a fresh process re-opening the same sqlite file).
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastSuccessAt).not.toBeNull();
|
||||
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||
expect(status.lastError).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||
const t = createTestDb();
|
||||
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||
const badTarget = join(workDir, "not-a-dir");
|
||||
writeFileSync(badTarget, "x");
|
||||
setTargetDir(t.db, badTarget);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
await expect(first.run("manual")).rejects.toThrow();
|
||||
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastError).not.toBeNull();
|
||||
expect(status.lastErrorAt).not.toBeNull();
|
||||
expect(status.lastSuccessAt).toBeNull();
|
||||
|
||||
// Now point at a real directory and succeed — the persisted error must clear.
|
||||
setTargetDir(t.db, target);
|
||||
await second.run("manual");
|
||||
const third = new BackupService(t.db);
|
||||
const finalStatus = third.status();
|
||||
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||
expect(finalStatus.lastError).toBeNull();
|
||||
expect(finalStatus.lastErrorAt).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||
it("is due immediately when no success has ever been recorded", () => {
|
||||
const t = createTestDb();
|
||||
const svc = new BackupService(t.db);
|
||||
expect(svc.isDue()).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const first = new BackupService(t.db);
|
||||
await first.run("manual");
|
||||
|
||||
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||
const second = new BackupService(t.db);
|
||||
expect(second.isDue()).toBe(false);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
|
||||
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||
|
||||
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||
expect(svc.isDue(overADayLater)).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
const afterFirst = svc.status().lastSuccessAt;
|
||||
|
||||
await svc.runScheduled(); // not due yet — must not run again
|
||||
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,12 @@ import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRete
|
||||
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
||||
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
||||
// wiki/concepts/backup-recovery.md.
|
||||
//
|
||||
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
||||
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
||||
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
||||
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
||||
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
||||
|
||||
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
||||
export function backupKeyFromEnv(): string {
|
||||
@@ -65,16 +71,33 @@ export class BackupService {
|
||||
readonly #logger?: FastifyBaseLogger;
|
||||
|
||||
#running = false;
|
||||
#lastSuccessAt: string | null = null;
|
||||
#lastResult: BackupResult | null = null;
|
||||
#lastErrorAt: string | null = null;
|
||||
#lastError: string | null = null;
|
||||
|
||||
constructor(db: Db, logger?: FastifyBaseLogger) {
|
||||
this.#db = db;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
||||
* or reset on restart). */
|
||||
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
||||
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
}
|
||||
|
||||
#persist(patch: {
|
||||
backupLastSuccessAt?: string | null;
|
||||
backupLastResultJson?: string | null;
|
||||
backupLastErrorAt?: string | null;
|
||||
backupLastError?: string | null;
|
||||
}): void {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const existing = this.#row();
|
||||
if (existing) {
|
||||
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
||||
}
|
||||
}
|
||||
|
||||
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
||||
targetDir(): string | null {
|
||||
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
@@ -104,6 +127,15 @@ export class BackupService {
|
||||
|
||||
status(): BackupStatus {
|
||||
const r = this.retention();
|
||||
const row = this.#row();
|
||||
let lastResult: BackupStatus["lastResult"] = null;
|
||||
if (row?.backupLastResultJson) {
|
||||
try {
|
||||
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
||||
} catch {
|
||||
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
||||
}
|
||||
}
|
||||
return {
|
||||
configured: this.configured,
|
||||
targetDir: this.targetDir(),
|
||||
@@ -111,12 +143,10 @@ export class BackupService {
|
||||
keepDailyDays: r.keepDailyDays,
|
||||
keyPresent: this.keyPresent,
|
||||
running: this.#running,
|
||||
lastSuccessAt: this.#lastSuccessAt,
|
||||
lastResult: this.#lastResult
|
||||
? { path: this.#lastResult.path, bytes: this.#lastResult.bytes, prunedFiles: this.#lastResult.prunedFiles }
|
||||
: null,
|
||||
lastErrorAt: this.#lastErrorAt,
|
||||
lastError: this.#lastError,
|
||||
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
||||
lastResult,
|
||||
lastErrorAt: row?.backupLastErrorAt ?? null,
|
||||
lastError: row?.backupLastError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,14 +169,17 @@ export class BackupService {
|
||||
try {
|
||||
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
||||
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
||||
this.#lastResult = res;
|
||||
this.#lastSuccessAt = new Date().toISOString();
|
||||
this.#lastError = null;
|
||||
this.#persist({
|
||||
backupLastSuccessAt: new Date().toISOString(),
|
||||
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
||||
backupLastErrorAt: null,
|
||||
backupLastError: null,
|
||||
});
|
||||
return res;
|
||||
} catch (err) {
|
||||
this.#lastError = (err as Error).message;
|
||||
this.#lastErrorAt = new Date().toISOString();
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${this.#lastError}`);
|
||||
const message = (err as Error).message;
|
||||
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
||||
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
this.#running = false;
|
||||
@@ -156,13 +189,34 @@ export class BackupService {
|
||||
return this.#inflight;
|
||||
}
|
||||
|
||||
/** Scheduled-run wrapper: never throws (a timer must not crash the process). */
|
||||
/**
|
||||
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
||||
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
||||
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
||||
* frequent backups.
|
||||
*/
|
||||
async runScheduled(): Promise<void> {
|
||||
if (!this.configured) return; // silent no-op when backups aren't set up
|
||||
if (!this.isDue()) return;
|
||||
try {
|
||||
await this.run("scheduled");
|
||||
} catch {
|
||||
/* recorded in last-error; already logged */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
||||
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
||||
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
||||
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
||||
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
||||
*/
|
||||
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
||||
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
||||
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
||||
const last = new Date(lastSuccessAt).getTime();
|
||||
if (Number.isNaN(last)) return true;
|
||||
return now.getTime() - last >= intervalMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,37 @@ describe("auth guard — no token", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/version", () => {
|
||||
it("without a session is 401", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/version" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("a site:read user gets the BUILD_VERSION env var, null when unset", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer2", roleId: "viewer2", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ buildVersion: null }); // no BUILD_VERSION set in the test env
|
||||
});
|
||||
|
||||
it("reflects a real BUILD_VERSION when the env var is set", async () => {
|
||||
process.env.BUILD_VERSION = "stage-abc1234";
|
||||
try {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer3", roleId: "viewer3", permissions: ["site:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/version", headers: { cookie } });
|
||||
expect(res.json()).toEqual({ buildVersion: "stage-abc1234" });
|
||||
} finally {
|
||||
delete process.env.BUILD_VERSION;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RBAC permission gate", () => {
|
||||
it("a site:read-only user can GET occupancy but is 403 on PUT site-config", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
|
||||
@@ -78,6 +78,15 @@ export async function siteRoutes(app: FastifyInstance, db: Db, eventLog?: EventL
|
||||
// Live occupancy: cars inside, capacity, free, full. Any signed-in role.
|
||||
app.get("/api/occupancy", { preHandler: readGuard }, async () => getOccupancy(db));
|
||||
|
||||
// Running build version ("<branch>-<short-sha>", matching the Komodo Stack's TAG in
|
||||
// komodo/resources.toml) — baked in at image build time (apps/server/Dockerfile
|
||||
// BUILD_VERSION ARG), read here from the running process env. null on a local/dev
|
||||
// build with no CI-supplied value. Purely informational (Setup nav display); not
|
||||
// site config, so it isn't stored in site_config.
|
||||
app.get("/api/version", { preHandler: readGuard }, async () => ({
|
||||
buildVersion: process.env.BUILD_VERSION?.trim() || null,
|
||||
}));
|
||||
|
||||
// Read site config (capacity + park metadata).
|
||||
app.get("/api/site-config", { preHandler: readGuard }, async () => {
|
||||
const row = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
|
||||
@@ -336,12 +336,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
void runSnapPrune(); // once at startup
|
||||
app.addHook("onClose", async () => clearInterval(snapPruneTimer));
|
||||
|
||||
// Scheduled encrypted backup — daily, unref'd. A no-op (silent) until BACKUP_TARGET_DIR +
|
||||
// BACKUP_KEY are configured; tolerates an unreachable/unmounted target by recording the
|
||||
// error and trying again next run. NOT run once at startup (a just-booted appliance after a
|
||||
// power cut shouldn't immediately write to a possibly-not-yet-mounted disk; the daily cadence
|
||||
// and the manual button cover it). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 24 * 60 * 60 * 1000);
|
||||
// Scheduled encrypted backup — checked every 15 min, unref'd; `runScheduled()` itself is a
|
||||
// no-op unless a full 24h has actually elapsed since the last PERSISTED success (isDue(), in
|
||||
// backup-service.ts), so this frequent poll does not cause frequent backups. Deliberately
|
||||
// NOT a `setInterval(..., 24h)` measured from process start: that design silently reset its
|
||||
// own countdown on every restart (deploy/crash/OOM/reboot, all routine under `restart:
|
||||
// always`), which could push a day's backup out arbitrarily far AND — before last-success was
|
||||
// persisted — made the admin UI show "Never" despite valid backups already on disk
|
||||
// (2026-08-30 field incident, park-buzi). A short poll against a persisted, wall-clock
|
||||
// timestamp is immune to both restart timing and to any single restart cadence. A no-op
|
||||
// (silent) until BACKUP_TARGET_DIR + BACKUP_KEY are configured; tolerates an
|
||||
// unreachable/unmounted target by recording the error and trying again next check. NOT run
|
||||
// once at startup (a just-booted appliance after a power cut shouldn't immediately write to a
|
||||
// possibly-not-yet-mounted disk). See wiki/concepts/backup-recovery.md.
|
||||
const backupTimer = setInterval(() => void backupService.runScheduled(), 15 * 60 * 1000);
|
||||
backupTimer.unref();
|
||||
app.addHook("onClose", async () => clearInterval(backupTimer));
|
||||
if (backupService.configured) {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-router": "^1.170.16",
|
||||
"@tauri-apps/plugin-http": "^2.5.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-websocket": "^2.3.0",
|
||||
"i18next": "^26.3.1",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
|
||||
+11
-2
@@ -6,7 +6,7 @@
|
||||
// wiki/entities/local-jwt-auth.md.
|
||||
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl } from "./lib/origin.js";
|
||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
@@ -28,7 +28,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers.set(CSRF_HEADER, csrf);
|
||||
}
|
||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
const res = await platformFetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||
if (!res.ok) {
|
||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||
const error = msg.error ?? `${path}: ${res.status}`;
|
||||
@@ -253,6 +253,15 @@ export async function fetchBackupStatus(): Promise<BackupStatus> {
|
||||
return apiFetch("/api/backup/status");
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
/** "<branch>-<short-sha>" baked in at image build time; null on a local/dev build. */
|
||||
buildVersion: string | null;
|
||||
}
|
||||
|
||||
export async function fetchVersion(): Promise<VersionInfo> {
|
||||
return apiFetch("/api/version");
|
||||
}
|
||||
|
||||
export interface BackupConfigPatch {
|
||||
/** "" clears the target. Omit a field to leave it unchanged; null resets retention to default. */
|
||||
targetDir?: string | null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// high-signal sources (failed requests, uncaught errors) are always captured.
|
||||
|
||||
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
|
||||
import { apiUrl, platformFetch } from "./origin.js";
|
||||
|
||||
const ENDPOINT = "/api/logs";
|
||||
const FLUSH_MS = 4000;
|
||||
@@ -76,7 +77,7 @@ async function flush(): Promise<void> {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
const csrf = readCookie(CSRF_COOKIE);
|
||||
if (csrf) headers[CSRF_HEADER] = csrf;
|
||||
await fetch(ENDPOINT, {
|
||||
await platformFetch(apiUrl(ENDPOINT), {
|
||||
method: "POST",
|
||||
headers,
|
||||
credentials: "include",
|
||||
@@ -90,7 +91,10 @@ async function flush(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
|
||||
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). Browser
|
||||
* only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent,
|
||||
* so this drops silently in the desktop shell (unload is rare there; the regular
|
||||
* 4s-interval flush above covers the common case). */
|
||||
function flushBeacon(): void {
|
||||
if (queue.length === 0) return;
|
||||
const entries = queue.splice(0, queue.length);
|
||||
@@ -99,7 +103,7 @@ function flushBeacon(): void {
|
||||
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
|
||||
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
|
||||
// this path degrades to "lost on unload" — acceptable for diagnostics.
|
||||
navigator.sendBeacon(ENDPOINT, blob);
|
||||
navigator.sendBeacon(apiUrl(ENDPOINT), blob);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -10,6 +10,15 @@
|
||||
// Keep this the SINGLE source for the backend origin — api.ts and the live-feed
|
||||
// WebSocket both read it, so the web app and the desktop shell stay identical
|
||||
// except for this one build-time value.
|
||||
//
|
||||
// platformFetch(): WebKitGTK treats tauri://localhost as a SECURE origin, so a
|
||||
// plain http://127.0.0.1:3000 fetch() from inside it is blocked as mixed
|
||||
// content (a WebKit limitation — CSP's connect-src does NOT override this;
|
||||
// found 2026-09-03 as "Load failed" on every desktop request). Inside Tauri we
|
||||
// dynamically import @tauri-apps/plugin-http's fetch, which routes the request
|
||||
// through Tauri's native side instead of the webview's own fetch, sidestepping
|
||||
// the check entirely. Browser build never imports the plugin (dynamic import,
|
||||
// same pattern as desktop-updater.ts).
|
||||
|
||||
/** Backend HTTP origin, no trailing slash. Empty string = same-origin/relative. */
|
||||
export const API_BASE: string = (import.meta.env.VITE_API_BASE ?? "").replace(/\/$/, "");
|
||||
@@ -28,3 +37,22 @@ export function wsUrl(path: string): string {
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}${path}`;
|
||||
}
|
||||
|
||||
/** True when running inside the Tauri webview (not a normal browser). */
|
||||
function inTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch(), but routed through @tauri-apps/plugin-http inside the desktop
|
||||
* shell (see the file header for why the webview's own fetch can't reach
|
||||
* the local backend). Same signature as the global fetch; a plain pass-
|
||||
* through in the browser.
|
||||
*/
|
||||
export async function platformFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
if (inTauri()) {
|
||||
const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
|
||||
return tauriFetch(input, init);
|
||||
}
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Desktop-only WebSocket adapter.
|
||||
//
|
||||
// WebKitGTK treats tauri://localhost as a SECURE origin, so a plain
|
||||
// ws://127.0.0.1:3000 connection from inside it is blocked as mixed content —
|
||||
// same root cause as the HTTP fetch() issue (see origin.ts's platformFetch),
|
||||
// but WS is a separate browser check with its own plugin
|
||||
// (@tauri-apps/plugin-websocket), which routes the connection through Tauri's
|
||||
// native side instead of the webview's own WebSocket.
|
||||
//
|
||||
// That plugin's API is async/listener-based, not the synchronous
|
||||
// onopen/onmessage/onclose event surface use-live-feed.ts is written against
|
||||
// (and has already been hardened for — reconnect backoff, StrictMode
|
||||
// double-invoke, cleanup). Rather than rewrite that hook around a different
|
||||
// API shape, this adapter presents the same native-WebSocket-like interface
|
||||
// use-live-feed.ts already expects, so that hook needs no changes at all.
|
||||
//
|
||||
// Browser build: plain pass-through to the real WebSocket (this file's
|
||||
// createPlatformSocket is only called from inside inTauri() callers).
|
||||
|
||||
export interface PlatformSocket {
|
||||
onopen: (() => void) | null;
|
||||
onmessage: ((ev: { data: string }) => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
class NativeSocketAdapter implements PlatformSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#sock: WebSocket;
|
||||
|
||||
constructor(url: string) {
|
||||
this.#sock = new WebSocket(url);
|
||||
this.#sock.onopen = () => this.onopen?.();
|
||||
this.#sock.onmessage = (ev) => this.onmessage?.({ data: ev.data as string });
|
||||
this.#sock.onclose = () => this.onclose?.();
|
||||
this.#sock.onerror = () => this.onerror?.();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#sock.close();
|
||||
}
|
||||
}
|
||||
|
||||
class TauriSocketAdapter implements PlatformSocket {
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
#closed = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
#conn: any = null;
|
||||
|
||||
constructor(url: string) {
|
||||
void this.#connect(url);
|
||||
}
|
||||
|
||||
async #connect(url: string): Promise<void> {
|
||||
try {
|
||||
const { default: TauriWebSocket } = await import("@tauri-apps/plugin-websocket");
|
||||
if (this.#closed) return; // close() called before connect resolved
|
||||
// Runs on Tauri's native (Rust) side, NOT inside the webview page — there
|
||||
// is no page context to auto-attach an Origin header the way a real
|
||||
// browser WebSocket would. The server's anti-CSWSH check (routes/ws.ts)
|
||||
// rejects any handshake with a missing/mismatched Origin, so it must be
|
||||
// set explicitly here to match what WS_ALLOWED_ORIGINS expects
|
||||
// (tauri://localhost — see apps/server/.env.example).
|
||||
const conn = await TauriWebSocket.connect(url, { headers: { Origin: "tauri://localhost" } });
|
||||
if (this.#closed) {
|
||||
void conn.disconnect();
|
||||
return;
|
||||
}
|
||||
this.#conn = conn;
|
||||
conn.addListener((msg: { type: string; data: unknown }) => {
|
||||
if (msg.type === "Text") {
|
||||
this.onmessage?.({ data: msg.data as string });
|
||||
} else if (msg.type === "Close") {
|
||||
this.onclose?.();
|
||||
}
|
||||
// Binary/Ping/Pong: the server protocol here is text-JSON only (see
|
||||
// routes/ws.ts) — nothing else is expected.
|
||||
});
|
||||
this.onopen?.();
|
||||
} catch (err) {
|
||||
console.error("Tauri WebSocket connect failed:", url, err);
|
||||
this.onerror?.();
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.#closed = true;
|
||||
void this.#conn?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** True when running inside the Tauri webview (not a normal browser). */
|
||||
function inTauri(): boolean {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
}
|
||||
|
||||
/** Open a live-feed socket, routed through the Tauri WebSocket plugin inside the
|
||||
* desktop shell (mixed-content workaround), or the native WebSocket in a browser. */
|
||||
export function createPlatformSocket(url: string): PlatformSocket {
|
||||
return inTauri() ? new TauriSocketAdapter(url) : new NativeSocketAdapter(url);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { DeviceStatus, LedgerEvent, Occupancy } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
import { useLiveStore, type LaneStatus, type LanePresence } from "./live-store.js";
|
||||
import { wsUrl } from "./origin.js";
|
||||
import { createPlatformSocket, type PlatformSocket } from "./platform-ws.js";
|
||||
|
||||
// Booth WebSocket client. Opens ONE socket to /api/ws and turns server pushes into
|
||||
// (a) live-store updates for the ticker/occupancy and (b) Query cache invalidations
|
||||
@@ -23,23 +24,34 @@ type WsMessage =
|
||||
| { kind: "plate-recognized"; plate: { identity: string; plate: string; direction: "entry" | "exit" } };
|
||||
|
||||
|
||||
export function useLiveFeed(): void {
|
||||
/**
|
||||
* @param enabled Gate on the WATCHER permission (`report:read` — mirrors the server's
|
||||
* WS guard in routes/ws.ts). A user whose role lacks it (e.g. a merchant validator
|
||||
* with only `validation:create`) must not attempt the socket at all: the server
|
||||
* 403s the upgrade and the capped-backoff reconnect would otherwise hammer it
|
||||
* forever, filling the server log with a 403 every few seconds.
|
||||
*/
|
||||
export function useLiveFeed(enabled: boolean = true): void {
|
||||
const qc = useQueryClient();
|
||||
const { setStatus, setOccupancy, pushEvent, setDevices, upsertDevice, setLanes, setRadar, patchPlate } =
|
||||
useLiveStore();
|
||||
// Hold the socket + reconnect timer across renders; guard against StrictMode
|
||||
// double-invoke and unmount.
|
||||
const sockRef = useRef<WebSocket | null>(null);
|
||||
const sockRef = useRef<PlatformSocket | null>(null);
|
||||
const retryRef = useRef(0);
|
||||
const closedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setStatus("closed");
|
||||
return;
|
||||
}
|
||||
closedRef.current = false;
|
||||
|
||||
const connect = () => {
|
||||
if (closedRef.current) return;
|
||||
setStatus(retryRef.current === 0 ? "connecting" : "connecting");
|
||||
const sock = new WebSocket(wsUrl("/api/ws"));
|
||||
const sock = createPlatformSocket(wsUrl("/api/ws"));
|
||||
sockRef.current = sock;
|
||||
|
||||
sock.onopen = () => {
|
||||
@@ -117,7 +129,8 @@ export function useLiveFeed(): void {
|
||||
sockRef.current?.close();
|
||||
sockRef.current = null;
|
||||
};
|
||||
// qc / store setters are stable; run once on mount.
|
||||
// qc / store setters are stable; re-run only if the permission gate flips
|
||||
// (login as a different role without a full reload).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
+27
-6
@@ -14,6 +14,7 @@ import {
|
||||
can,
|
||||
closeShift,
|
||||
fetchShiftReport,
|
||||
fetchVersion,
|
||||
logout,
|
||||
openShift,
|
||||
setLanguagePref,
|
||||
@@ -94,6 +95,17 @@ function SetupTab({ to, label, exact = false }: { to: string; label: string; exa
|
||||
);
|
||||
}
|
||||
|
||||
/** The running deploy's "<branch>-<short-sha>" (matches the Komodo Stack's TAG in
|
||||
* komodo/resources.toml), gated the same as the "Park" tab (site:read) since it's the
|
||||
* same kind of read-only app metadata. Renders nothing if the value isn't known (e.g. a
|
||||
* local/dev build with no CI-supplied BUILD_VERSION) rather than showing an empty badge. */
|
||||
function VersionBadge() {
|
||||
const q = useQuery({ queryKey: ["version"], queryFn: fetchVersion, staleTime: Infinity });
|
||||
const version = q.data?.buildVersion;
|
||||
if (!version) return null;
|
||||
return <span className="ml-auto shrink-0 pl-3 text-[0.7rem] text-term-muted">{version}</span>;
|
||||
}
|
||||
|
||||
/** Setup layout — the config hub. Renders a permission-gated tab bar and the active
|
||||
* tab's screen via <Outlet>. Each tab is a child route (its own URL + guard), so
|
||||
* deep links and the back button work and a denied tab redirects to the booth. */
|
||||
@@ -112,6 +124,7 @@ function SetupLayout() {
|
||||
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
{show("site:read") && <VersionBadge />}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
@@ -448,11 +461,17 @@ function ConfirmFigure({ label, value, bold, sub }: { label: string; value: stri
|
||||
function RootLayout() {
|
||||
const { user, setUser } = rootRoute.useRouteContext();
|
||||
const { t } = useTranslation();
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget).
|
||||
useLiveFeed();
|
||||
// Nav is gated by PERMISSION, not role — a tab shows iff the user's role grants
|
||||
// the permission its screen needs (the route guards enforce the same server-side).
|
||||
const show = (perm: Permission) => can(user, perm);
|
||||
// One app-wide WebSocket for the live feed (booth + any live widget) — but ONLY
|
||||
// for roles the server would accept (routes/ws.ts gates on report:read). A
|
||||
// merchant validator must not even attempt it: the 403'd upgrade would reconnect
|
||||
// on backoff forever and spam the server log. Same rule for the widgets that feed
|
||||
// off it (StatusDot) or make their own gated calls (ShiftButton → shift:read,
|
||||
// DeviceFooter → device:read).
|
||||
const canWatch = show("report:read");
|
||||
useLiveFeed(canWatch);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-term-bg text-term-text">
|
||||
@@ -489,11 +508,11 @@ function RootLayout() {
|
||||
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && <ShiftButton />}
|
||||
{user && show("shift:read") && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||
<StatusDot />
|
||||
{canWatch && <StatusDot />}
|
||||
{user && (
|
||||
<Link
|
||||
to="/profile"
|
||||
@@ -518,8 +537,10 @@ function RootLayout() {
|
||||
<main className="min-h-0 flex-1 overflow-auto p-3">
|
||||
<Outlet />
|
||||
</main>
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
|
||||
{user && <DeviceFooter />}
|
||||
{/* Fixed device-status footer — relays, readers, cameras, printers. Its REST
|
||||
seed needs device:read (and its live updates ride the report:read WS), so
|
||||
it's hidden for roles without device visibility (e.g. merchant validators). */}
|
||||
{user && show("device:read") && <DeviceFooter />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+38
-38
@@ -30,42 +30,6 @@
|
||||
# new [[stack]] block per site (unique name, its own per-booth secret refs).
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# park-lab — the LAB bench box (hardware/dev testing, no real traffic). Chases
|
||||
# the dev tier: compose files from `dev`, MOVING image tag `dev` (labs may
|
||||
# float; real booths pin). Secrets are its own park_lab_* refs — per-box blast
|
||||
# radius, never shared with a real booth even in the lab.
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-lab"
|
||||
[stack.config]
|
||||
server = "park-lab"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "dev"
|
||||
file_paths = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.prod.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Lab tier: the MOVING dev tag — redeploy pulls the latest dev build. Pin to a
|
||||
# dev-<sha> only when reproducing a specific state.
|
||||
TAG=dev
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
JWT_SECRET=[[park_lab_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
|
||||
BACKUP_KEY=[[park_lab_backup_key]]
|
||||
"""
|
||||
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-buzi"
|
||||
[stack.config]
|
||||
@@ -85,11 +49,47 @@ REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-22544ec
|
||||
TAG=stage-28bd838
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
WS_ALLOWED_ORIGINS=
|
||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||
JWT_SECRET=[[park_buzi_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_buzi_event_signing_key]]
|
||||
BACKUP_KEY=[[park_buzi_backup_key]]
|
||||
"""
|
||||
|
||||
##############################################################################
|
||||
|
||||
[[stack]]
|
||||
name = "park-2"
|
||||
[stack.config]
|
||||
server = "park-2"
|
||||
git_provider = "git.infra.msai.al"
|
||||
git_account = "komodo"
|
||||
repo = "mca/parking_solution"
|
||||
branch = "stage"
|
||||
file_paths = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.prod.yml"
|
||||
]
|
||||
registry_provider = "git.infra.msai.al"
|
||||
registry_account = "komodo"
|
||||
environment = """
|
||||
REGISTRY=git.infra.msai.al/mca/parking_solution
|
||||
# Staging booth: pinned immutable stage-<sha>. After each promotion (merge dev → stage, CI builds
|
||||
# :stage-<sha>), bump this to the new sha and re-sync/deploy from Core. The moving `:stage` tag
|
||||
# exists as the pointer; we deploy the sha, not the mover.
|
||||
TAG=stage-28bd838
|
||||
COOKIE_SECURE=0
|
||||
VISION_ENABLED=1
|
||||
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
|
||||
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
|
||||
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
|
||||
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
|
||||
JWT_SECRET=[[park_2_jwt_secret]]
|
||||
EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
|
||||
BACKUP_KEY=[[park_2_backup_key]]
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Last-success/last-error for the encrypted DB backup were previously tracked only as
|
||||
-- in-process fields on BackupService (never written to the DB) — so every server restart
|
||||
-- (deploy/crash/OOM/host reboot, all routine under `restart: always`) silently reset the admin
|
||||
-- UI's "last successful backup" to "Never", even with valid, correctly-rotating backups already
|
||||
-- on disk (2026-08-30 field incident, park-buzi). Four additive, nullable columns; null = no
|
||||
-- run recorded yet (or, for the error pair, no failure since the last success). See
|
||||
-- wiki/concepts/backup-recovery.md.
|
||||
ALTER TABLE `site_config` ADD `backup_last_success_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `backup_last_result_json` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `backup_last_error_at` text;--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `backup_last_error` text;
|
||||
@@ -176,6 +176,13 @@
|
||||
"when": 1783948800000,
|
||||
"tag": "0024_validation_programs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "6",
|
||||
"when": 1788078414270,
|
||||
"tag": "0025_backup_last_status",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -288,6 +288,20 @@ export const siteConfig = sqliteTable("site_config", {
|
||||
backupKeepLast: integer("backup_keep_last"),
|
||||
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
|
||||
backupKeepDailyDays: integer("backup_keep_daily_days"),
|
||||
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
|
||||
* (not just in-process memory) so the admin UI's "last successful backup" survives a
|
||||
* server restart — before this column existed, a restart silently reset that status to
|
||||
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
|
||||
* See wiki/concepts/backup-recovery.md. */
|
||||
backupLastSuccessAt: text("backup_last_success_at"),
|
||||
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
|
||||
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
|
||||
backupLastResultJson: text("backup_last_result_json"),
|
||||
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
|
||||
* reason. null = no failure recorded (or none since the last success). */
|
||||
backupLastErrorAt: text("backup_last_error_at"),
|
||||
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
|
||||
backupLastError: text("backup_last_error"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
Generated
+20
@@ -108,12 +108,18 @@ importers:
|
||||
'@tanstack/react-router':
|
||||
specifier: ^1.170.16
|
||||
version: 1.170.16(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@tauri-apps/plugin-http':
|
||||
specifier: ^2.5.2
|
||||
version: 2.6.0
|
||||
'@tauri-apps/plugin-process':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
'@tauri-apps/plugin-updater':
|
||||
specifier: ^2.10.1
|
||||
version: 2.10.1
|
||||
'@tauri-apps/plugin-websocket':
|
||||
specifier: ^2.3.0
|
||||
version: 2.4.3
|
||||
i18next:
|
||||
specifier: ^26.3.1
|
||||
version: 26.3.1(typescript@6.0.3)
|
||||
@@ -1577,12 +1583,18 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
hasBin: true
|
||||
|
||||
'@tauri-apps/plugin-http@2.6.0':
|
||||
resolution: {integrity: sha512-QYXwbGb4hQ9/8Riv/ejU/kPFFnbBIrBcWwV1LIXv2xBKfoj8lkWfGkd9pkCSsBI/pljPtz+IPqfrE3t3bVl3mg==}
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==}
|
||||
|
||||
'@tauri-apps/plugin-updater@2.10.1':
|
||||
resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==}
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.4.3':
|
||||
resolution: {integrity: sha512-c85ykljg6AzY6Zw4KpYsEBaLirjPIs6m8xxC6hZcdwAchckaBU368US+oSsa5B43PjSLukjwD5vOOqzYYnswWA==}
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4089,6 +4101,10 @@ snapshots:
|
||||
'@tauri-apps/cli-win32-ia32-msvc': 2.11.3
|
||||
'@tauri-apps/cli-win32-x64-msvc': 2.11.3
|
||||
|
||||
'@tauri-apps/plugin-http@2.6.0':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/plugin-process@2.3.1':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
@@ -4097,6 +4113,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@tauri-apps/plugin-websocket@2.4.3':
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.11.1
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, durability, backup, recovery, security, crypto]
|
||||
sources: []
|
||||
updated: 2026-06-29
|
||||
updated: 2026-08-30
|
||||
---
|
||||
|
||||
# Backup & Disaster Recovery
|
||||
@@ -207,12 +207,68 @@ timer + the manual route**. What landed:
|
||||
**SMB/NFS already work** — they're just a mounted path the admin enters as the target. **Deferred to
|
||||
follow-up slices:** an **SFTP** target and a **restore runbook / CLI**.
|
||||
|
||||
## Field bug — "last successful backup: Never" despite valid, rotating backups on disk (found + fixed 2026-08-30)
|
||||
|
||||
**Symptom (park-buzi):** the admin noticed the backup directory held 7 real, correctly-sized,
|
||||
correctly-rotating encrypted backups (`parking-backup-*.sqlite.enc`, retention working exactly as
|
||||
designed) — yet the Backup screen's "Kopja e fundit e suksesshme" (last successful backup) showed
|
||||
**"Asnjëherë" (Never)**. Separately, the most recent file was 2 days old rather than ~1.
|
||||
|
||||
**Root cause — two independent, disconnected code paths, both traced to `setInterval`-since-
|
||||
process-start:**
|
||||
|
||||
1. **Status was never persisted.** `BackupService` tracked `lastSuccessAt`/`lastResult`/
|
||||
`lastErrorAt`/`lastError` as **plain in-process private fields** — set only inside `run()`,
|
||||
read only by `status()` on the *same running instance*. Nothing wrote them to `site_config` or
|
||||
anywhere else durable. The actual backup-writing engine (`backup.ts`: consistent copy → encrypt
|
||||
→ `pruneOldBackups`) is a completely separate code path that only touches the filesystem and
|
||||
has no notion of this status object. So "7 valid files on disk" and "status says Never" were
|
||||
never contradictory — they were two unrelated signals, and **any** server restart (deploy,
|
||||
crash, OOM, host reboot — all routine under `restart: always` in `docker-compose.prod.yml`)
|
||||
silently reset the in-memory fields to `null` regardless of what had actually happened on disk.
|
||||
2. **The schedule was measured from process start, not from the last real backup.** The daily
|
||||
timer was `setInterval(() => backupService.runScheduled(), 24h)` — a fixed 24h period counted
|
||||
from whenever the *process* last started, not from wall-clock time or from when a backup last
|
||||
actually succeeded. The exact same restart that wiped the in-memory status also reset this
|
||||
countdown, which is why the cadence can silently drift or skip past a day with no error ever
|
||||
surfacing anywhere.
|
||||
|
||||
Both symptoms are one cause: **the server process restarted after the Aug 28 backup, and nothing
|
||||
about this design was built to survive that.**
|
||||
|
||||
### Fix (2026-08-30)
|
||||
|
||||
- **`packages/db/src/schema.ts`** / migration `0025_backup_last_status.sql` — four new nullable
|
||||
`site_config` columns: `backup_last_success_at`, `backup_last_result_json`,
|
||||
`backup_last_error_at`, `backup_last_error`. Same table, same upsert pattern as
|
||||
`backup_target_dir`/`backup_keep_last`/`backup_keep_daily_days` (migrations 0016/0017).
|
||||
- **`backup-service.ts`** — `run()` now writes success/error outcomes to these columns (via a
|
||||
`#persist` upsert helper) instead of private fields; `status()` reads them fresh from the DB on
|
||||
every call. A brand-new `BackupService` instance (i.e. a fresh process) now sees exactly what
|
||||
the previous instance last recorded — no more restart amnesia.
|
||||
- **New `isDue(now, intervalMs = 24h)`** method: due iff `now - backupLastSuccessAt >= 24h` (or
|
||||
immediately due if no success was ever recorded), computed from the **persisted** timestamp —
|
||||
never from process uptime.
|
||||
- **`server.ts`** — the daily `setInterval` was replaced with a **15-minute poll** calling
|
||||
`runScheduled()`, which now itself no-ops unless `isDue()` is true. This makes the actual backup
|
||||
cadence immune to restart timing entirely: however often the process happens to restart, the
|
||||
next backup fires within 15 minutes of 24h having genuinely elapsed since the last real success
|
||||
— not 24h after whatever moment the process most recently came back up.
|
||||
- Covered by a new `backup-service.test.ts`: a fresh `BackupService` over the same DB handle
|
||||
(simulating a restart) sees the prior instance's last success/error and its cleared-on-success
|
||||
behavior; `isDue()` is exercised directly against injected timestamps rather than real sleeps.
|
||||
|
||||
No change to the `BackupStatus` shape returned by `GET /api/backup/status` or to
|
||||
`BackupSettings.tsx` — this was purely a durability fix underneath the same contract.
|
||||
|
||||
## Status
|
||||
|
||||
Design settled 2026-06-29; **engine + admin-configured local/mounted target + admin UI BUILT
|
||||
2026-06-29** (SFTP + restore tooling pending). The target directory is **admin-chosen in the UI**
|
||||
(`site_config`, migration 0016), not an env var — the on-site admin picks where backups land; only
|
||||
`BACKUP_KEY` stays a server secret. Resolves the *design* half of [[open-questions]] #5 and the first
|
||||
build slices; records the key-custody stance that bears on #6 (signing stays decoupled from the TPM) and
|
||||
#10 (snapshots bloat backups → future exclude toggle). See [[append-only-event-chain]],
|
||||
[[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]], [[reconciliation]].
|
||||
`BACKUP_KEY` stays a server secret. **Last-success/last-error status + the scheduling cadence are
|
||||
now restart-durable (migration 0025, 2026-08-30)** — see field bug above. Resolves the *design*
|
||||
half of [[open-questions]] #5 and the first build slices; records the key-custody stance that bears
|
||||
on #6 (signing stays decoupled from the TPM) and #10 (snapshots bloat backups → future exclude
|
||||
toggle). See [[append-only-event-chain]], [[disk-os-hardening]], [[tpm]], [[fleet-deployment-komodo]],
|
||||
[[reconciliation]].
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: concept
|
||||
tags: [parking, device, printer, transport, usb, escpos, provisioning]
|
||||
sources: []
|
||||
updated: 2026-07-06
|
||||
updated: 2026-08-30
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -140,5 +140,70 @@ hint. The transport option label no longer hardcodes lp0.
|
||||
> the monitor would mark a perfectly working printer offline/degraded. Over USB the two drivers
|
||||
> behave identically (reachability floor), so either works post-fix. See [[rongta-printer]].
|
||||
|
||||
## Field bug — cover-open re-enumeration wedges the container's `/dev/usb` view; only `docker restart`, not a host reboot, clears it (investigated 2026-08-30, unconfirmed root cause)
|
||||
|
||||
**Symptom (park-buzi, unknown/"Generic" USB printer, model not yet identified — see below):** every
|
||||
time the booth operator opens the printer's paper-roll cover to reload paper, the printer's status
|
||||
goes `offline`/faulty in the app and **never self-recovers** — not after the cover closes, not after
|
||||
a full appliance reboot. The only fix found so far is SSH in and `docker restart server`.
|
||||
|
||||
**Ruled out at the application layer.** Traced `sendRawUsb`/`probeUsb` in `printer-escpos.ts`: every
|
||||
print AND every poll tick (`device-monitor.ts` 8s / `printer-monitor.ts` 5s) does a fresh
|
||||
`open()` → write/probe → `close()` against the configured `devicePath`. **No fd, socket, or driver
|
||||
instance is held across calls** — `driver.create(config)` is a throwaway object with no persistent
|
||||
handle. So a naive "stale Node file descriptor" explanation does not fit this codebase; the
|
||||
app-layer retry-by-fresh-open-every-poll should self-heal within one poll cycle if the kernel's view
|
||||
of the device node is current.
|
||||
|
||||
**Leading hypothesis: the container's bind-mount of `/dev/usb`, not the Node process, holds the
|
||||
stale state.** Docker Compose wires the printer in as a **directory bind-mount**
|
||||
(`docker-compose.prod.yml`, `volumes: - /dev/usb:/dev/usb`), chosen deliberately (per its own
|
||||
comment) so the app survives the printer renumbering to a different `lpN`. But many USB thermal
|
||||
printers cut power to their own USB interface board when the cover-open microswitch trips (a
|
||||
hardware safety/power feature, not just a status flag) — the printer drops off the bus and
|
||||
re-enumerates, potentially as a new device node, when the cover closes. The **host** kernel picks
|
||||
this up fine; the **container's mount namespace**, once established, is a known Docker/OverlayFS
|
||||
sharp edge for `/dev` subtree bind-mounts — it can keep resolving the old node until the mount
|
||||
itself is redone.
|
||||
|
||||
- `docker restart server` recreates the container's mount namespace → the `/dev/usb` bind-mount is
|
||||
redone against current host state → the new node is picked up → fixed.
|
||||
- A full host reboot restarts the container too (`restart: always`), but as a boot-time race: if the
|
||||
container starts before the USB subsystem finishes settling, or the printer re-enumerated some
|
||||
time *before* the reboot and Docker doesn't necessarily redo an already-satisfied bind-mount
|
||||
target on a policy-driven restart, the container can come back up still bound to the pre-incident
|
||||
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
|
||||
|
||||
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To
|
||||
confirm at the next occurrence, BEFORE restarting anything:
|
||||
```bash
|
||||
# host:
|
||||
ls -la /dev/usb/ && stat /dev/usb/lp1
|
||||
# container:
|
||||
docker exec server ls -la /dev/usb/ && docker exec server stat /dev/usb/lp1
|
||||
```
|
||||
A major:minor or inode mismatch between host and container is the smoking gun. Also worth
|
||||
capturing on the lab RONGTA (different printer, but same cover-open mechanism is plausible):
|
||||
`watch -n1 lsusb` + `sudo dmesg -w | grep -i -E 'usb|disconnect'` while cycling the cover, to see
|
||||
whether the Bus/Device number changes.
|
||||
|
||||
**Candidate fixes, not yet implemented** (ranked cheapest-to-most-invasive):
|
||||
1. A host-side watchdog/udev rule that detects re-enumeration of this printer (match vendor:product
|
||||
ID) and runs `docker restart server` automatically — turns the manual SSH fix into a self-healing
|
||||
one without touching app code.
|
||||
2. Same idea but event-driven via a udev rule or systemd path unit watching `/dev/usb`, rather than
|
||||
polling.
|
||||
3. Switch the compose device wiring from the directory bind-mount to a specific `--device=` cgroup
|
||||
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
|
||||
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
|
||||
|
||||
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the
|
||||
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
|
||||
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
|
||||
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
|
||||
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
|
||||
-n /dev/usb/lp1`. This mirrors the same discovery gap already noted above under "Device discovery"
|
||||
(sysfs `ieee1284_id` enrichment) — once identified, fold the model into that mechanism's coverage.
|
||||
|
||||
Related: [[rongta-printer]], [[printer-status-monitoring]], [[printer-roles-failover]],
|
||||
[[appliance-provisioning]], [[network-isolation]], [[technology-stack]].
|
||||
|
||||
@@ -100,7 +100,11 @@ priced at booth check-in, inside the normal walk-back-grace flow).
|
||||
both; mode/params/caps/receipt-label/bound-users). `/validate` (`ValidateScreen.tsx`) is the
|
||||
merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused
|
||||
input works with HID scanners; merchant-only users (no `session:read`) land there on login and
|
||||
the permission-gated nav shows them nothing else. Booth pay modal shows gross → lines → net;
|
||||
the permission-gated nav shows them nothing else. The app SHELL also degrades by permission
|
||||
(2026-07-13 follow-up): the live-feed WebSocket connects only with `report:read` (the server's
|
||||
WS guard — a merchant's socket would 403 and the capped-backoff reconnect would spam the server
|
||||
log forever), and the StatusDot / ShiftButton / DeviceFooter widgets render only with their
|
||||
backing permissions (`report:read` / `shift:read` / `device:read`). Booth pay modal shows gross → lines → net;
|
||||
the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged).
|
||||
Feed label `VALIDIM`/`VALIDATION`. RolesManager picks the new resource up generically.
|
||||
- **Verified**: 8 route-level integration tests (guards, signed events, money cycle, void locks,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: reference
|
||||
tags: [parking, deployment, appliance, hardening, runbook, offline-first]
|
||||
sources: []
|
||||
updated: 2026-07-06
|
||||
updated: 2026-09-02
|
||||
status: settled
|
||||
---
|
||||
|
||||
@@ -282,24 +282,38 @@ sudo loginctl enable-linger admin # so the user service starts at boot witho
|
||||
```
|
||||
|
||||
- `--connect-as` is the **Server name in Core** — unique, stable, site-meaningful (the fleet's
|
||||
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one.
|
||||
primary key). Booth #2 = a different name (e.g. `park-durres`); never reuse one. **Get this
|
||||
right in the command itself** — it's a plain field in `periphery.config.toml` on the host, so a
|
||||
typo/placeholder here needs a config edit + agent restart to fix, NOT a rename in Core's UI
|
||||
(which only relabels Core's record, not the agent's real identity — gotcha #12 below).
|
||||
- `--core-address` is Core's **reverse-proxy URL** (the URL you load the Core UI at over the mesh),
|
||||
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)
|
||||
|
||||
@@ -482,8 +496,36 @@ 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**
|
||||
(success). Execute only enables when the file and Core diverge (e.g. you edit `TAG`).
|
||||
12. **Renaming a Server in Core's UI does NOT change the agent's actual identity.**
|
||||
`connect_as` is a plain field persisted in the agent's own
|
||||
`~/.config/komodo/periphery.config.toml` — Core's UI rename only relabels Core's *record*,
|
||||
the agent keeps re-announcing under its original `connect_as` on every reconnect. Symptom (hit
|
||||
2026-08-30, lab box): a server named via a leftover template placeholder in the install
|
||||
command kept reappearing in Core no matter how many times it was renamed there, while the
|
||||
intended name sat permanently NOT OK (nothing was ever checking in as that name). **Fix: edit
|
||||
`connect_as` directly in `periphery.config.toml` on the host, then `systemctl --user restart
|
||||
periphery`** — no reinstall/re-onboarding needed. Delete the stray old-name Server record in
|
||||
Core afterward. Lesson: always double-check `--connect-as` is a REAL name (never leave a
|
||||
template placeholder like `<new-server-name>` in a copy-pasted install command) — Core will
|
||||
happily create a server with that literal string.
|
||||
13. **Upgrading an already-installed Periphery is: re-run the same installer, unchanged
|
||||
`--connect-as`.** No separate update mechanism, no update-only flag. The installer script
|
||||
explicitly skips rewriting `periphery.config.toml` if one already exists ("Config already
|
||||
exists, skipping...") — it only stops the service, replaces the binary, and restarts — so a
|
||||
re-run is **config-preserving** and a fresh/dummy `--onboarding-key` value on that re-run is
|
||||
simply unused (confirmed against Komodo's own `setup-periphery.py` source, 2026-08-30; no
|
||||
Periphery-specific breaking changes between v2.2.0 and v2.3.2 per Komodo's release notes).
|
||||
Verified end-to-end on `art-docker-station` (lab, dry run) then `park-buzi` (live booth,
|
||||
2026-08-30): same command as §7a step 2, same `--connect-as`, app containers untouched
|
||||
throughout (Periphery restarting itself never touches the already-running compose stack).
|
||||
**Always dry-run a version bump on a lab/dev box before a live booth**, even with a clean
|
||||
release-notes check — this project only had one lab box to test against and used it first.
|
||||
|
||||
@@ -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,64 @@ 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 `""`.
|
||||
- **Mixed content blocks http(s)/ws(s) from the webview — fixed 2026-09-03.** Even with
|
||||
`VITE_API_BASE` correctly set (below), login still failed with WebKit's generic `"Load failed"`.
|
||||
Root cause is a separate, deeper issue: WebKitGTK treats `tauri://localhost` as a **secure
|
||||
origin**, so a plain `http://127.0.0.1:3000` `fetch()` — or a `ws://127.0.0.1:3000` WebSocket —
|
||||
from inside it is blocked as **mixed content**, a long-standing WebKit limitation
|
||||
([bugs.webkit.org #171934](https://bugs.webkit.org/show_bug.cgi?id=171934)). `connect-src` in the
|
||||
CSP does **not** override this — it's a different browser security layer entirely, so the request
|
||||
never even reaches the network layer to be diagnosable via server logs. **Fix:** two Tauri plugins
|
||||
route the SPA's traffic through Tauri's native (Rust) side instead of the webview's own
|
||||
fetch/WebSocket, which sidesteps the check entirely:
|
||||
- **`tauri-plugin-http`** — `apps/web/src/lib/origin.ts`'s `platformFetch()` dynamically imports
|
||||
`@tauri-apps/plugin-http`'s `fetch` (a genuine drop-in for the standard Fetch API) inside Tauri,
|
||||
plain `fetch` in the browser. `api.ts` and `logger.ts` both call `platformFetch` instead of the
|
||||
global `fetch` now.
|
||||
- **`tauri-plugin-websocket`** — NOT a drop-in (async `connect()`/listener-callback API, not
|
||||
`onopen`/`onmessage`/sync `send`/`close`). `apps/web/src/lib/platform-ws.ts` adapts it behind
|
||||
the same native-`WebSocket`-shaped interface `use-live-feed.ts` already expects (hardened for
|
||||
reconnect backoff + StrictMode double-invoke), so that hook needed zero changes.
|
||||
- Capability grants: `apps/desktop/src-tauri/capabilities/default.json` adds `websocket:default`
|
||||
and a scoped `http:default` (`allow: [{url: "http://127.0.0.1:3000"}, {url:
|
||||
"http://localhost:3000"}]`) — deny-by-default, matching the CSP's existing allowlist.
|
||||
- `logger.ts`'s `flushBeacon()` (page-hide `navigator.sendBeacon`) is a native browser API with no
|
||||
Tauri equivalent — it still drops silently in the desktop shell on unload. Accepted: the regular
|
||||
4s-interval flush (now fixed, routes through `platformFetch`) covers the common case.
|
||||
- **Gotcha (found immediately after shipping the above): the native WS plugin sends no `Origin`
|
||||
header.** `tauri-plugin-websocket`'s `connect()` runs on Tauri's Rust side, not inside the
|
||||
webview page — there's no page context to auto-attach `Origin: tauri://localhost` the way a real
|
||||
browser `WebSocket` would. The server's anti-CSWSH check (`routes/ws.ts`, `isAllowedOrigin`)
|
||||
treats a missing Origin as untrusted and 403s the handshake before touching auth — the live feed
|
||||
showed **"JASHTË LINJË"** (offline) in the desktop app while the browser showed **"LIVE"**, same
|
||||
server, same moment. **Fix (two parts, both needed):** `platform-ws.ts`'s `connect()` call now
|
||||
passes `{ headers: { Origin: "tauri://localhost" } }` explicitly; separately, `komodo/
|
||||
resources.toml`'s booth Stacks had `WS_ALLOWED_ORIGINS=` **empty** in production (despite
|
||||
`.env.example` documenting `tauri://localhost,http://tauri.localhost` as required) — even a
|
||||
correct Origin header is useless if the server's allowlist doesn't include it. Both fixed
|
||||
together; a `resources.toml` change still needs a Komodo sync + Stack redeploy to take effect on
|
||||
a live booth, it isn't automatic from a git push alone.
|
||||
- **`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 +209,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 +237,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 +269,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.
|
||||
|
||||
+108
-2
@@ -1,8 +1,8 @@
|
||||
---
|
||||
type: entity
|
||||
tags: [parking, hardware, readers, offline-first]
|
||||
sources: [parking-system-architecture]
|
||||
updated: 2026-07-07
|
||||
sources: [parking-system-architecture, DS-2CD1047G3H-LIU]
|
||||
updated: 2026-08-23
|
||||
---
|
||||
|
||||
# LPR Camera
|
||||
@@ -110,6 +110,112 @@ Covered by `packages/devices/src/drivers/camera.test.ts` (retry behaviour + the
|
||||
selection). `healthCheck()` deliberately reports a live 503 as `degraded` (it surfaces a genuinely
|
||||
saturated main stream rather than hiding it behind a retry).
|
||||
|
||||
### Main-stream ISAPI snapshot 503 is model-specific, not config — and RTSP routes around it (2026-08-23)
|
||||
|
||||
Live comparison, same site (park-buzi), same day, both cameras reachable via the site's port
|
||||
forwards (`park-buzi.msai.al:8081` / `:8082`) and directly on the LAN (`10.0.10.13` = the `:8082`
|
||||
unit):
|
||||
|
||||
| | `:8081` (works) | `:8082` = `10.0.10.13` (503s) |
|
||||
|---|---|---|
|
||||
| Model | **DS-2CD1043G2-LIU** | **DS-2CD1047G3H-LIU** |
|
||||
| Firmware | V5.8.10 | V5.8.11 |
|
||||
| Channel 101 config | 2560×1440, VBR, 6144 Kbps cap, 20fps | **identical** — 2560×1440, VBR, 6144 Kbps cap, 20fps |
|
||||
| `SmartCodec` | disabled | disabled |
|
||||
| `GET /ISAPI/Streaming/channels/101/picture` | **200**, valid JPEG | **503**, `statusCode 2 / deviceBusy` (3/3 retries, instant) |
|
||||
| `GET /ISAPI/Streaming/channels/102/picture` (sub) | — | **200**, valid JPEG |
|
||||
|
||||
Channel-101 config is **byte-identical** between the two units (bitrate, resolution, frame rate,
|
||||
SmartCodec) — this rules out "misconfigured over some ceiling" definitively; the only things that
|
||||
differ are model + firmware. Combined with the [[#Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet|
|
||||
vendor datasheet]] fact that the G3H's **main stream has no MJPEG option** (sub-stream does), the
|
||||
working theory is that this SKU's snapshot codepath has to transcode a live H.264/H.265 frame into
|
||||
JPEG on demand for main, and its firmware/encoder can't do that reliably at this resolution —
|
||||
while sub can serve JPEG more natively. **Treat this as a `DS-2CD1047G3H-LIU`-model limitation
|
||||
(this firmware line), not a config or ISAPI-usage bug** — matches every earlier finding on this
|
||||
same unit (`10.0.10.13`) in the sections below, now cross-confirmed against a working sibling model
|
||||
on the same network with identical settings.
|
||||
|
||||
**RTSP main-stream frame-grab works and routes around it entirely**, confirmed live against
|
||||
`10.0.10.13`:
|
||||
|
||||
```bash
|
||||
ffmpeg -rtsp_transport tcp -y \
|
||||
-i "rtsp://admin:<pw>@10.0.10.13:554/Streaming/Channels/101" \
|
||||
-frames:v 1 -update 1 snapshot.jpg
|
||||
```
|
||||
|
||||
Returned a valid 2560×1440 JPEG (94 KB) on the first try — same camera, same main-stream
|
||||
resolution the ISAPI endpoint 503s on. This makes sense mechanically: RTSP just taps the H.264
|
||||
stream the encoder is **already producing continuously** for live-view/recording; there's no
|
||||
on-demand "pause and re-encode as standalone JPEG" step for the firmware to choke on, unlike the
|
||||
ISAPI snapshot path. Port 554 is open on the LAN (`10.0.10.13`) but **not** forwarded through the
|
||||
site's public port-forward (`park-buzi.msai.al` only exposes the HTTP/ISAPI ports, consistent with
|
||||
[[network-isolation|the camera/controller network staying LAN-only]] — RTSP was only reachable
|
||||
from inside the site network, never tested through the public forward).
|
||||
|
||||
**Not yet built**: `packages/devices/src/drivers/camera.ts` is HTTP-Digest/ISAPI only today: no
|
||||
RTSP client, no `ffmpeg` child-process dependency. Adding an RTSP fallback (or RTSP-first path for
|
||||
cameras that report persistent `deviceBusy` on ISAPI main) is a real architectural addition — new
|
||||
process-spawn dependency, RTSP auth handling, transport selection (TCP confirmed working; UDP
|
||||
untested) — not implemented as of this writing.
|
||||
|
||||
**Escalated from "nice to have" to a real requirement (2026-08-23): the sub-stream (768×432) is
|
||||
too weak for reliable plate reads** — it fails to read plates "from time to time" in practice, so
|
||||
sub-stream-only is not an acceptable permanent mitigation for this camera; RTSP-for-main is needed
|
||||
for ANPR accuracy, not just for a higher-res evidence photo.
|
||||
|
||||
**Firmware update tested and RULED OUT as the fix (2026-08-23).** Before building the RTSP path,
|
||||
checked whether this was simply a day-one bug: the camera shipped on `V5.8.11` build 250415 —
|
||||
confirmed via the official Hikvision release note to be **the very first H13U firmware build that
|
||||
added support for the DS-2CD1XX7G3H-LIU family at all** ("Newly add 1 series 4MP fixed-focus
|
||||
cameras: DS-2CD1XX7G3H-LIU"), a plausible day-one-bug candidate. Upgraded live to **`V5.11.0` build
|
||||
260701** (over a year of firmware progress, incl. an intermediate `V5.8.21_SP1` release explicitly
|
||||
noting "Fix network and image potential bugs to enhance device stability"). **Result: NO CHANGE.**
|
||||
Post-upgrade, `channels/101/picture` still returns `HTTP 503 / statusCode 2 / deviceBusy`, 5/5
|
||||
consecutive attempts, byte-identical error body to pre-upgrade. Sub-stream (`102`) still healthy
|
||||
(200, ~15KB) — camera is fine post-upgrade, just this one limitation persists.
|
||||
|
||||
**Superseded finding, below: "durable hardware/encoder ceiling" was the wrong framing.** At this
|
||||
point in the investigation it looked like a real capacity limit (reproduced across ~15 months of
|
||||
firmware). The channel-sweep test below shows that's not what's actually happening.
|
||||
|
||||
### The real cause: a broken/incomplete ISAPI snapshot handler, not "busy" (2026-08-23)
|
||||
|
||||
`deviceBusy` never meant "busy." Swept every channel/stream ID against the snapshot endpoint in
|
||||
one sitting, including IDs that don't exist on this camera at all:
|
||||
|
||||
| channel | `GET .../channels/<id>/picture` |
|
||||
|---|---|
|
||||
| 1 | 503 `deviceBusy` |
|
||||
| 100 | 503 `deviceBusy` |
|
||||
| **101** (real main) | **503 `deviceBusy`** |
|
||||
| **102** (real sub) | **200 OK** |
|
||||
| 103 | 503 `deviceBusy` |
|
||||
| 201 (channel 2 doesn't exist — single-channel camera) | 503 `deviceBusy` |
|
||||
| 999 (garbage) | 503 `deviceBusy` |
|
||||
|
||||
**Every ID fails identically except exactly `102`.** A genuinely busy/saturated encoder would not
|
||||
succeed on one specific value and fail the same way on nonexistent channel IDs — a real resource
|
||||
contention error would 404 or behave differently on garbage input, not return the identical `Device
|
||||
Busy` XML body regardless of whether the target exists. This is the signature of a **generic
|
||||
fallback error path**: the firmware's snapshot handler appears to only be correctly wired for
|
||||
`102` (the one channel/stream combination Hikvision evidently tested for this SKU) and returns a
|
||||
stock, misleading `deviceBusy` for every other case — valid main-stream `101` included. It is a
|
||||
**firmware bug that mislabels itself as resource contention**, not a real capacity ceiling — which
|
||||
also fits the firmware-upgrade non-result above (a wrong-code-path bug doesn't get fixed by
|
||||
"more capacity," so no firmware version fixing it would be surprising).
|
||||
|
||||
**Decision (2026-08-23): replace this camera line rather than build around it.** RTSP main-stream
|
||||
capture is proven to work (see above) and could still be built as a `camera.ts` addition, but given
|
||||
the ISAPI snapshot path is flatly broken for anything but one hardcoded channel value, and the
|
||||
site's actual need (reliable plate reads — sub-stream alone isn't accurate enough) requires
|
||||
full-resolution captures, the owner chose to swap out the `DS-2CD1047G3H-LIU` units rather than
|
||||
carry a `ffmpeg`/RTSP dependency to route around a vendor firmware bug. The working `DS-2CD1043G2-
|
||||
LIU` (`:8081` in the comparison above) has no such issue — ISAPI main-stream snapshot works
|
||||
natively — and is the reference model for replacements. RTSP-frame-grab remains documented above
|
||||
as a viable fallback if a `G3H`-family camera is ever unavoidable.
|
||||
|
||||
## Clock sync — the 1970 power-cut reset (built 2026-07-07)
|
||||
|
||||
Field observation (park-buzi): after a power cut these cameras come back with their clock at the
|
||||
|
||||
+5
-4
@@ -19,6 +19,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||
- [[dingtian-dt008]] — Dingtian DT-008 product page: QR/RFID access reader (QR/barcode + ID/IC/NFC; Wiegand/TCP-IP/USB/RS485; HTTP-GET push).
|
||||
- [[qrcode-sdk]] — QRCode SDK v1.6.5: the reader's HTTP-GET-poll protocol + JSON verdict (beep/output).
|
||||
- [[parksql2017-legacy-schema]] — predecessor SQL Server schema (Albanian market): legacy tariff/discount/membership/shift/fiscal model; confirms blocks, adds time-windows + categories, lacks postpaid sponsors.
|
||||
- [[ds-2cd1047g3h-liu]] — HIKVISION DS-2CD1047G3H-LIU-F datasheet: 4MP, main stream has no MJPEG option (sub does) — likely explains the model's persistent ISAPI snapshot 503 on main; confirms ISAPI/RTSP both fully in-spec.
|
||||
|
||||
## Entities — technology stack
|
||||
- [[technology-stack]] — the full stack table; all MIT/Apache/BSD, chosen to avoid lock-in.
|
||||
@@ -39,7 +40,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||
- [[esp32-custom-controller]] — prevention-grade upgrade; device-level auth.
|
||||
- [[atecc608]] — secure element; non-extractable signing key (host events + controller auth).
|
||||
- [[wiegand]] — reader standard feeding the controller directly (autonomous permit-holder path).
|
||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
|
||||
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source; DS-2CD1047G3H-LIU main-stream ISAPI snapshot 503 is a firmware bug (only channel 102 ever works, ALL other IDs incl. garbage 503 identically) — firmware update tested, no fix; owner decided to replace the camera line rather than build an RTSP workaround.
|
||||
- [[dingtian-dt008-reader]] — Dingtian DT-008 QR/RFID reader on hand; host-side serial → `read` bus (the QR-ticket scanner).
|
||||
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
|
||||
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware); spare relays drive aux outputs (`setAux`).
|
||||
@@ -57,7 +58,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||
- [[hardware-signer-options]] — where the ledger signing key should live (TPM interim → USB-HSM target; ATECC608 upcoming, not on-site) so a host-owner can't forge the chain.
|
||||
- [[reconciliation]] — the real anti-fraud control; what remote sync actually is.
|
||||
- [[disk-os-hardening]] — the *why* of host hardening: LUKS FDE + TPM-sealed auto-unlock (PCR 7) + Secure Boot + GRUB edit-lock + unprivileged operator + firmware/dbx lockdown; secondary control (reconciliation is the main event). Commands → [[appliance-provisioning]].
|
||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only.
|
||||
- [[backup-recovery]] — admin-driven encrypted full-DB backup (local/SMB/SFTP) + DR; signing key escrowed & decoupled from TPM so the ledger survives total hardware loss; restore is admin-only; last-success/error status + schedule are now restart-durable (migration 0025, fixed a "shows Never despite valid backups" bug).
|
||||
|
||||
## Concepts — device architecture & safety
|
||||
- [[device-adapter-pattern]] — business logic talks to interfaces; swap hardware → new adapter.
|
||||
@@ -68,7 +69,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
|
||||
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
|
||||
- [[printer-roles-failover]] — ≥2 printers by role; entry ticket falls back outside→booth.
|
||||
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
|
||||
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14).
|
||||
- [[printer-usb-transport]] — ESC/POS drivers drive TCP (9100) OR local USB (/dev/usb/lp0) behind one render layer; USB = usblp char device, reachability-only status; provisioning open (oq#14); park-buzi cover-open-wedges-USB-status bug (docker restart-only fix) under investigation.
|
||||
- [[device-status-monitoring]] — unified live status across ALL device categories (healthCheck + printer readStatus) → the booth footer over /api/ws.
|
||||
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
|
||||
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
|
||||
@@ -133,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.
|
||||
|
||||
+174
@@ -2619,3 +2619,177 @@ scanning (blocked on secure-context TLS for LAN phones + weak Code128-via-camera
|
||||
QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning via the official
|
||||
barcode-scanner plugin; deferred over Android build/distribution overhead + the
|
||||
configurable-server-URL prerequisite). Full analysis on [[validation-discounts]].
|
||||
|
||||
## [2026-08-23] ingest | HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||||
|
||||
Vendor datasheet dropped in `raw/DS-2CD1047G3H-LIU.md`. Key new fact: main stream on this model
|
||||
supports H.265+/H.265/H.264+/H.264 only — **no MJPEG**; sub-stream adds MJPEG. Likely mechanical
|
||||
explanation for the persistent ISAPI main-stream snapshot 503 (`deviceBusy`) already logged on this
|
||||
model in [[lpr-camera]] (2026-06-26/27): the on-demand JPEG snapshot has no native path on main,
|
||||
so it has to transcode from H.264/H.265 live, which this SKU's firmware apparently can't do
|
||||
reliably at 2560×1440. Bitrate spec (32Kbps–16Mbps) also confirms the site's main-stream config
|
||||
(6144–12288Kbps) was never out of range — rules out misconfiguration definitively.
|
||||
|
||||
## [2026-08-23] query | Main-stream 503: model-specific or config? RTSP as a workaround?
|
||||
|
||||
Live-compared two Hikvision units at park-buzi via their public port forwards
|
||||
(`park-buzi.msai.al:8081`/`:8082`) plus the `:8082` unit directly on the LAN (`10.0.10.13`).
|
||||
Channel-101 (main) config is **byte-identical** between a working `DS-2CD1043G2-LIU` (8081, 200 OK)
|
||||
and the failing `DS-2CD1047G3H-LIU` (8082 / `10.0.10.13`, persistent 503 `deviceBusy`, 3/3 retries
|
||||
instant) — same resolution/bitrate/framerate/SmartCodec state. Rules out config as the cause;
|
||||
confirms it's model/firmware-specific (matches the datasheet finding above). Then tested RTSP
|
||||
(`rtsp://…@10.0.10.13:554/Streaming/Channels/101` via ffmpeg, TCP transport) against the SAME
|
||||
failing camera: returned a valid 2560×1440 JPEG on the first try. RTSP taps the continuously-
|
||||
running encode rather than asking for an on-demand re-encoded JPEG, so it sidesteps whatever the
|
||||
ISAPI snapshot path chokes on. Not yet built into `camera.ts` (would add an `ffmpeg` child-process
|
||||
dependency + RTSP auth/transport handling) — filed as a viable, proven fallback if full-resolution
|
||||
main-stream stills are ever needed; sub-stream ISAPI snapshot remains sufficient for current ANPR
|
||||
use. Full comparison table + RTSP command on [[lpr-camera]].
|
||||
|
||||
## [2026-08-23] update | Firmware update tested and ruled out; sub-stream confirmed too weak for ANPR — RTSP is now required
|
||||
|
||||
Two developments on the DS-2CD1047G3H-LIU (`10.0.10.13`) main-stream 503: (1) the owner reports the
|
||||
sub-stream (768×432) **fails to read plates "from time to time"** in real use — sub-stream-only is
|
||||
no longer an acceptable mitigation, it's an accuracy problem. (2) Before building RTSP, tested
|
||||
whether this was a day-one firmware bug: the camera's original `V5.8.11`/250415 build was confirmed
|
||||
(via Hikvision's own release note) to be the FIRST H13U firmware to support this camera family at
|
||||
all. Upgraded live to `V5.11.0`/260701 (~15 months newer, spanning an intermediate release that
|
||||
explicitly claimed "image stability" fixes). Result: **no change** — identical `deviceBusy` 503,
|
||||
5/5 attempts, post-upgrade. Firmware is now a ruled-out cause, not a theory; this looks like a real
|
||||
encoder/hardware ceiling on this SKU. Next step: build the RTSP-based main-stream capture path into
|
||||
`packages/devices/src/drivers/camera.ts` (not yet started). Full detail on [[lpr-camera]].
|
||||
|
||||
## [2026-08-23] update | Root cause nailed down: broken ISAPI handler, not "busy" — decision to REPLACE the camera line
|
||||
|
||||
Final test on the DS-2CD1047G3H-LIU snapshot 503: swept every channel/stream ID against
|
||||
`GET .../channels/<id>/picture`, including nonexistent ones (1, 100, 103, 201, 999). Every single
|
||||
ID returns the identical `503 deviceBusy` body EXCEPT exactly `102` (the real sub-stream), which is
|
||||
always 200. A real busy/saturated encoder would not succeed on one specific value while failing
|
||||
garbage IDs identically — this is a generic fallback error: the firmware's snapshot handler is only
|
||||
correctly wired for channel 102, and everything else (valid main-stream 101 included) falls through
|
||||
to a stock, mislabeled "Device Busy" response. Confirms the firmware-upgrade non-result from
|
||||
earlier today (a wrong-code-path bug wouldn't be fixed by more capacity). Owner's decision: replace
|
||||
the DS-2CD1047G3H-LIU units rather than carry an RTSP/ffmpeg workaround dependency — the sibling
|
||||
DS-2CD1043G2-LIU (no such bug, ISAPI main-stream snapshot works natively) is the reference model
|
||||
going forward. RTSP main-stream capture remains documented as a proven, viable fallback if a G3H
|
||||
camera is ever unavoidable, but is not being built. Full sweep table + reasoning on [[lpr-camera]].
|
||||
|
||||
## [2026-08-30] update | Booth USB printer cover-open bug: leading theory is a stale container bind-mount, not a stale app-layer handle
|
||||
|
||||
Live troubleshooting request (park-buzi): opening the printer's paper-roll cover reliably wedges its
|
||||
status to offline/faulty, surviving a full appliance reboot; only `docker restart server` clears it.
|
||||
Traced `sendRawUsb`/`probeUsb` end-to-end in `printer-escpos.ts` plus both poll loops
|
||||
(`device-monitor.ts`, `printer-monitor.ts`): every print AND every poll does a fresh
|
||||
open→write/probe→close with no persistent fd/socket/driver instance anywhere — ruling out a naive
|
||||
"stale Node handle" explanation. Leading hypothesis instead: the cover-open microswitch cuts power
|
||||
to the printer's USB interface board, causing a real bus re-enumeration; the container's directory
|
||||
bind-mount of `/dev/usb` (chosen specifically to survive `lpN` renumbering) can retain a stale view
|
||||
of the old device node until the container's mount namespace is recreated — which `docker restart`
|
||||
does and a policy-driven reboot-time restart may not (boot-order race). Not yet confirmed on
|
||||
hardware (host-vs-container `stat`/inode comparison at the next occurrence is the next step); lab
|
||||
repro is blocked because the lab has a RONGTA, not the park-buzi unit's actual (still unidentified,
|
||||
"Generic (unknown)") model. Full writeup, confirmation commands, and candidate fixes on
|
||||
[[printer-usb-transport]].
|
||||
|
||||
## [2026-08-30] update | Backup status "Never" despite valid rotating backups — restart amnesia in BackupService, fixed
|
||||
|
||||
Admin noticed park-buzi's Backup screen showed "last successful backup: Never" despite 7 real,
|
||||
correctly-rotating encrypted backup files on disk, plus a 2-day gap since the last file. Traced
|
||||
both symptoms to the same cause: `BackupService` tracked last-success/last-error as PLAIN
|
||||
IN-PROCESS FIELDS (never written to the DB), and the daily schedule was a `setInterval(...,24h)`
|
||||
measured from PROCESS START, not wall-clock time since the last real backup — so any server
|
||||
restart (routine under `restart: always`: deploy/crash/OOM/host reboot) simultaneously wiped the
|
||||
visible status back to "Never" and reset the 24h countdown, independent of the actual
|
||||
file-writing/retention engine (`backup.ts`), which was working correctly the whole time and
|
||||
explains why files existed on disk despite the UI's contradictory-seeming status. Fix: four new
|
||||
nullable `site_config` columns (migration `0025_backup_last_status.sql`) persist last-success/
|
||||
error there instead of in memory; `BackupService.status()` reads them fresh each call so a new
|
||||
instance (= a restart) sees the prior instance's outcome; a new `isDue()` method computes
|
||||
schedule-due-ness from the persisted last-success timestamp; `server.ts`'s scheduler is now a
|
||||
15-minute poll gated by `isDue()` instead of a 24h `setInterval`, making the real cadence immune
|
||||
to restart timing. New test file `backup-service.test.ts` (6 tests) covers restart-durability and
|
||||
`isDue()` directly; full existing suite (319 tests) still green. No API/UI contract change. Not
|
||||
yet committed (holding per instruction). Full writeup on [[backup-recovery]].
|
||||
|
||||
## [2026-08-30] update | Two Komodo Periphery gotchas: connect_as renaming, agent upgrade procedure
|
||||
|
||||
Two real incidents this session, both closed out as new gotchas (#12, #13) on
|
||||
[[appliance-provisioning]] §7: (1) a lab box installed with a leftover template placeholder
|
||||
left in `--connect-as` kept reappearing under that name in Core no matter how many times it was
|
||||
renamed in the UI — because `connect_as` is a plain field in the agent's own
|
||||
`periphery.config.toml`, and a Core-UI rename never touches it; fixed by editing the field
|
||||
directly on the host + `systemctl --user restart periphery`, no reinstall needed. (2) Upgrading
|
||||
Periphery from a version-mismatch (Core bumped to v2.3.2, an agent still on v2.2.0) has no
|
||||
separate update mechanism — confirmed against Komodo's own `setup-periphery.py` source that
|
||||
re-running the same installer with unchanged `--connect-as` is config-preserving (it explicitly
|
||||
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]].
|
||||
|
||||
## [2026-09-03] fix | Desktop login "Load failed": WebKit mixed-content, not CORS/CSP
|
||||
|
||||
After fixing VITE_API_BASE, login still failed with WebKit's generic "Load failed" — a raw browser
|
||||
fetch() rejection with no server-side trace, since the request never reached the network. Root
|
||||
cause: WebKitGTK treats tauri://localhost as a secure origin, so http://127.0.0.1:3000 (and
|
||||
ws://127.0.0.1:3000) from inside it is blocked as mixed content — a known WebKit limitation, NOT
|
||||
fixable via CSP connect-src. Fixed by routing both through Tauri plugins that use the native (Rust)
|
||||
HTTP/WS client instead of the webview's own: tauri-plugin-http (a genuine fetch() drop-in, wired
|
||||
into api.ts/logger.ts via a new platformFetch() in origin.ts) and tauri-plugin-websocket (NOT a
|
||||
drop-in — async/listener API — adapted behind a native-WebSocket-shaped interface in the new
|
||||
platform-ws.ts so use-live-feed.ts needed no changes). Full detail on [[desktop-shell-tauri]].
|
||||
|
||||
## [2026-09-03] fix | Desktop live feed offline: native WS plugin sends no Origin, prod allowlist was empty
|
||||
|
||||
Login worked after the mixed-content fix, but the live feed showed offline in the desktop app while
|
||||
the browser showed LIVE, same server. tauri-plugin-websocket's connect() runs on Tauri's Rust side,
|
||||
not inside the webview page, so it never auto-attaches an Origin header — routes/ws.ts's anti-CSWSH
|
||||
check treats a missing Origin as untrusted and 403s before auth. Compounded by a second, independent
|
||||
gap: komodo/resources.toml's booth Stacks had WS_ALLOWED_ORIGINS= empty in production, despite
|
||||
.env.example documenting tauri://localhost as required for the desktop app. Fixed both: platform-ws.ts
|
||||
now passes Origin: tauri://localhost explicitly in connect()'s headers; resources.toml's two Stacks
|
||||
get the real allowlist. Needs a Komodo sync + redeploy to reach a live booth, not just a git push.
|
||||
Also confirmed the "update downloads then nothing happens" report was an older pre-fix build (v0.1.2)
|
||||
self-updating — expected, not a new bug; v0.1.3 carries the error-logging fix from the mixed-content
|
||||
commit and should surface a real error going forward. Full detail on [[desktop-shell-tauri]].
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#### HIKVISION DS-2CD1047G3H-LIU-F 4 MP ColorVu 3.0 Fixed Bullet Network Camera
|
||||
|
||||

|
||||
|
||||
#### FEATURE
|
||||
|
||||
- HikAI-ISP for excellent noise reduction effect
|
||||
- Super clear 24/7 colorful imaging with ColorVu 3.0 technology
|
||||
- Motion Detection 3.0, more accurate on person and vehicle classification
|
||||
- Strobe Light & Audio Alarm to deter intruders (optional)
|
||||
- Two-Way Audio via camera and Hik-Connect
|
||||
- Smart Hybrid Light: integrates IR and white lights, 3 supplemental lighting modes
|
||||
- On-board storage with SD card up to 512 GB (optional)
|
||||
- Water and dust resistant (IP67)
|
||||
|
||||
#### Specification
|
||||
|
||||
<table><tbody><tr><td colspan="2" width="681">n <strong>Specification</strong></td></tr><tr><td colspan="2" width="681"><strong>Camera</strong></td></tr><tr><td width="194">Max. Resolution</td><td width="487">2560 × 1440</td></tr><tr><td width="194">Min. Illumination</td><td width="487">Color: 0.0001 Lux @ (F1.0, AGC ON)</td></tr><tr><td width="194">Shutter Time</td><td width="487">1 s to 1/100,000 s</td></tr><tr><td width="194">Day & Night</td><td width="487">IR cut filter</td></tr><tr><td width="194">Angle Adjustment</td><td width="487">Pan: 0° to 360°,tilt: 0° to 90°,rotate: 0° to 360°</td></tr><tr><td colspan="2" width="681"><strong>Lens</strong></td></tr><tr><td width="194">Lens Type</td><td width="487">Fixed focal lens, 2.8 and 4 mm optional</td></tr><tr><td width="194">Focal Length & FOV</td><td width="487">2.8 mm, horizontal FOV 104°, vertical FOV 54.4°, diagonal FOV 126.7°<p>4 mm, horizontal FOV 89.3°, vertical FOV 48.2°, diagonal FOV 106.5°</p></td></tr><tr><td width="194">Lens Mount</td><td width="487">M16</td></tr><tr><td width="194">Iris Type</td><td width="487">Fixed</td></tr><tr><td width="194">Aperture</td><td width="487">F1.0</td></tr><tr><td width="194">Depth of Field</td><td width="487">2.8 mm: 2.5 m to ∞<p>4 mm: 2.8 m to ∞</p></td></tr><tr><td colspan="2" width="681"><strong>DORI</strong></td></tr><tr><td width="194">DORI</td><td width="487">2.8 mm, D: 61 m, O: 24 m, R: 12 m, I: 6 m<p>4 mm, D: 68 m, O: 27 m, R: 13 m, I: 6 m</p></td></tr><tr><td colspan="2" width="681"><strong>Illuminator</strong></td></tr><tr><td width="194">Supplement Light Type</td><td width="487">IR,White Light</td></tr><tr><td width="194">Supplement Light Range</td><td width="487">IR: up to 30 m<p>White Light: up to 20 m</p></td></tr><tr><td width="194">Smart Supplement Light</td><td width="487">Yes</td></tr><tr><td width="194">IR Wavelength</td><td width="487">850 nm</td></tr><tr><td colspan="2" width="681"><strong>Video</strong></td></tr><tr><td width="194"><strong> </strong><p><strong> </strong></p><p>Main Stream</p></td><td width="487">50 Hz:<p>25 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p><p>60 Hz:</p><p>24 fps (2560 × 1440, 1920 × 1080, 1280 × 720)</p></td></tr><tr><td width="194">Sub-Stream</td><td width="487">50 Hz: 25 fps (768 × 432, 640 × 360)<p>60 Hz: 24 fps (768 × 432, 640 × 360)</p></td></tr><tr><td width="194">Video Compression</td><td width="487">Main stream: H.265+/H.265/H.264+/H.264,<p>Sub-stream: H.265/H.264/MJPEG</p></td></tr><tr><td width="194">Video Bit Rate</td><td width="487">32 Kbps to 16 Mbps</td></tr><tr><td width="194">H.264 Type</td><td width="487">Baseline Profile,Main Profile,High Profile</td></tr><tr><td width="194">H.265 Type</td><td width="487">Main Profile</td></tr><tr><td width="194">Bit Rate Control</td><td width="487">CBR,VBR</td></tr><tr><td width="194">Scalable Video Coding (SVC)</td><td width="487">H.264 and H.265 encoding</td></tr><tr><td width="194">Region of Interest (ROI)</td><td width="487">1 fixed region for main stream</td></tr><tr><td colspan="2" width="681"><strong>Audio</strong></td></tr><tr><td width="194">Audio Type</td><td width="487">Mono sound</td></tr><tr><td width="194">Audio Compression</td><td width="487">G.711/G.722.1/G.726/MP2L2/PCM/MP3/AAC-LC</td></tr><tr><td width="194">Audio Bit Rate</td><td width="487">64 Kbps (G.711)/16 Kbps (G.722.1)/16 Kbps (G.726)/32 to 160 Kbps (MP2L2)/16 to 64<p>Kbps (AAC-LC)</p></td></tr><tr><td width="194">Audio Sampling Rate</td><td width="487">8 kHz/16 kHz</td></tr></tbody></table>
|
||||
|
||||
<table><tbody><tr><td width="199">Environment Noise Filtering</td><td width="482">Yes</td></tr><tr><td colspan="2" width="681"><strong>Network</strong></td></tr><tr><td width="199">Protocols</td><td width="482">TCP/IP, ICMP, DHCP, DNS, HTTP, RTP, RTSP, RTCP, NTP, IPv4, IPv6, IGMP, UDP, QoS,<p>FTP, SMTP</p></td></tr><tr><td width="199">Simultaneous Live View</td><td width="482">Up to 6 channels</td></tr><tr><td width="199">API</td><td width="482">ONVIF (Profile S, Profile G),ISAPI,SDK</td></tr><tr><td width="199">User/Host</td><td width="482">Up to 32 users<p>3 user levels: administrator, operator, and user</p></td></tr><tr><td width="199"><strong> </strong><p>Security</p></td><td width="482">Password protection, complicated password, watermark, basic and digest<p>authentication for HTTP, WSSE and digest authentication for Open Network Video</p><p>Interface, security audit log, host authentication (MAC address)</p></td></tr><tr><td width="199">Client</td><td width="482">iVMS-4200,Hik-Connect</td></tr><tr><td width="199">Web Browser</td><td width="482">Plug-in required live view: Chrome 80+, Firefox 80+, Edge 89+, Safari 13+,<p>Plug-in free live view: Chrome 80+, Firefox 80+, Edge 89+</p></td></tr><tr><td colspan="2" width="681"><strong>Image</strong></td></tr><tr><td width="199">Image Settings</td><td width="482">Rotate mode,saturation,brightness,contrast,sharpness,gain,white balance,adjustable<p>by client software or web browser</p></td></tr><tr><td width="199">Day/Night Switch</td><td width="482">Day,Night,Auto,Schedule</td></tr><tr><td width="199">Wide Dynamic Range (WDR)</td><td width="482">120 dB</td></tr><tr><td width="199">SNR</td><td width="482">≥ 52 dB</td></tr><tr><td width="199">Image Enhancement</td><td width="482">BLC,HLC,3D DNR</td></tr><tr><td width="199">Privacy Mask</td><td width="482">4 programmable polygon privacy masks</td></tr><tr><td colspan="2" width="681"><strong>Interface</strong></td></tr><tr><td width="199">Ethernet Interface</td><td width="482">1 RJ45 10 M/100 M self-adaptive Ethernet port</td></tr><tr><td width="199"><strong> </strong><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p>On-Board Storage</p></td><td width="482">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF: Yes<p>-LIU: NA</p></td></tr><tr><td width="199">Built-in Microphone</td><td width="482">Yes,1 built-in microphone</td></tr></tbody></table>
|
||||
|
||||
| Built-in Speaker | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB: Yes \-LIU/LIUF: NA |
|
||||
| --- | --- |
|
||||
| Audio | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: NA |
|
||||
| Alarm | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: NA |
|
||||
| Reset Key | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB/LIUF: Yes, \-LIU: NA |
|
||||
|
||||
<table><tbody><tr><td colspan="2" width="681"><strong>Event</strong></td></tr><tr><td width="192">Basic Event</td><td width="489">Motion detection (support alarm triggering by specified target types (human and<p>vehicle)),video tampering alarm,exception</p></td></tr><tr><td width="192">Linkage</td><td width="489">Upload to FTP,notify surveillance center,send email,trigger recording,trigger capture</td></tr><tr><td colspan="2" width="681"><strong>General</strong></td></tr><tr><td width="192">Power</td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 12 VDC ± 25%, 0.58 A, max. 7 W, Ø5.5 mm<p>coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 9 W LIUF: 12 VDC ± 25%, 0.67 A, max. 8 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 10 W</p><p>-SL/SRB: 12 VDC ± 25%, 0.84 A, max. 10.1 W, Ø5.5 mm coaxial power plug, reverse polarity protection, PoE: IEEE 802.3af, Class 3,max. 12.1 W</p><p> </p></td></tr><tr><td width="192"><strong> </strong><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p><strong> </strong></p><p>Dimension</p></td><td width="489">DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: 69.1 mm × 66.9 mm × 172.9 mm (2.7″ × 2.6″ ×<p>6.8″)</p><p>-LIUF/SL/SRB: 69.1 mm × 67.4 mm × 179 mm (2.7″ × 2.7″ × 7.1″)</p><p> </p></td></tr><tr><td width="192">Package Dimension</td><td width="489">210 mm × 116 mm × 106 mm (8.3″ × 4.6″ × 4.2″)</td></tr></tbody></table>
|
||||
|
||||
| Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 310 g (0.7 lb.) \-LIUF: Approx. 315 g (0.7 lb.) \-SL/SRB: Approx. 325 g (0.7 lb.) |
|
||||
| --- | --- |
|
||||
| With Package Weight | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-LIU: Approx. 465 g (1.0 lb.) \-LIUF: Approx. 470 g (1.0 lb.) \-SL/SRB: Approx. 495 g (1.1 lb.) |
|
||||
| Storage Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
|
||||
| Startup and Operating Conditions | \-30 °C to 60 °C (-22 °F to 140 °F). Humidity 95% or less (non-condensing) |
|
||||
| Language | English, Russian, Ukrainian, Arabic, Spanish, French, Portuguese, Turkish, Polish, German |
|
||||
| General Function | Heartbeat,mirror,flash log,password reset via email,password protection,anti -banding |
|
||||
| Flashing Light | DS-2CD1047G3H-LIU(F)(/SL)(/SRB):-SL/SRB: Yes \-LIU/LIUF: NA |
|
||||
|
||||
<table><tbody><tr><td colspan="2" width="681"><strong>Approval</strong></td></tr><tr><td width="194">EMC</td><td width="487">CE-EMC: EN 55032:2015+A1:2020, EN 50130-4:2011+A1:2014, EN IEC<p>61000-3-2:2019+A1:2021, EN 61000-3-3:2013+A1:2019+A2:2021</p></td></tr><tr><td width="194">Safety</td><td width="487">CB: IEC 62368-1: 2014+A11,<p>CE-LVD: EN 62368-1: 2014/A11: 2017</p></td></tr><tr><td width="194">Environment</td><td width="487">CE-RoHS: 2011/65/EU,<p>WEEE: 2012/19/EU</p></td></tr><tr><td width="194">Protection</td><td width="487">IP67: IEC 60529-2013</td></tr></tbody></table>
|
||||
|
||||
#### Available Model
|
||||
|
||||
- DS-2CD1047G3H-LIUF/SRB(2.8mm)
|
||||
- DS-2CD1047G3H-LIUF/SRB(4mm)
|
||||
- DS-2CD1047G3H-LIU(2.8mm)
|
||||
- DS-2CD1047G3H-LIU(4mm)
|
||||
- DS-2CD1047G3H-LIUF(2.8mm)
|
||||
- DS-2CD1047G3H-LIUF(4mm)
|
||||
- DS-2CD1047G3H-LIUF/SL(2.8mm)
|
||||
- DS-2CD1047G3H-LIUF/SL(4mm)
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: source
|
||||
tags: [parking, hardware, cameras, hikvision, datasheet]
|
||||
sources: [DS-2CD1047G3H-LIU]
|
||||
updated: 2026-08-23
|
||||
---
|
||||
|
||||
# Source: HIKVISION DS-2CD1047G3H-LIU-F datasheet
|
||||
|
||||
Vendor spec sheet (`raw/DS-2CD1047G3H-LIU.md`) for the **DS-2CD1047G3H-LIU** — the exit-lane
|
||||
camera at `10.0.10.13` (park-buzi) already covered extensively in [[lpr-camera]] for its
|
||||
persistent main-stream ISAPI 503 (`deviceBusy`) and its 2026-06-27 config-DB-corruption incident.
|
||||
This is the first *vendor-sourced* spec data for the model; everything before it was field-derived.
|
||||
|
||||
## Key takeaways
|
||||
|
||||
- **4 MP, max resolution 2560×1440** (ColorVu 3.0 — color night imaging, not IR-only).
|
||||
- **Main stream: H.265+/H.265/H.264+/H.264 only — no MJPEG option.** **Sub-stream: H.265/H.264/
|
||||
MJPEG** (MJPEG is sub-only). This is a concrete, spec-level asymmetry that plausibly explains
|
||||
*why* the ISAPI on-demand JPEG snapshot is reliable on sub but structurally broken on main: sub
|
||||
can serve a snapshot natively, main has to transcode out of H.264/H.265 on demand. Consistent
|
||||
with — and a likely root cause for — the persistent `deviceBusy` behavior already logged in
|
||||
[[lpr-camera]].
|
||||
- **Bit rate range 32 Kbps–16 Mbps.** The site's main-stream config (6144–12288 Kbps, confirmed
|
||||
live 2026-08-23) is well inside spec — rules out "misconfigured over the camera's own ceiling"
|
||||
as a cause, confirming what live testing already showed.
|
||||
- **ROI: 1 fixed region for main stream** — another main-only constraint/asymmetry vs. sub.
|
||||
- **API: ONVIF (Profile S, Profile G), ISAPI, SDK.** ISAPI is fully in-spec for this model — the
|
||||
503 is a real firmware/hardware limitation, not a case of using an unsupported API.
|
||||
- **Protocols include RTSP** (alongside HTTP/ONVIF/etc.) — confirms RTSP is a documented, supported
|
||||
surface on this model, not a workaround outside its design.
|
||||
- Simultaneous live view: up to 6 channels. Available in `-LIU`/`-LIUF`/`-LIUF/SL`/`-LIUF/SRB`
|
||||
variants at 2.8mm/4mm focal lengths; the deployed unit is the base `-LIU`.
|
||||
|
||||
## How this changes the picture
|
||||
|
||||
Confirms rather than overturns [[lpr-camera]]'s existing conclusion (sub-stream-only for ISAPI
|
||||
snapshots on this model) — but gives it a concrete mechanical explanation (main has no MJPEG path)
|
||||
instead of just an empirically-observed limitation. See [[lpr-camera]] § "Main-stream ISAPI
|
||||
snapshot 503 vs. RTSP" for the 2026-08-23 live comparison against a `DS-2CD1043G2-LIU` sibling
|
||||
(main-stream ISAPI snapshot works fine there) and the confirmed RTSP frame-grab workaround.
|
||||
Reference in New Issue
Block a user