@trillboards/edge-sdk 0.2.52 → 0.2.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ What lands on disk (home-domain, **no sudo / no admin password**):
19
19
  edge-sdk), WITHOUT the heavy local-inference stack
20
20
  share/com.trillboards.edge.plist LaunchAgent template
21
21
  share/ensure-litertlm.sh background local-AI retry supervisor
22
+ share/apply-update.sh signed-pkg updater with rollback
22
23
  share/models/gemma-4-E2B-it.litertlm required customer local VLM (removed after import)
23
24
  ~/Applications/Trillboards CCTV Setup.app native customer setup window (opened automatically)
24
25
  ~/Library/Application Support/Trillboards/trillboards.config.json (after pair, 0600)
@@ -31,14 +32,15 @@ bootstrapped automatically on first successful pair. The device token is
31
32
  delivered **only by pairing** — it is never baked into the `.pkg`.
32
33
 
33
34
  > The customer build includes Gemma and completes its local health check before
34
- > pairing. Cloud inference requires explicit configuration. `--skip-model` is
35
- > available only for engineering checks and must not be published.
35
+ > pairing. Cloud inference requires explicit configuration. The release lane
36
+ > also builds a signed, notarized `--skip-model` package for automatic upgrades;
37
+ > it is accepted only on an already-provisioned machine whose model is healthy.
36
38
 
37
39
  ---
38
40
 
39
41
  ## Install (operator)
40
42
 
41
- 1. Download `TrillboardsEdge-<version>-arm64.pkg`.
43
+ 1. Download `SenseAnythingConnector.pkg`.
42
44
  2. Double-click it. The installer offers **Install for me only** (home-domain) —
43
45
  no admin password. (Equivalent CLI: `installer -pkg <pkg> -target
44
46
  CurrentUserHomeDirectory`.)
@@ -73,7 +75,7 @@ launchctl kickstart -k "gui/$(id -u)/com.trillboards.edge"
73
75
  cd trillboards-edge-sdk
74
76
  npm install # ensures ffmpeg-static is fetched
75
77
  cd packages/edge-sdk/deploy/macos
76
- ./build-macos-pkg.sh # → dist-installer/TrillboardsEdge-<ver>-arm64.pkg
78
+ ./build-macos-pkg.sh # → dist-installer/SenseAnythingConnector.pkg
77
79
  ```
78
80
 
79
81
  Options:
@@ -83,13 +85,26 @@ Options:
83
85
  | `--arch arm64\|x64` | host arch | target architecture |
84
86
  | `--node-version vX.Y.Z` | `v22.22.0` | pinned Node runtime to bundle |
85
87
  | `--bundle-model` | on | include the pinned ~2.4 GB Gemma model required by customer setup |
86
- | `--skip-model` | off | build a slim engineering artifact that must not be published to customers |
88
+ | `--skip-model` | off | build the slim code-only upgrade package; it is not a valid first installer |
87
89
  | `--offline` | off | skip the official Node download; copy the host `node` |
88
90
  | `--sign-installer "Developer ID Installer: …"` | unset | `productsign` the pkg |
89
91
 
90
92
  The build is idempotent (it wipes its staging tree each run) and **smoke-tests
91
93
  the staged bundle** (`trillboards-edge version`) before packaging.
92
94
 
95
+ ### Automatic updates
96
+
97
+ Every paired connector checks the macOS version manifest at launch and every
98
+ six hours. Once a release has baked for six hours, the connector downloads the
99
+ content-addressed code-only package while sensing remains online. Before it can
100
+ be staged, macOS must validate the exact Aethercorp Developer ID Installer
101
+ identity, the notarization staple, Gatekeeper acceptance, size, and SHA-256.
102
+ The connector waits for a safe sensing point, exits cleanly, installs the
103
+ package, smoke-tests the new version, and relaunches the LaunchAgent. The old
104
+ tree remains as `~/Library/Trillboards/edge.previous` and is restored
105
+ automatically if installation, smoke testing, or relaunch fails. Existing
106
+ pairing and the local model are preserved; the setup window does not reopen.
107
+
93
108
  ### Local end-to-end test
94
109
 
95
110
  ```bash
@@ -121,9 +136,9 @@ To ship a Gatekeeper-clean installer, on a machine that has the
121
136
  # (or: export MAC_INSTALLER_IDENTITY="Developer ID Installer: …" && ./build-macos-pkg.sh)
122
137
 
123
138
  # 2. Notarize + staple
124
- xcrun notarytool submit dist-installer/TrillboardsEdge-<ver>-arm64.pkg \
139
+ xcrun notarytool submit dist-installer/SenseAnythingConnector.pkg \
125
140
  --keychain-profile <notary-profile> --wait
126
- xcrun stapler staple dist-installer/TrillboardsEdge-<ver>-arm64.pkg
141
+ xcrun stapler staple dist-installer/SenseAnythingConnector.pkg
127
142
  ```
128
143
 
129
144
  The bundled `bin/node` and `bin/ffmpeg` are third-party Mach-O binaries; for full
@@ -0,0 +1,225 @@
1
+ #!/bin/bash
2
+ #
3
+ # Stage and apply the signed, notarized macOS connector update package.
4
+ # The running connector chooses the safe point; this detached helper owns the
5
+ # download, macOS trust checks, LaunchAgent handoff, swap, smoke test, and
6
+ # `.previous` rollback.
7
+
8
+ set -u
9
+
10
+ LABEL='com.trillboards.edge'
11
+ EXPECTED_SIGNER='Developer ID Installer: Aethercorp Inc (78JQ7882U9)'
12
+ MODE=''
13
+ INSTALL_DIR=''
14
+ UPDATE_URL=''
15
+ EXPECTED_VERSION=''
16
+ CURRENT_VERSION=''
17
+ EXPECTED_SHA256=''
18
+ EXPECTED_SIZE=''
19
+ WAIT_PID=''
20
+
21
+ while [ "$#" -gt 0 ]; do
22
+ case "$1" in
23
+ --stage|--commit) MODE="$1"; shift ;;
24
+ --install-dir) INSTALL_DIR="${2:-}"; shift 2 ;;
25
+ --update-url) UPDATE_URL="${2:-}"; shift 2 ;;
26
+ --expected-version) EXPECTED_VERSION="${2:-}"; shift 2 ;;
27
+ --current-version) CURRENT_VERSION="${2:-}"; shift 2 ;;
28
+ --expected-sha256) EXPECTED_SHA256="${2:-}"; shift 2 ;;
29
+ --expected-size) EXPECTED_SIZE="${2:-}"; shift 2 ;;
30
+ --wait-pid) WAIT_PID="${2:-}"; shift 2 ;;
31
+ *) printf '[apply-update] unknown argument: %s\n' "$1" >&2; exit 2 ;;
32
+ esac
33
+ done
34
+
35
+ log() {
36
+ /usr/bin/logger -t com.trillboards.edge "apply-update: $*" 2>/dev/null || true
37
+ printf '[apply-update] %s\n' "$*"
38
+ }
39
+
40
+ case "$EXPECTED_VERSION" in
41
+ [0-9]*.[0-9]*.[0-9]*) ;;
42
+ *) log 'invalid expected version'; exit 2 ;;
43
+ esac
44
+
45
+ # The updater is intentionally scoped to the one home-domain path the package
46
+ # installs. This makes every later rm/mv target resolved and bounded.
47
+ EXPECTED_INSTALL_DIR="${HOME}/Library/Trillboards/edge"
48
+ if [ "$INSTALL_DIR" != "$EXPECTED_INSTALL_DIR" ]; then
49
+ log "refusing unexpected install root: $INSTALL_DIR"
50
+ exit 2
51
+ fi
52
+
53
+ PARENT_DIR="$(dirname "$INSTALL_DIR")"
54
+ UPDATE_DIR="$PARENT_DIR/.edge-auto-update"
55
+ ARCHIVE="$UPDATE_DIR/connector-update.pkg"
56
+ MARKER="$UPDATE_DIR/staged.json"
57
+ OUTCOME="$UPDATE_DIR/outcome.json"
58
+ LOCK_DIR="$UPDATE_DIR/lock"
59
+ PREVIOUS="$PARENT_DIR/edge.previous"
60
+ PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
61
+
62
+ mkdir -p "$UPDATE_DIR" || exit 1
63
+ if ! mkdir "$LOCK_DIR" 2>/dev/null; then
64
+ log 'another update transaction is already running'
65
+ exit 0
66
+ fi
67
+ trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
68
+
69
+ json_escape() {
70
+ printf '%s' "$1" | /usr/bin/sed 's/\\/\\\\/g; s/"/\\"/g'
71
+ }
72
+
73
+ write_outcome() {
74
+ local result="$1" detail="$2" tmp="$OUTCOME.tmp.$$"
75
+ printf '{"attemptedAt":"%s","result":"%s","detail":"%s"}\n' \
76
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$result" "$(json_escape "$detail")" > "$tmp" \
77
+ && mv -f "$tmp" "$OUTCOME"
78
+ }
79
+
80
+ fail_stage() {
81
+ log "$1"
82
+ rm -f "$ARCHIVE.download" "$MARKER.tmp.$$"
83
+ exit 1
84
+ }
85
+
86
+ configure_proxy() {
87
+ if [ -n "${HTTPS_PROXY:-}" ] || [ -n "${https_proxy:-}" ] || [ -n "${ALL_PROXY:-}" ]; then
88
+ return
89
+ fi
90
+ local proxy_conf enabled host port
91
+ proxy_conf="$(/usr/sbin/scutil --proxy 2>/dev/null || true)"
92
+ enabled="$(printf '%s\n' "$proxy_conf" | /usr/bin/awk '/HTTPSEnable/ {print $3}')"
93
+ [ "$enabled" = '1' ] || return
94
+ host="$(printf '%s\n' "$proxy_conf" | /usr/bin/awk '/HTTPSProxy/ {print $3}')"
95
+ port="$(printf '%s\n' "$proxy_conf" | /usr/bin/awk '/HTTPSPort/ {print $3}')"
96
+ [ -n "$host" ] || return
97
+ HTTPS_PROXY="http://$host${port:+:$port}"
98
+ export HTTPS_PROXY HTTP_PROXY="$HTTPS_PROXY"
99
+ }
100
+
101
+ verify_pkg() {
102
+ local pkg="$1" sha="$2" size="$3" actual_size actual_sha sig_out
103
+ [ -f "$pkg" ] || return 1
104
+ case "$size" in *[!0-9]*|'') return 1 ;; esac
105
+ printf '%s' "$sha" | /usr/bin/grep -Eq '^[0-9a-f]{64}$' || return 1
106
+
107
+ actual_size="$(/usr/bin/wc -c < "$pkg" | tr -d ' ')"
108
+ [ "$actual_size" = "$size" ] || { log "payload size mismatch: expected $size, got $actual_size"; return 1; }
109
+ actual_sha="$(/usr/bin/shasum -a 256 "$pkg" | /usr/bin/awk '{print $1}')"
110
+ [ "$actual_sha" = "$sha" ] || { log 'payload SHA-256 mismatch'; return 1; }
111
+
112
+ sig_out="$UPDATE_DIR/signature.txt"
113
+ /usr/sbin/pkgutil --check-signature "$pkg" > "$sig_out" 2>&1 || { log 'package signature is invalid'; return 1; }
114
+ /usr/bin/grep -Fq "$EXPECTED_SIGNER" "$sig_out" || { log 'package signer is not Aethercorp'; return 1; }
115
+ /usr/bin/xcrun stapler validate "$pkg" >/dev/null 2>&1 || { log 'package has no valid notarization staple'; return 1; }
116
+ /usr/sbin/spctl -a -vv --type install "$pkg" >/dev/null 2>&1 || { log 'Gatekeeper rejected the update package'; return 1; }
117
+ return 0
118
+ }
119
+
120
+ if [ "$MODE" = '--stage' ]; then
121
+ case "$UPDATE_URL" in https://*) ;; *) fail_stage 'update URL must use HTTPS' ;; esac
122
+ configure_proxy
123
+ log "downloading connector $EXPECTED_VERSION while sensing stays online"
124
+ /usr/bin/curl --fail --location --silent --show-error --proto '=https' --tlsv1.2 \
125
+ --connect-timeout 30 --max-time 3600 --output "$ARCHIVE.download" "$UPDATE_URL" \
126
+ || fail_stage 'update download failed'
127
+ verify_pkg "$ARCHIVE.download" "$EXPECTED_SHA256" "$EXPECTED_SIZE" \
128
+ || fail_stage 'update package verification failed'
129
+ mv -f "$ARCHIVE.download" "$ARCHIVE" || fail_stage 'could not retain verified package'
130
+ marker_tmp="$MARKER.tmp.$$"
131
+ printf '{"version":"%s","currentVersion":"%s","sha256":"%s","sizeBytes":%s}\n' \
132
+ "$EXPECTED_VERSION" "$CURRENT_VERSION" "$EXPECTED_SHA256" "$EXPECTED_SIZE" > "$marker_tmp" \
133
+ || fail_stage 'could not write staged marker'
134
+ mv -f "$marker_tmp" "$MARKER" || fail_stage 'could not publish staged marker'
135
+ log "connector $EXPECTED_VERSION is staged, signed, notarized, and verified"
136
+ exit 0
137
+ fi
138
+
139
+ if [ "$MODE" != '--commit' ]; then
140
+ log 'exactly one of --stage or --commit is required'
141
+ exit 2
142
+ fi
143
+
144
+ marker_version="$(/usr/bin/plutil -extract version raw "$MARKER" 2>/dev/null || true)"
145
+ marker_sha="$(/usr/bin/plutil -extract sha256 raw "$MARKER" 2>/dev/null || true)"
146
+ marker_size="$(/usr/bin/plutil -extract sizeBytes raw "$MARKER" 2>/dev/null || true)"
147
+ if [ "$marker_version" != "$EXPECTED_VERSION" ]; then
148
+ write_outcome 'rejected' 'staged version did not match the requested version'
149
+ log 'staged version mismatch; refusing update'
150
+ exit 1
151
+ fi
152
+ verify_pkg "$ARCHIVE" "$marker_sha" "$marker_size" || {
153
+ write_outcome 'rejected' 'staged package failed commit-time verification'
154
+ rm -f "$MARKER" "$ARCHIVE"
155
+ exit 1
156
+ }
157
+
158
+ # The connector launches us and then exits cleanly. Do not touch its mapped
159
+ # binaries until that PID is gone; a timeout leaves the current install intact.
160
+ if [ -n "$WAIT_PID" ]; then
161
+ waited=0
162
+ while kill -0 "$WAIT_PID" 2>/dev/null && [ "$waited" -lt 120 ]; do
163
+ sleep 1
164
+ waited=$((waited + 1))
165
+ done
166
+ if kill -0 "$WAIT_PID" 2>/dev/null; then
167
+ write_outcome 'failed' 'connector did not exit before the update deadline'
168
+ log 'connector did not exit; leaving current install untouched'
169
+ exit 1
170
+ fi
171
+ fi
172
+
173
+ restart_agent() {
174
+ [ -f "$PLIST" ] || return 1
175
+ /bin/launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true
176
+ local attempt=1
177
+ while [ "$attempt" -le 10 ]; do
178
+ /bin/launchctl bootstrap "gui/$(id -u)" "$PLIST" >/dev/null 2>&1 || true
179
+ if /bin/launchctl print "gui/$(id -u)/$LABEL" >/dev/null 2>&1; then
180
+ /bin/launchctl kickstart -k "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true
181
+ return 0
182
+ fi
183
+ sleep 1
184
+ attempt=$((attempt + 1))
185
+ done
186
+ return 1
187
+ }
188
+
189
+ rollback() {
190
+ local detail="$1"
191
+ /bin/launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true
192
+ rm -rf "$INSTALL_DIR"
193
+ if [ -d "$PREVIOUS" ]; then
194
+ mv "$PREVIOUS" "$INSTALL_DIR" || true
195
+ fi
196
+ if restart_agent; then
197
+ write_outcome 'failed' "$detail; previous version restored"
198
+ else
199
+ write_outcome 'failed' "$detail; previous files restored but the agent did not restart"
200
+ fi
201
+ rm -f "$MARKER" "$ARCHIVE"
202
+ log "$detail; rolled back"
203
+ exit 1
204
+ }
205
+
206
+ log "applying connector $EXPECTED_VERSION"
207
+ /bin/launchctl bootout "gui/$(id -u)/$LABEL" >/dev/null 2>&1 || true
208
+ rm -rf "$PREVIOUS"
209
+ mv "$INSTALL_DIR" "$PREVIOUS" || {
210
+ write_outcome 'failed' 'could not preserve the current install for rollback'
211
+ exit 1
212
+ }
213
+
214
+ /usr/sbin/installer -pkg "$ARCHIVE" -target CurrentUserHomeDirectory >/dev/null 2>&1 \
215
+ || rollback 'macOS Installer rejected the update'
216
+
217
+ installed_version="$($INSTALL_DIR/bin/trillboards-edge version 2>/dev/null || true)"
218
+ printf '%s' "$installed_version" | /usr/bin/grep -Fq "edge-sdk v$EXPECTED_VERSION" \
219
+ || rollback 'updated connector failed its version smoke test'
220
+
221
+ restart_agent || rollback 'updated connector installed but its LaunchAgent did not restart'
222
+ write_outcome 'success' "updated from $CURRENT_VERSION to $EXPECTED_VERSION"
223
+ rm -f "$MARKER" "$ARCHIVE" "$UPDATE_DIR/signature.txt"
224
+ log "connector $EXPECTED_VERSION installed and relaunched"
225
+ exit 0
@@ -0,0 +1,140 @@
1
+ #!/bin/bash
2
+ #
3
+ # bench-local-vlm.sh — macOS twin of `deploy/windows/e2e/test-local-sense.ps1
4
+ # -Mode bench`: provision the PINNED litert-lm + Gemma 4 E2B, start a real
5
+ # serve, and MEASURE this machine's per-frame cost.
6
+ #
7
+ # WHY IT EXISTS. Local-only inference makes the box spec the product spec, and
8
+ # every latency number in the capacity plan came off one Apple M4 Max
9
+ # workstation — including the 7.30 s/frame in connectorCadence.ts that sizes the
10
+ # whole 493-analyses/hour budget. A 3-core / 7 GB Apple silicon runner is the
11
+ # closest CI has to the Mac mini we would actually recommend, and it is the only
12
+ # cheap way to test the "16 GB+ RAM" minimum the activation runbook publishes.
13
+ #
14
+ # It does NOT use setup-litertlm.sh's LaunchAgent path: that bootstraps a
15
+ # per-login agent, and a CI runner's session is not the session a customer logs
16
+ # into. The runtime, the pin and the model are identical; only the supervisor
17
+ # differs, and the supervisor is not what is being measured.
18
+ #
19
+ # NOTE ON BASH 3.2. macOS still ships bash 3.2 as /bin/bash and that is what a
20
+ # GitHub macOS runner executes. Its identifier scan is NOT UTF-8 aware, so an
21
+ # expansion written `$VAR…` swallows the ellipsis bytes into the variable NAME
22
+ # and `set -u` then reports an unbound variable whose name is the real one with
23
+ # mojibake stapled to it — which reads as "the variable is unset" when it is set.
24
+ # Every expansion here is braced and every log line is ASCII for that reason.
25
+ #
26
+ # Usage:
27
+ # deploy/macos/bench-local-vlm.sh [--port 9379] [--arms a,b] [--frames f]
28
+ # [--rounds 1] [--budget-min 40]
29
+ # [--frame-timeout-min 20] [--out FILE]
30
+ # [--model <non-default>.litertlm]
31
+ #
32
+ # Exit 0 = a matrix was written (even a partial one — a slow box is a RESULT).
33
+ # Exit 1 = nothing was measured at all.
34
+ set -uo pipefail
35
+
36
+ PORT=9379
37
+ ARMS='cpu-sop,cpu-dooh-sop,gpu-sop'
38
+ FRAMES='kitchen-5-people'
39
+ ROUNDS=1
40
+ BUDGET_MIN=40
41
+ FRAME_TIMEOUT_MIN=20
42
+ OUT="${TMPDIR:-/tmp}/bench-local-vlm.json"
43
+ NOTE=''
44
+ # A NON-default .litertlm id, for the per-silicon A/B. Empty = the generic file
45
+ # both provisioners import everywhere.
46
+ MODEL=''
47
+
48
+ while [ $# -gt 0 ]; do
49
+ case "$1" in
50
+ --port) PORT="$2"; shift 2 ;;
51
+ --arms) ARMS="$2"; shift 2 ;;
52
+ --frames) FRAMES="$2"; shift 2 ;;
53
+ --rounds) ROUNDS="$2"; shift 2 ;;
54
+ --budget-min) BUDGET_MIN="$2"; shift 2 ;;
55
+ --frame-timeout-min) FRAME_TIMEOUT_MIN="$2"; shift 2 ;;
56
+ --out) OUT="$2"; shift 2 ;;
57
+ --note) NOTE="$2"; shift 2 ;;
58
+ --model) MODEL="$2"; shift 2 ;;
59
+ *) echo "unknown flag: $1" >&2; exit 2 ;;
60
+ esac
61
+ done
62
+
63
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # <edge-sdk>/deploy/macos
64
+ PKG_ROOT="$(cd "$HERE/../.." && pwd)" # <edge-sdk>
65
+ WORKSPACE_ROOT="$(cd "$PKG_ROOT/../.." && pwd)" # <repo>/trillboards-edge-sdk
66
+ [ -f "$PKG_ROOT/scripts/bench-local-vlm.mjs" ] || { echo "FAIL - PKG_ROOT wrong: $PKG_ROOT" >&2; exit 1; }
67
+ [ -f "$WORKSPACE_ROOT/package.json" ] || { echo "FAIL - WORKSPACE_ROOT wrong: $WORKSPACE_ROOT" >&2; exit 1; }
68
+ log() { echo "[macos-bench] $*"; }
69
+ die() { echo "FAIL - $*" >&2; exit 1; }
70
+
71
+ # The PINNED version, read from the provisioner rather than restated here — the
72
+ # litert-lm-version-pin guard already keeps that one declaration honest, and a
73
+ # second copy would be a second thing to forget.
74
+ LITERT_LM_VERSION="$(sed -n 's/^LITERT_LM_VERSION="\${LITERT_LM_VERSION:-\([^}]*\)}"/\1/p' "$HERE/scripts/setup-litertlm.sh" | head -1)"
75
+ [ -n "$LITERT_LM_VERSION" ] || die "could not read the pinned litert-lm version from setup-litertlm.sh"
76
+ log "pinned litert-lm: $LITERT_LM_VERSION"
77
+
78
+ log "building the connector (tsc, all workspaces in dependency order) ..."
79
+ ( cd "$WORKSPACE_ROOT" && npm run build ) || die "npm run build failed"
80
+
81
+ if ! command -v uv >/dev/null 2>&1; then
82
+ log "installing uv (home-domain, no sudo) ..."
83
+ curl -LsSf https://astral.sh/uv/install.sh | sh || die "uv install failed"
84
+ fi
85
+ export PATH="$HOME/.local/bin:$PATH"
86
+
87
+ log "installing litert-lm==${LITERT_LM_VERSION} ..."
88
+ uv tool install --python 3.12 --managed-python --force "litert-lm==$LITERT_LM_VERSION" \
89
+ || die "litert-lm install failed"
90
+ LITERTLM="$HOME/.local/bin/litert-lm"
91
+ [ -x "$LITERTLM" ] || die "litert-lm not installed at $LITERTLM"
92
+
93
+ MODEL_ID='gemma-4-E2B-it.litertlm'
94
+ HF_REPO='litert-community/gemma-4-E2B-it-litert-lm'
95
+ if ! "$LITERTLM" list 2>/dev/null | grep -q "$MODEL_ID"; then
96
+ # EXACT form setup-litertlm.sh uses. The positional argument is the FILE inside
97
+ # the repo, not the repo: passing the repo id alone gets
98
+ # "Source file not found: <repo>", because the bare form imports a LOCAL path.
99
+ log "importing ${MODEL_ID} from ${HF_REPO} (~2.4 GB) ..."
100
+ "$LITERTLM" import --from-huggingface-repo "$HF_REPO" "$MODEL_ID" || die "model import failed"
101
+ fi
102
+
103
+ SERVE_LOG="${TMPDIR:-/tmp}/litertlm-serve-bench.log"
104
+ if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/v1/models" >/dev/null 2>&1; then
105
+ log "a serve is already healthy on :$PORT"
106
+ SERVE_PID=''
107
+ else
108
+ log "starting litert-lm serve on 127.0.0.1:$PORT (log: $SERVE_LOG)"
109
+ "$LITERTLM" serve --host 127.0.0.1 --port "$PORT" --verbose > "$SERVE_LOG" 2>&1 &
110
+ SERVE_PID=$!
111
+ fi
112
+ cleanup() { [ -n "${SERVE_PID:-}" ] && kill "$SERVE_PID" 2>/dev/null; }
113
+ trap cleanup EXIT
114
+
115
+ HEALTHY=0
116
+ for _ in $(seq 1 120); do
117
+ if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/v1/models" >/dev/null 2>&1; then HEALTHY=1; break; fi
118
+ [ -n "${SERVE_PID:-}" ] && ! kill -0 "$SERVE_PID" 2>/dev/null && break
119
+ sleep 5
120
+ done
121
+ [ "$HEALTHY" = "1" ] || { tail -40 "$SERVE_LOG" 2>/dev/null; die "litert-lm serve never became healthy on :$PORT"; }
122
+ log "litert-lm serve healthy on :$PORT"
123
+
124
+ log "running the hardware matrix — arms=$ARMS frames=$FRAMES rounds=$ROUNDS budget=${BUDGET_MIN}m"
125
+ node "$PKG_ROOT/scripts/bench-local-vlm.mjs" \
126
+ --litert "http://127.0.0.1:$PORT" \
127
+ --arms "$ARMS" --frames "$FRAMES" --rounds "$ROUNDS" \
128
+ --budget-ms "$((BUDGET_MIN * 60000))" \
129
+ --timeout-ms "$((FRAME_TIMEOUT_MIN * 60000))" \
130
+ --machine-note "$NOTE" \
131
+ ${MODEL:+--model "$MODEL"} \
132
+ --out "$OUT"
133
+ BENCH_EXIT=$?
134
+
135
+ # A bench REPORTS; it does not gate. Exiting non-zero because a machine is slow
136
+ # would turn "this box is too slow" into "the lane is broken" — the confusion
137
+ # that left windows-latest unmeasured for two months. Only producing NOTHING fails.
138
+ [ -f "$OUT" ] || die "bench produced no matrix at all (node exit $BENCH_EXIT)"
139
+ log "matrix written: $OUT"
140
+ exit 0
@@ -31,7 +31,7 @@
31
31
  # [--sign-app "Developer ID Application: …"]
32
32
  # [--sign-installer "Developer ID Installer: …"]
33
33
  # [--bundle-model] (default; include the pinned ~2.4 GB VLM)
34
- # [--skip-model] (engineering-only slim build)
34
+ # [--skip-model] (code-only upgrade; not a first installer)
35
35
  # [--offline] (skip the official Node + ffmpeg downloads,
36
36
  # copy the host `node`/`ffmpeg` instead — for
37
37
  # air-gapped CI)
@@ -104,6 +104,7 @@ WORKSPACE_ROOT="$(cd "$PKG_ROOT/../.." && pwd)" # …/trillboards-edge-sdk
104
104
 
105
105
  BUNDLE_ID="com.trillboards.edge"
106
106
  APP_NAME="TrillboardsEdge"
107
+ INSTALLER_NAME="SenseAnythingConnector"
107
108
  INSTALL_SUBDIR="Library/Trillboards/edge" # relative to the user home
108
109
 
109
110
  # ─── Args ────────────────────────────────────────────────────────────────────
@@ -150,8 +151,8 @@ while [ $# -gt 0 ]; do
150
151
  esac
151
152
  done
152
153
 
153
- # Offline customer builds must provide the model cache explicitly. Slim builds
154
- # remain available only for focused engineering checks.
154
+ # Offline first-install builds must provide the model cache explicitly. Slim
155
+ # builds are the release lane's code-only upgrade artifact.
155
156
  if [ "$OFFLINE" -eq 1 ] && [ "$BUNDLE_MODEL" -eq 1 ] && [ -z "$GEMMA_MODEL_FILE" ]; then
156
157
  echo "--offline with the customer model bundle requires GEMMA_MODEL_FILE" >&2
157
158
  exit 2
@@ -176,7 +177,7 @@ DIST_DIR="$PKG_ROOT/deploy/macos/dist-installer"
176
177
  STAGE="$DIST_DIR/stage-$ARCH_LABEL" # the payload root (== the user home view)
177
178
  PAYLOAD="$STAGE/$INSTALL_SUBDIR" # …/Library/Trillboards/edge
178
179
  SCRATCH="$DIST_DIR/scratch-$ARCH_LABEL"
179
- PKG_OUT="$DIST_DIR/${APP_NAME}-${VERSION}-${ARCH_LABEL}.pkg"
180
+ PKG_OUT="$DIST_DIR/${INSTALLER_NAME}.pkg"
180
181
  COMPONENT_PKG="$SCRATCH/${APP_NAME}-component.pkg"
181
182
  SETUP_APP="$STAGE/Applications/Trillboards CCTV Setup.app"
182
183
 
@@ -398,7 +399,9 @@ cp "$PKG_ROOT/deploy/macos/com.trillboards.edge.plist" "$PAYLOAD/share/com.trill
398
399
  cp "$PKG_ROOT/deploy/macos/com.trillboards.litertlm.plist" "$PAYLOAD/share/com.trillboards.litertlm.plist"
399
400
  cp "$PKG_ROOT/deploy/macos/scripts/setup-litertlm.sh" "$PAYLOAD/share/setup-litertlm.sh"
400
401
  cp "$PKG_ROOT/deploy/macos/scripts/ensure-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh"
401
- chmod +x "$PAYLOAD/share/setup-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh"
402
+ cp "$PKG_ROOT/deploy/macos/apply-update.sh" "$PAYLOAD/share/apply-update.sh"
403
+ chmod +x "$PAYLOAD/share/setup-litertlm.sh" "$PAYLOAD/share/ensure-litertlm.sh" \
404
+ "$PAYLOAD/share/apply-update.sh"
402
405
 
403
406
  # ─── 7b. Stage the pre-bundled Gemma model (.litertlm) into the payload ───────
404
407
  # Stage the redistributable (Apache-2.0) Gemma 4 E2B LiteRT-LM model and verify
@@ -423,7 +426,7 @@ if [ "$BUNDLE_MODEL" -eq 1 ]; then
423
426
  printf '%s %s\n' "$GEMMA_MODEL_SHA256" "$GEMMA_MODEL_ID" > "$PAYLOAD/share/models/$GEMMA_MODEL_ID.sha256"
424
427
  log "Staged + SHA-verified bundled Gemma model ($(du -h "$GEMMA_OUT" | cut -f1))"
425
428
  else
426
- warn "Building an engineering-only slim package without local AI. Do not publish this artifact to customers."
429
+ warn "Building the code-only upgrade package without bundled local AI; it requires an already-provisioned Mac."
427
430
  fi
428
431
 
429
432
  # ─── 7c. Build the native customer setup application ─────────────────────────
@@ -588,7 +591,7 @@ productbuild \
588
591
  # ─── 10. Optional sign ───────────────────────────────────────────────────────
589
592
  if [ -n "$SIGN_IDENTITY" ]; then
590
593
  log "productsign with '$SIGN_IDENTITY'…"
591
- SIGNED="$DIST_DIR/${APP_NAME}-${VERSION}-${ARCH}-signed.pkg"
594
+ SIGNED="$DIST_DIR/${INSTALLER_NAME}-signed.pkg"
592
595
  productsign --sign "$SIGN_IDENTITY" "$PKG_OUT" "$SIGNED"
593
596
  mv "$SIGNED" "$PKG_OUT"
594
597
  log "Signed: $PKG_OUT"
@@ -603,12 +606,12 @@ else
603
606
  warn "cert (pass --sign-installer) then notarize (see deploy/macos/README.md)."
604
607
  fi
605
608
 
606
- # Keep deliberately slim engineering builds bounded so an unrelated payload
607
- # regression cannot masquerade as the bundled customer package.
609
+ # Keep code-only upgrade builds bounded so an unrelated payload regression
610
+ # cannot turn an automatic update into another multi-gigabyte first install.
608
611
  PKG_BYTES="$(stat -f%z "$PKG_OUT")"
609
612
  MAX_SLIM_PKG_BYTES="${MAX_SLIM_PKG_BYTES:-262144000}"
610
613
  if [ "$BUNDLE_MODEL" -eq 0 ] && [ "$PKG_BYTES" -gt "$MAX_SLIM_PKG_BYTES" ]; then
611
- die "slim engineering installer is $PKG_BYTES bytes; maximum is $MAX_SLIM_PKG_BYTES bytes"
614
+ die "code-only update package is $PKG_BYTES bytes; maximum is $MAX_SLIM_PKG_BYTES bytes"
612
615
  fi
613
616
 
614
617
  # ─── Done ────────────────────────────────────────────────────────────────────
@@ -8,7 +8,8 @@
8
8
  # - ensure the runtime dirs exist (Application Support config dir + Logs dir)
9
9
  # - make the bundled binaries executable (pkg payloads can lose +x)
10
10
  # - install and health-check the bundled local AI model
11
- # - open camera pairing only after local AI is ready
11
+ # - open camera pairing only after local AI is ready on a first install
12
+ # - keep an already-paired update headless
12
13
  # - DO NOT start the LaunchAgent: there is no paired config yet, and a
13
14
  # config-less cloud-sense crash-loops. The agent is bootstrapped only after
14
15
  # a successful setup (the installed pairing engine does that).
@@ -42,6 +43,9 @@ fi
42
43
  EDGE_ROOT="$USER_HOME/Library/Trillboards/edge"
43
44
  APP_SUPPORT="$USER_HOME/Library/Application Support/Trillboards"
44
45
  LOG_DIR="$USER_HOME/Library/Logs/Trillboards"
46
+ PAIRED_CONFIG="$APP_SUPPORT/trillboards.config.json"
47
+ IS_UPGRADE=0
48
+ [ -s "$PAIRED_CONFIG" ] && IS_UPGRADE=1
45
49
 
46
50
  log() { /usr/bin/logger -t com.trillboards.edge "postinstall: $*" 2>/dev/null || true; printf '[trillboards postinstall] %s\n' "$*"; }
47
51
 
@@ -118,7 +122,9 @@ APPS_DIR="$USER_HOME/Applications"
118
122
  SETUP_APP="$APPS_DIR/Trillboards CCTV Setup.app"
119
123
  LEGACY_PAIR_LAUNCHER="$APPS_DIR/Trillboards Pair.command"
120
124
  rm -f "$LEGACY_PAIR_LAUNCHER" 2>/dev/null || true
121
- if [ -d "$SETUP_APP" ] && [ -x "$EDGE_ROOT/bin/trillboards-pair" ]; then
125
+ if [ "$IS_UPGRADE" -eq 1 ]; then
126
+ log "paired connector upgrade detected; setup remains closed"
127
+ elif [ -d "$SETUP_APP" ] && [ -x "$EDGE_ROOT/bin/trillboards-pair" ]; then
122
128
  log "native setup app ready: \"$SETUP_APP\" (opening it)"
123
129
  if [ "$(id -u)" = "0" ] && [ -n "$OWNER" ] && [ "$OWNER" != "root" ]; then
124
130
  LAUNCH_UID="$(id -u "$OWNER" 2>/dev/null || echo '')"
@@ -149,5 +155,9 @@ else
149
155
  log "WARNING: launcher not executable at $EDGE_ROOT/bin/trillboards-edge"
150
156
  fi
151
157
 
152
- log "done. Connector and local AI installed; Trillboards CCTV Setup opened automatically."
158
+ if [ "$IS_UPGRADE" -eq 1 ]; then
159
+ log "done. Connector update installed; existing pairing preserved."
160
+ else
161
+ log "done. Connector and local AI installed; Trillboards CCTV Setup opened automatically."
162
+ fi
153
163
  exit 0
@@ -70,15 +70,17 @@ serves every Windows 10/11 — no per-arch builds.
70
70
  ./build-windows.ps1 -Offline # copy host node/ffmpeg (air-gapped)
71
71
 
72
72
  # after the inner installer is signed, create the one-file customer artifact
73
- ./bundle-windows-installer.ps1 -InnerInstaller <signed-inner.exe>
73
+ ./bundle-windows-installer.ps1 -InnerInstaller <signed-inner.exe> # -> dist-installer\customer\SenseAnythingConnector.exe
74
74
  ```
75
75
 
76
76
  Requires NSIS and 7-Zip on PATH (`choco install nsis 7zip -y`). The release workflow publishes
77
- the `.exe` to `s3://trillboards-screen-static/connector/trillboards-connector-windows.exe`
77
+ the `.exe` to `s3://trillboards-screen-static/connector/SenseAnythingConnector.exe`
78
78
  plus `trillboards-connector-windows.version.json`, containing the exact connector
79
- version, production commit, SHA-256, file size, and publication time. It then
80
- invalidates the `screen.trillboards.com` CloudFront distribution, so the portal's
81
- stable download URL never changes while field tests remain artifact-verifiable.
79
+ version, production commit, SHA-256, file size, and publication time. The old
80
+ `trillboards-connector-windows.exe` object remains a compatibility alias, with a
81
+ content-disposition header that still downloads as `SenseAnythingConnector.exe`.
82
+ It then invalidates the `screen.trillboards.com` CloudFront distribution, so the
83
+ portal's stable download URL never changes while field tests remain artifact-verifiable.
82
84
 
83
85
  ## Notes
84
86
 
@@ -37,9 +37,7 @@ if (-not (Test-Path -LiteralPath $InnerInstaller -PathType Leaf)) {
37
37
  }
38
38
 
39
39
  if ([string]::IsNullOrWhiteSpace($OutputPath)) {
40
- $version = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($InnerInstaller).ProductVersion
41
- if ([string]::IsNullOrWhiteSpace($version)) { $version = "unknown" }
42
- $OutputPath = Join-Path (Join-Path $DistDir "customer") "TrillboardsEdgeSetup-$version-x64.exe"
40
+ $OutputPath = Join-Path (Join-Path $DistDir "customer") "SenseAnythingConnector.exe"
43
41
  }
44
42
  $OutputPath = [System.IO.Path]::GetFullPath($OutputPath)
45
43