Two changes, and the second was found by verifying the first. ## Watching a camera from the app, in another building Snapshots answer "is that camera working". They do not answer "what is happening in my shop right now", which is what somebody who opens the app away from the counter is asking. Head office's browser already had that answer - LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is watching and pushes JPEG frames for as long as somebody is - and the app could not reach it. cloud.CameraLive opens that feed and the app's own loopback relay re-emits it as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img> cannot render that, and an <img> renders MJPEG natively - so a tile is an ordinary <img> pointed at loopback whether the camera is in this room or another city. - Reconnecting happens in the relay, not the page. The server caps one push at five minutes, so doing it here means the <img> never sees the stream end. - The headers are flushed before the first frame. Go writes them on the first body write, so without that the whole response waits for the shop PC to start pushing. Measured against production: 30 seconds and not even a Content-Type, which surfaces as the request timing out. - One camera at a time. Watching makes a shop PC upload, so a grid that went live at once would put an estate's worth of cameras on the wire because somebody opened a page. - live.mjpeg is behind the same per-run token as the engine routes, and a wrong token is a 404 that never reaches head office at all. - CameraLive uses its own HTTP client: the shared one's 30s timeout covers the whole response and would sever a working view every thirty seconds - the trap that made the server set WriteTimeout to zero for its own SSE endpoint. ## A camera read "Connected" for 34 minutes after the shop PC went blind Which is why the verification above looked like a failure: head office registered the viewer and no frame ever came. reportWith returns early when the engine is unreachable - correctly, it has nothing to say - so the last state it sent stays in the database looking current. Measured live: cam2 and entrance both reading Connected, in green, with last_seen_at 34 minutes old, while the heartbeat from the same PC said cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing. false could not be the answer. It means "this camera is not connecting", which sends an installer to check cabling on a camera that was working perfectly the last time anybody could ask it. So there are four states and one function: connected reported recently, and working not_connecting reported recently, and the stream will not open waiting no shop PC has ever reported this camera stale reported once, and not lately - Connected is CLEARED when stale or waiting. A stale true left in place stays available to every client reading the field directly, and leaves two fields on one object disagreeing - how the shops screen once came out labelled Working, in green, above "2 of 3 cameras not connecting". - Computed in scanCamera, so every camera anybody reads passes through it. A state computed per handler is one a handler forgets, and this had already reached three screens. - CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning as three missed heartbeats - an indicator that cries wolf gets ignored. - An unparseable last_seen_at is stale. It should be impossible, which is why it must not fall through to the state that says everything is fine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
Behavision desktop
The store-facing app: a tray icon, a window, and the supervisor for the Python recognition engine.
Why one process, not three
The tray, the window and the supervisor all need the same state, and a user who quits the tray expects recognition to stop. Splitting them means two things can disagree about whether the engine is running.
It is deliberately not a Windows service. A service runs in session 0 and cannot draw a tray icon — that is Windows session isolation, not a library limitation. Spawning a child process also needs no elevation, while controlling a service does, so this design never triggers UAC at runtime.
Layout
main.go wails.Run, window options
app.go the methods bound to the frontend
tray.go fyne.io/systray — wails v2 has no tray of its own
icons.go tray icons generated at run time, not embedded
internal/local client for the engine on 127.0.0.1:8010
internal/cloud client for https://mcp.loyaly.ai
frontend/ React + Vite
The supervisor, durable spool, broker client and path resolution come from
../agent/pkg/* — the same tested code the headless agent runs, imported
rather than copied.
Build
cd frontend && npm install && npm run build # then, from this directory:
wails build -platform windows/amd64
wails build needs the Wails CLI:
go install github.com/wailsapp/wails/v2/cmd/wails@v2.9.2
Without it, go build still type-checks everything provided
frontend/dist exists — the embed directive requires it.
What the frontend talks to
Nothing is imported from generated bindings. src/bridge.js calls
window.go.main.App.* directly, so npm run build works without running
wails generate, and there is one place that handles "the engine is not
running yet" — the state every screen has to survive on a fresh install.