diff --git a/desktop/internal/cloud/client.go b/desktop/internal/cloud/client.go
index 13932ed..268641c 100644
--- a/desktop/internal/cloud/client.go
+++ b/desktop/internal/cloud/client.go
@@ -39,6 +39,19 @@ type Client struct {
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
+
+ // Camera snapshots already fetched, keyed by camera id. The Cameras screen
+ // polls every 8 seconds and a snapshot is ~90 KB, so re-fetching one that
+ // has not changed would put megabytes an hour on the wire to redraw the
+ // same picture - the same trap the web app's useAuthedImage avoids by
+ // keying on the url rather than the object around it.
+ shotMu sync.Mutex
+ shots map[string]cachedShot
+}
+
+type cachedShot struct {
+ at string // the server's snapshot_at; a new one is a new picture
+ uri string
}
type User struct {
@@ -629,3 +642,107 @@ func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
"items": items, "source": "manual", "notes": notes,
}, nil)
}
+
+// ---------------------------------------------------------------- viewing --
+//
+// A PC with no engine of its own is not broken, it is a VIEWER: somebody
+// signed in on a laptop away from the shop. Everything below reads head
+// office so those screens have something true to show instead of "engine not
+// reachable", which is an accurate sentence and a useless one when the reader
+// was never expecting an engine on that machine.
+
+// Arrival is one visit as the estate's feed reports it, across every shop -
+// not just this PC's. `GET /api/visits`.
+type Arrival struct {
+ VisitID string `json:"visit_id"`
+ VisitRef string `json:"visit_ref"`
+ OccurredAt string `json:"occurred_at"`
+ Site string `json:"site"`
+ SiteSlug string `json:"site_slug"`
+ CameraID string `json:"camera_id"`
+ VisitorID string `json:"visitor_id"`
+ Ref string `json:"ref"`
+ Label string `json:"label"`
+ IsNew bool `json:"is_new_visitor"`
+ Similarity float64 `json:"similarity"`
+ Attributes map[string]any `json:"attributes"`
+ Image Photo `json:"image"`
+}
+
+// RemoteCamera is a camera as HEAD OFFICE knows it. Deliberately not the same
+// type the local engine returns: this one can never be edited from here (the
+// shop PC on that LAN is the only thing that can reach it) and it carries a
+// snapshot rather than a stream.
+type RemoteCamera struct {
+ ID string `json:"id"`
+ CameraID string `json:"camera_id"`
+ Label string `json:"label"`
+ Site string `json:"site"`
+ SiteSlug string `json:"site_slug"`
+ Enabled bool `json:"enabled"`
+ Connected *bool `json:"connected"`
+ LastSeenAt string `json:"last_seen_at"`
+ Snapshot Photo `json:"snapshot"`
+ SnapshotAt string `json:"snapshot_at"`
+}
+
+// Arrivals reads the estate's recent visits, newest last.
+func (c *Client) Arrivals(ctx context.Context, limit int) ([]Arrival, error) {
+ var out struct {
+ Arrivals []Arrival `json:"arrivals"`
+ }
+ if err := c.send(ctx, http.MethodGet,
+ fmt.Sprintf("/api/visits?limit=%d", limit), nil, &out); err != nil {
+ return nil, err
+ }
+ return out.Arrivals, nil
+}
+
+// RemoteCameras lists every camera head office knows about for this company.
+func (c *Client) RemoteCameras(ctx context.Context) ([]RemoteCamera, error) {
+ var out []RemoteCamera
+ if err := c.send(ctx, http.MethodGet, "/api/cameras", nil, &out); err != nil {
+ return nil, err
+ }
+ for i := range out {
+ out[i].Snapshot = c.resolveShot(ctx, out[i].ID, out[i].SnapshotAt, out[i].Snapshot)
+ }
+ return out, nil
+}
+
+// resolveShot turns a camera snapshot into something the window can render.
+//
+// Same problem VisitorImage has and the same answer: a deployment with no
+// object storage serves the picture from the API itself, so the url is
+// relative and needs this session's bearer. A webview
![]()
can supply
+// neither - it resolves a relative src against wails:// and cannot set a
+// header - so the bytes are fetched here and passed as a data: URI.
+//
+// A failure is an absence with a reason, never an error. Whether the camera is
+// CONNECTED is the answer this screen exists to give; the photograph is
+// decoration, and blanking the card because a picture would not load would
+// hide the part that matters.
+func (c *Client) resolveShot(ctx context.Context, camID, at string, p Photo) Photo {
+ if !p.Available || !p.Auth || p.URL == "" {
+ return p
+ }
+ c.shotMu.Lock()
+ hit, ok := c.shots[camID]
+ c.shotMu.Unlock()
+ if ok && hit.at == at && at != "" {
+ p.URL, p.Auth = hit.uri, false
+ return p
+ }
+ uri, err := c.fetchImage(ctx, p.URL)
+ if err != nil {
+ return Photo{Reason: "That camera's picture could not be loaded."}
+ }
+ c.shotMu.Lock()
+ if c.shots == nil {
+ c.shots = map[string]cachedShot{}
+ }
+ c.shots[camID] = cachedShot{at: at, uri: uri}
+ c.shotMu.Unlock()
+ p.URL, p.Auth = uri, false
+ return p
+}
diff --git a/desktop/viewing_test.go b/desktop/viewing_test.go
new file mode 100644
index 0000000..fe7a461
--- /dev/null
+++ b/desktop/viewing_test.go
@@ -0,0 +1,166 @@
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/loyaly/behavision-desktop/internal/cloud"
+ "github.com/loyaly/behavision-desktop/internal/local"
+)
+
+// Viewer mode: what the app shows on a computer that is signed in and is not
+// itself watching any cameras.
+//
+// This is the friend's-Mac case, and before it existed the app was honest and
+// useless: Live() and Cameras() read ONLY the engine on 127.0.0.1, so a laptop
+// with no engine got "engine not reachable at http://127.0.0.1:8010" and
+// "0 of 0 cameras" - on an account whose shops were running and recognising
+// people the whole time. Signing in is what the person did; the app answered
+// as if they had not.
+//
+// The engine here is a port nothing listens on, which is precisely what a PC
+// with no engine is.
+const noEngine = "http://127.0.0.1:1" // reserved, refuses immediately
+
+func viewerApp(t *testing.T, srv *httptest.Server) *App {
+ t.Helper()
+ c := cloud.New(srv.URL)
+ c.SetSession(cloud.Session{Token: "test-token"})
+ return &App{
+ ctx: context.Background(),
+ cloud: c,
+ local: local.New(noEngine, "", ""),
+ }
+}
+
+func TestLiveFallsBackToHeadOfficeWhenThereIsNoEngine(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch {
+ case r.URL.Path == "/api/sites":
+ // Two shops. One is fine, one is the Office1 case.
+ w.Write([]byte(`[
+ {"slug":"a","name":"A","cameras_up":2,"cameras_total":2,"fraction_below_gate":0.10},
+ {"slug":"b","name":"B","cameras_up":1,"cameras_total":3,"fraction_below_gate":0.73}
+ ]`))
+ case strings.HasPrefix(r.URL.Path, "/api/visits"):
+ w.Write([]byte(`{"arrivals":[
+ {"visit_id":"v1","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2","is_new_visitor":true},
+ {"visit_id":"v2","visitor_id":"p1","ref":"V-1","label":"Visitor 1","camera_id":"cam2"},
+ {"visit_id":"v3","camera_id":"entrance"}
+ ]}`))
+ default:
+ t.Errorf("unexpected request %s", r.URL.Path)
+ }
+ }))
+ defer srv.Close()
+
+ snap, err := viewerApp(t, srv).Live()
+ if err != nil {
+ t.Fatalf("Live: %v", err)
+ }
+ if !snap.Viewing {
+ t.Fatal("the snapshot did not say it was a view of somewhere else")
+ }
+ if got := snap.Stats["cameras_up"]; got != 3 {
+ t.Errorf("cameras_up = %v, want 3 summed across both shops", got)
+ }
+ if got := snap.Stats["cameras_total"]; got != 5 {
+ t.Errorf("cameras_total = %v, want 5", got)
+ }
+ // The WORST site, never an average. Averaging 0.10 against 0.73 reports
+ // 0.42 and hides the only shop anyone needs to go and fix - the same rule
+ // the heartbeat already follows with worst_site.
+ if got := snap.Stats["fraction_below_gate"]; got != 0.73 {
+ t.Errorf("fraction_below_gate = %v, want the worst shop's 0.73", got)
+ }
+ // Three arrivals, two of them the same person, one unidentified. A visit
+ // with no visitor_id is real footfall and an unknown person, so it counts
+ // as a sighting and not as somebody known.
+ g := snap.Stats["gallery"].(map[string]any)
+ if g["identities"] != 1 || g["sightings"] != 3 {
+ t.Errorf("gallery = %v, want 1 identity over 3 sightings", g)
+ }
+ if len(snap.Events) != 3 {
+ t.Fatalf("got %d events, want 3", len(snap.Events))
+ }
+ if snap.Events[0]["type"] != "person.new" || snap.Events[1]["type"] != "person.seen" {
+ t.Errorf("arrival types wrong: %v", snap.Events)
+ }
+}
+
+// Nobody signed in: the local failure is the honest answer. There is nothing
+// else to show, and the person is most likely setting this PC up - telling
+// them about head office would be telling them about something they have not
+// got to yet.
+func TestLiveWithNoEngineAndNoSessionReportsTheEngine(t *testing.T) {
+ a := &App{ctx: context.Background(), cloud: cloud.New("https://example.invalid"),
+ local: local.New(noEngine, "", "")}
+ if _, err := a.Live(); err == nil {
+ t.Fatal("want the engine error, got nil")
+ }
+}
+
+// A remote camera is flagged, because the screen has to withhold every button
+// that would talk to a camera on a network this computer cannot reach. An Edit
+// button that cannot work is worse than one that is absent.
+func TestRemoteCamerasAreFlaggedAndCarryNoCredentials(t *testing.T) {
+ jpeg := base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xD9})
+ var shots int
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/api/camera-snapshots/") {
+ shots++
+ w.Header().Set("Content-Type", "image/jpeg")
+ b, _ := base64.StdEncoding.DecodeString(jpeg)
+ w.Write(b)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`[
+ {"id":"c1","camera_id":"cam2","label":"Open office","site":"Coimbatore",
+ "connected":true,"snapshot_at":"2026-09-30T10:00:00Z",
+ "snapshot":{"available":true,"url":"/api/camera-snapshots/c1.jpg","auth":true}}
+ ]`))
+ }))
+ defer srv.Close()
+
+ a := viewerApp(t, srv)
+ cams, err := a.Cameras()
+ if err != nil {
+ t.Fatalf("Cameras: %v", err)
+ }
+ if len(cams) != 1 {
+ t.Fatalf("got %d cameras, want 1", len(cams))
+ }
+ if cams[0]["remote"] != true {
+ t.Error("the camera was not flagged remote")
+ }
+ // The RTSP details are a live path into the camera itself and the server
+ // does not send them to a tenant at all. Nothing here may invent them.
+ for _, k := range []string{"host", "port", "path", "username", "password"} {
+ if _, ok := cams[0][k]; ok {
+ t.Errorf("a remote camera carried %q", k)
+ }
+ }
+
+ // The picture has to be fetched here: a webview
![]()
resolves a relative
+ // src against wails:// and cannot send the session's bearer.
+ shot := cams[0]["snapshot"].(cloud.Photo)
+ if !strings.HasPrefix(shot.URL, "data:image/jpeg;base64,") || shot.Auth {
+ t.Errorf("snapshot url = %q auth=%v, want an inline data URI", shot.URL, shot.Auth)
+ }
+
+ // And fetched ONCE. This screen polls every 8 seconds and a real snapshot
+ // is ~90 KB, so re-fetching an unchanged picture is megabytes an hour to
+ // redraw the same frame.
+ if _, err := a.Cameras(); err != nil {
+ t.Fatalf("second poll: %v", err)
+ }
+ if shots != 1 {
+ t.Errorf("fetched the same snapshot %d times across two polls", shots)
+ }
+}