Behavision: face recognition for retail, edge to head office

Five components that ship as one product:

- behavision/  the recognition engine. RTSP ingest, YuNet detection, IoU
               tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
               FastAPI dashboard. Identity is decided once per TRACK from an
               average of at least three embeddings, never per frame.
- agent/       the Go edge agent: supervises the engine, holds a durable
               spool, and drains it to MQTT. Nothing is acked before the
               broker confirms.
- desktop/     the shop PC application (Wails + React + tray).
- server/      the cloud API, MQTT consumer, reports and assistant.
- web/         platform.loyaly.ai, the head-office app, embedded in the
               server binary.

The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.

CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
2026-09-04 11:14:18 +05:30
commit dad04e8cda
216 changed files with 40473 additions and 0 deletions

48
desktop/README.md Normal file
View File

@@ -0,0 +1,48 @@
# 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.

687
desktop/app.go Normal file
View File

@@ -0,0 +1,687 @@
// The methods bound to the frontend.
//
// Every one is a thin adapter: it talks to the local engine, the cloud, or the
// supervisor, and returns something JSON-shaped. No recognition logic lives
// here - the engine owns that, and duplicating any of it would give the UI a
// second opinion about who someone is.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
agentbridge "github.com/loyaly/behavision-agent/pkg/bridge"
agentcameras "github.com/loyaly/behavision-agent/pkg/cameras"
agentcfg "github.com/loyaly/behavision-agent/pkg/config"
agentengine "github.com/loyaly/behavision-agent/pkg/engine"
agentmqtt "github.com/loyaly/behavision-agent/pkg/mqtt"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
agentspool "github.com/loyaly/behavision-agent/pkg/spool"
"github.com/loyaly/behavision-desktop/internal/cloud"
"github.com/loyaly/behavision-desktop/internal/local"
)
type App struct {
ctx context.Context
mu sync.RWMutex
cfg agentcfg.Config
cloud *cloud.Client
local *local.Client
sup *agentengine.Supervisor
spool *agentspool.Spool
bridge *agentbridge.Bridge
broker *agentmqtt.Client
stopBridge func()
hookURL string
// Set once the operator logs in. Until then the UI shows the login sheet
// and nothing else is reachable.
onSessionChange func(bool)
}
func NewApp() *App {
cfg, _ := agentcfg.Load(agentpaths.AgentConfig())
// The engine invents its own Basic credential when none is configured,
// which is the default. Without this every call this app makes to the
// engine - health, cameras, the live feed - comes back 401, and the tray
// shows a healthy process the UI cannot talk to.
cfg = cfg.WithEngineCredentials(agentpaths.APICredentials())
base := cfg.APIBase
if base == "" {
base = "http://127.0.0.1:8010"
}
return &App{
cfg: cfg,
cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")),
local: local.New(base, cfg.APIUser, cfg.APIPassword),
}
}
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
_ = agentpaths.EnsureState()
// A saved session means a shop PC that rebooted overnight comes back
// working instead of waiting for someone to log in.
if a.cfg.SessionToken != "" {
a.cloud.SetSession(cloud.Session{
Token: a.cfg.SessionToken, RefreshToken: a.cfg.SessionRefresh,
User: cloud.User{Email: a.cfg.SessionEmail},
})
}
// The server rotates the refresh token every time it is used, so a PC that
// refreshes and then reboots would come back holding one the server has
// already invalidated - it would look exactly like a normal expiry, twelve
// hours after anyone last touched the machine.
a.cloud.OnRefresh(func(s cloud.Session) { a.persistSession(s) })
exe := a.cfg.EngineExe
if exe != "" && !filepath.IsAbs(exe) {
exe = filepath.Join(agentpaths.InstallRoot(), exe)
}
logFile, _ := agentengine.LogFile(agentpaths.EngineLog())
a.sup = agentengine.New(agentengine.Options{
Command: func(c context.Context) *exec.Cmd {
cmd := exec.CommandContext(c, exe, a.cfg.EngineArgs...)
// How the engine learns where to post its detections. Its config
// already reads `events.webhook_url: ${BEHAVISION_WEBHOOK_URL}`,
// and python-dotenv does not override a variable the process
// already has, so this needs no new endpoint and no fixed port.
//
// Read here rather than captured, because the bridge picks its
// port after this closure is built and a restarted engine has to
// be told again. Without it the engine recognised people and the
// bridge received nothing: a claimed shop PC published heartbeats
// and zero visits.
cmd.Env = append(os.Environ(), "BEHAVISION_WEBHOOK_URL="+a.webhookURL())
return cmd
},
LogWriter: logFile,
HealthURL: strings.TrimRight(a.cfg.APIBase, "/") + "/api/health",
StatsURL: strings.TrimRight(a.cfg.APIBase, "/") + "/api/stats",
User: a.cfg.APIUser, Password: a.cfg.APIPassword,
})
a.startPipeline(ctx)
}
// webhookURL is the loopback address the bridge is listening on, or empty
// before it has started.
func (a *App) webhookURL() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.hookURL
}
// startPipeline connects detections to the server: the engine posts events to
// a loopback webhook, the bridge queues them durably, and the pump drains the
// queue to the broker. Without it the engine recognises people and nothing
// ever leaves the PC.
func (a *App) startPipeline(ctx context.Context) {
logger := log.New(os.Stdout, "", log.LstdFlags)
// A PC set up on its own has nothing to report to, and unlike an unclaimed
// one it never will. Queuing anyway would write up to SpoolMax visits to
// disk - each carrying a face template, which is biometric personal data -
// into a queue nothing is ever going to drain. Recognition, the gallery
// and the cameras are unaffected: they are the engine's, not the pump's.
//
// Deliberately distinct from the unclaimed case below, where the queue is
// exactly right: that PC is waiting for credentials, and its footfall from
// the day it was installed should survive until they arrive.
if a.cfg.Standalone && !a.cfg.Configured() {
logger.Print("standalone: recognition runs locally, nothing is reported")
go a.startLocalCameras(ctx, logger)
return
}
q, err := agentspool.Open(agentpaths.SpoolDir(), a.cfg.SpoolMax)
if err != nil {
logger.Printf("spool unavailable, detections will not be recorded: %v", err)
return
}
a.spool = q
// Created before the bridge and handed over unconditionally: an unclaimed
// PC has no pump reading it, which is harmless - the single slot fills
// once and later rings are dropped.
waker := agentmqtt.NewWaker()
a.bridge = &agentbridge.Bridge{
Queue: q,
Wake: waker.Wake,
Embeddings: agentbridge.NewEngineEmbeddings(
a.cfg.APIBase, a.cfg.APIUser, a.cfg.APIPassword),
TopicPrefix: topicPrefix(a.cfg),
Log: logger,
// Uploads face images through a URL the server mints, so this PC never
// holds bucket credentials. Harmless when the engine writes no images
// or the PC is not claimed: Upload reports "images off" and the visit
// queues without a photo.
Uploader: &agentbridge.SpacesUploader{
BaseURL: a.cfg.CloudBase, Token: a.cfg.AgentToken,
},
}
// Cameras, kept in step with head office. Started before the broker check
// because it does not need one: an unclaimed PC still reconciles (to
// nothing) and still keeps running its local cameras.
go a.startLocalCameras(ctx, logger)
url, stop, err := a.bridge.Listen(ctx)
if err != nil {
logger.Printf("event bridge failed to start: %v", err)
return
}
// Under the lock: the engine supervisor reads this from whichever goroutine
// launches the child, and Claim can run startPipeline again at any time.
a.mu.Lock()
a.stopBridge = stop
a.hookURL = url
a.mu.Unlock()
logger.Printf("event bridge on %s", url)
if !a.cfg.Configured() {
// Not claimed yet. The bridge still runs, so footfall from today is on
// disk waiting for the credentials rather than lost.
return
}
client, err := agentmqtt.NewClient(agentmqtt.ClientOptions{
BrokerURL: a.cfg.BrokerURL,
ClientID: "behavision-" + a.cfg.ClientID + "-" + a.cfg.SiteID,
Username: a.cfg.BrokerUsername, Password: a.cfg.BrokerPassword,
CAFile: a.cfg.BrokerCAFile, Log: logger,
})
if err != nil {
logger.Printf("broker unavailable, queuing locally: %v", err)
return
}
a.broker = client
go (&agentmqtt.Pump{
Queue: q, Publisher: client, Log: logger,
Wake: waker.C(),
HeartbeatTopic: topicPrefix(a.cfg) + "/heartbeat",
HeartbeatPayload: a.heartbeat,
}).Run(ctx)
logger.Print("broker pump running")
}
// startLocalCameras runs the reconciler that keeps this PC's cameras in step
// with head office. It is deliberately not conditional on being claimed: with
// no server to ask it reconciles against nothing and the locally configured
// cameras keep running, which is the whole of standalone operation.
func (a *App) startLocalCameras(ctx context.Context, logger *log.Logger) {
camUploader := &agentbridge.SpacesUploader{
BaseURL: a.cfg.CloudBase, Token: a.cfg.AgentToken,
}
camCloud := agentcameras.NewCloudClient(a.cfg.CloudBase, a.cfg.AgentToken)
camCloud.Upload = camUploader.UploadBytes
camEngine := agentcameras.NewEngineClient(
a.cfg.APIBase, a.cfg.APIUser, a.cfg.APIPassword)
// New(), not a struct literal: assembling the Syncer by hand here is how
// this app - and the headless agent - both ended up wiring configuration
// and forgetting the check runner, so "Test connection" at head office
// never completed on any shop PC.
agentcameras.New(camEngine, camCloud, logger).Run(ctx)
}
// topicPrefix must equal the MQTT username: the broker enforces
// `pattern write bv/%u/...`, so any other prefix is refused.
func topicPrefix(cfg agentcfg.Config) string {
if cfg.ClientID == "" || cfg.SiteID == "" {
return ""
}
return "bv/" + cfg.ClientID + "." + cfg.SiteID
}
func (a *App) heartbeat() []byte {
hb := map[string]any{"sent_at": time.Now().UTC().Format(time.RFC3339)}
if a.spool != nil {
hb["queued"] = a.spool.Len()
// Non-zero means this site's queue overflowed and it genuinely lost
// footfall. Reported rather than inferred from a dip in a graph.
hb["dropped"] = a.spool.Dropped()
}
s := a.EngineStatus()
hb["engine_state"] = s.State
if s.Model != "" {
hb["recognition_model"] = s.Model
}
if s.Cameras != nil {
hb["cameras"] = s.Cameras
}
b, _ := json.Marshal(hb)
return b
}
// PipelineStatus is what the UI shows about the link to head office.
type PipelineStatus struct {
WebhookURL string `json:"webhook_url"`
Queued int `json:"queued"`
Dropped uint64 `json:"dropped"`
Claimed bool `json:"claimed"`
// Standalone separates "nothing is being sent because this PC is set up on
// its own" from "nothing is being sent and something is wrong". They look
// identical from the counters alone, and only one of them is a fault.
Standalone bool `json:"standalone"`
BrokerUp bool `json:"broker_up"`
Accepted uint64 `json:"accepted"`
}
func (a *App) PipelineStatus() PipelineStatus {
out := PipelineStatus{
WebhookURL: a.hookURL,
Claimed: a.cfg.Configured(),
Standalone: a.cfg.Standalone && !a.cfg.Configured(),
}
if a.spool != nil {
out.Queued, out.Dropped = a.spool.Len(), a.spool.Dropped()
}
if a.bridge != nil {
out.Accepted = a.bridge.Accepted
}
if a.broker != nil {
out.BrokerUp = a.broker.Connected()
}
return out
}
// ---------------------------------------------------------------- session --
type SessionInfo struct {
LoggedIn bool `json:"logged_in"`
User cloud.User `json:"user"`
SiteName string `json:"site_name"`
Claimed bool `json:"claimed"`
// Standalone is a PC deliberately run on its own. The UI then shows only
// the screens that work without head office - the cameras and what this
// PC is seeing - rather than a sign-in form for an account that does not
// exist.
Standalone bool `json:"standalone"`
}
func (a *App) Session() SessionInfo {
a.mu.RLock()
defer a.mu.RUnlock()
return SessionInfo{
LoggedIn: a.cloud.LoggedIn(),
User: a.cloud.User(),
SiteName: a.cfg.SiteName,
Claimed: a.cfg.Configured(),
Standalone: a.cfg.Standalone && !a.cfg.Configured(),
}
}
// RunStandalone sets this PC up on its own, with no head office.
//
// Recognition, the cameras and the local gallery all work without a server -
// they always did - so refusing to open the app until somebody issues an
// enrolment code held the product hostage to a component it does not need. The
// choice is persisted because it has to survive a reboot, and it is reversible:
// Claim still works afterwards and clears the flag.
func (a *App) RunStandalone() (SessionInfo, error) {
a.mu.Lock()
a.cfg.Standalone = true
err := a.cfg.Save(agentpaths.AgentConfig())
a.mu.Unlock()
if err != nil {
// A choice that is not on disk works until the next restart and then
// silently is not made any more, which looks exactly like the app
// forgetting the setup step was ever done.
return SessionInfo{}, fmt.Errorf("could not save this choice: %w", err)
}
return a.Session(), nil
}
func (a *App) Login(email, password string) (SessionInfo, error) {
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
sess, err := a.cloud.Login(ctx, email, password)
if err != nil {
return SessionInfo{}, err
}
a.persistSession(sess)
if a.onSessionChange != nil {
a.onSessionChange(true)
}
return a.Session(), nil
}
// persistSession writes the tokens to the DPAPI-protected config. Called on
// sign-in and on every silent refresh, so the two can never diverge.
func (a *App) persistSession(s cloud.Session) {
a.mu.Lock()
defer a.mu.Unlock()
a.cfg.SessionToken = s.Token
a.cfg.SessionRefresh = s.RefreshToken
if s.User.Email != "" {
a.cfg.SessionEmail = s.User.Email
}
_ = a.cfg.Save(agentpaths.AgentConfig())
}
// Claim links this PC to a shop, using the one-shot code an operator is given.
//
// This is the half of onboarding that had no way to happen. The server has had
// POST /api/agent/enrol since enrolment was built and `cloud.Client.Bootstrap`
// has existed to call it - and nothing called it, so a freshly installed PC
// displayed "Not linked to head office" and offered no way to link it. The
// only route was hand-editing a JSON file on a shop counter.
//
// Deliberately NOT session-authenticated, mirroring the endpoint: the person
// standing at a new shop PC has no account on it yet, and requiring a login
// first would mean shipping a password to every shop that installs the
// software.
func (a *App) Claim(code string) (SessionInfo, error) {
code = strings.TrimSpace(code)
if code == "" {
return SessionInfo{}, errors.New("type the installation code you were given")
}
ctx, cancel := context.WithTimeout(a.ctx, 30*time.Second)
defer cancel()
b, err := a.cloud.Bootstrap(ctx, code)
if err != nil {
return SessionInfo{}, err
}
a.mu.Lock()
// The slugs, not the uuids: the topic prefix is <client>.<site> and the
// broker's ACL is written against exactly that username.
a.cfg.ClientID = b.ClientSlug
a.cfg.SiteID = b.SiteSlug
a.cfg.SiteName = b.SiteName
a.cfg.BrokerURL = b.MQTTURL
a.cfg.BrokerUsername = b.MQTTUser
a.cfg.BrokerPassword = b.MQTTPass
a.cfg.AgentToken = b.AgentToken
a.cfg.CloudBase = a.cloud.Base
// A PC that was running on its own and has now been linked is no longer
// standalone. Leaving the flag set would keep the head-office screens
// hidden on the one machine that just earned them.
a.cfg.Standalone = false
err = a.cfg.Save(agentpaths.AgentConfig())
a.mu.Unlock()
if err != nil {
// Reported, not swallowed. A claim that is not on disk works until the
// next restart and then silently is not claimed any more, which looks
// like the code was wrong when it was not.
return SessionInfo{}, fmt.Errorf("could not save the settings: %w", err)
}
// The pipeline was started unclaimed: no broker, no pump. Restart it so
// this PC begins publishing now rather than at the next launch - an
// installer who has to reboot to finish setting up will assume it failed.
a.restartPipeline()
return a.Session(), nil
}
// restartPipeline tears the bridge and broker down and builds them again from
// the current config. Only Claim needs it today; it exists as its own method
// because "stop everything that reads the config, then start it" is the part
// that is easy to get half right.
func (a *App) restartPipeline() {
if a.stopBridge != nil {
a.stopBridge()
a.stopBridge = nil
}
if a.broker != nil {
a.broker.Close()
a.broker = nil
}
a.startPipeline(a.ctx)
}
func (a *App) Logout() SessionInfo {
// Revoke server-side too. Clearing only the local copy leaves a live token
// on a machine somebody is about to hand back or resell.
ctx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
defer cancel()
_ = a.cloud.Logout(ctx)
a.mu.Lock()
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
_ = a.cfg.Save(agentpaths.AgentConfig())
a.mu.Unlock()
if a.onSessionChange != nil {
a.onSessionChange(false)
}
return a.Session()
}
// ---------------------------------------------------------------- engine ---
type EngineStatus struct {
State string `json:"state"`
Error string `json:"error,omitempty"`
Restarts int `json:"restarts"`
Reachable bool `json:"reachable"`
Model string `json:"recognition_model,omitempty"`
Cameras map[string]bool `json:"cameras,omitempty"`
}
func (a *App) EngineStatus() EngineStatus {
out := EngineStatus{State: "stopped"}
if a.sup == nil {
return out
}
st, err := a.sup.State()
out.State = string(st)
out.Restarts = a.sup.Restarts()
if err != nil {
out.Error = err.Error()
}
ctx, cancel := context.WithTimeout(a.ctx, 4*time.Second)
defer cancel()
// A running process is not a working engine: on a memory-starved box the
// large model loses the fallback chain and the process stays up regardless,
// so the UI reports which encoder actually loaded.
if h, herr := a.sup.Health(ctx); herr == nil {
out.Reachable = true
out.Model = h.RecognitionModel
out.Cameras = h.Cameras
}
return out
}
func (a *App) StartEngine() EngineStatus {
if a.sup != nil {
a.sup.Start()
}
return a.EngineStatus()
}
func (a *App) StopEngine() EngineStatus {
if a.sup != nil {
a.sup.Stop()
}
return a.EngineStatus()
}
// ---------------------------------------------------------------- cameras --
func (a *App) Cameras() ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.Cameras(ctx)
}
func (a *App) TestCamera(cam map[string]any) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
return a.local.TestCamera(ctx, cam)
}
func (a *App) SaveCamera(id string, cam map[string]any) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
if id == "" {
return a.local.AddCamera(ctx, cam)
}
return a.local.UpdateCamera(ctx, id, cam)
}
func (a *App) DeleteCamera(id string) error {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.local.DeleteCamera(ctx, id)
}
// StartPlacementCheck begins the guided commissioning walk. This is the step
// that stops a site being signed off with a camera that recognises nobody.
func (a *App) StartPlacementCheck(id string, seconds float64) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.StartPlacementCheck(ctx, id, seconds)
}
func (a *App) PlacementResult(id string) (map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
return a.local.PlacementResult(ctx, id)
}
// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an
// <img> tag can load it. Loopback only - it never leaves this machine.
func (a *App) StreamURL(cameraID string) string {
base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://")
if a.local.User == "" {
return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID)
}
return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg",
a.local.User, a.local.Password, base, cameraID)
}
// ------------------------------------------------------------------- live --
type LiveSnapshot struct {
Stats map[string]any `json:"stats"`
Events []map[string]any `json:"events"`
}
func (a *App) Live() (LiveSnapshot, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
stats, err := a.local.Stats(ctx)
if err != nil {
return LiveSnapshot{}, err
}
events, err := a.local.Events(ctx, 40)
if err != nil {
return LiveSnapshot{}, err
}
return LiveSnapshot{Stats: stats, Events: events}, nil
}
// ---------------------------------------------------------------- reports --
func (a *App) Footfall(from, to, bucket string) (cloud.FootfallReport, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.Footfall(ctx, from, to, bucket)
}
func (a *App) Sales(from, to string) (cloud.SalesReport, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.Sales(ctx, from, to)
}
func (a *App) Customers(query string, limit int) ([]cloud.Customer, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
if limit <= 0 {
limit = 100
}
return a.cloud.Customers(ctx, query, limit)
}
// Sites is the health of every store this account can see. It is what makes
// "no customers today" distinguishable from "that shop's PC has been unplugged
// for a week" - two identical rows of zeroes with completely different answers.
func (a *App) Sites() ([]cloud.SiteHealth, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.Sites(ctx)
}
// VisitorHistory is one customer's timeline, for the customer record screen.
func (a *App) VisitorHistory(id string, limit int) ([]cloud.Visit, error) {
if limit <= 0 {
limit = 100
}
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.VisitorHistory(ctx, id, limit)
}
// VisitorPhoto returns a link to this customer's face image, valid for a few
// minutes. "There is no photo" comes back as a Photo with Available false and
// a sentence explaining why, not as an error - see cloud.Photo.
func (a *App) VisitorPhoto(id string) (cloud.Photo, error) {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.VisitorImage(ctx, id)
}
// ForgetCustomer erases a person at the request of that person.
//
// Bound as its own method rather than folded into SaveProfile because it is
// not an edit: it destroys the face template, the photo and the profile, and
// cannot be undone.
func (a *App) ForgetCustomer(id string) error {
// Longer than the usual 20s: the server deletes every stored image from
// object storage before it touches the database, and refuses the whole
// request if any one of them fails.
ctx, cancel := context.WithTimeout(a.ctx, 60*time.Second)
defer cancel()
return a.cloud.ForgetVisitor(ctx, id)
}
func (a *App) SaveProfile(p cloud.Profile) error {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.SaveProfile(ctx, p)
}
func (a *App) RecordPurchase(visitorID string, amount float64,
items []string, notes string) error {
ctx, cancel := context.WithTimeout(a.ctx, 20*time.Second)
defer cancel()
return a.cloud.RecordPurchase(ctx, visitorID, amount, items, notes)
}
// --------------------------------------------------------------- identity --
// LocalIdentities reads the engine's own gallery. Shown alongside the cloud
// customer list because they answer different questions: this is who this PC
// can recognise right now, that is who the business knows.
func (a *App) LocalIdentities(limit int) ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
if limit <= 0 {
limit = 50
}
return a.local.Identities(ctx, limit)
}
func (a *App) LocalSightings(limit int) ([]map[string]any, error) {
ctx, cancel := context.WithTimeout(a.ctx, 15*time.Second)
defer cancel()
if limit <= 0 {
limit = 50
}
return a.local.Sightings(ctx, limit)
}
func envOr(key, def string) string {
if v := osGetenv(key); v != "" {
return v
}
return def
}

5
desktop/env.go Normal file
View File

@@ -0,0 +1,5 @@
package main
import "os"
func osGetenv(k string) string { return os.Getenv(k) }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
desktop/frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-whFsTNQf.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1738
desktop/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "behavision-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useState } from 'react'
import { api, isDesktop, message } from './bridge.js'
import { usePolled } from './hooks.js'
import Login from './views/Login.jsx'
import Setup from './views/Setup.jsx'
import Live from './views/Live.jsx'
import Customers from './views/Customers.jsx'
import Cameras from './views/Cameras.jsx'
// Three screens, and the trim is by AUDIENCE rather than by taste.
//
// This window runs on a PC behind a counter, and the person in front of it can
// act on exactly three things: is it working, who is this customer, and is the
// camera set up. Footfall and Sales answer a different person's questions - an
// owner comparing shops, who is not standing in one - and they now live on the
// head-office platform where a comparison across sites is even possible. A
// month-on-month chart on a shop PC was a report nobody there could act on,
// competing for the attention of somebody with a customer waiting.
//
// `cloud` marks a screen that cannot work without head office. A PC set up on
// its own hides those rather than showing a screen that can only ever fail:
// the customer record lives on the server, the cameras and what this PC is
// seeing do not.
const VIEWS = [
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
]
export default function App() {
const [session, setSession] = useState(null)
const [view, setView] = useState('live')
const [booting, setBooting] = useState(true)
// A standalone PC can join head office later. That is the same Setup screen,
// reached deliberately rather than because the app will not open otherwise.
const [linking, setLinking] = useState(false)
useEffect(() => {
(async () => {
try { setSession(await api.session()) } catch { setSession(null) }
setBooting(false)
})()
}, [])
if (!isDesktop()) {
// The frontend can be served by `npm run dev` for styling work, where the
// Go bindings do not exist. Saying so beats a blank screen and a console
// error nobody will read.
return (
<div className="login"><div className="box">
<h1>Behavision</h1>
<p className="lead">
This is the Behavision window running outside the app, so it has no
connection to the recognition engine. Launch the Behavision
application instead.
</p>
</div></div>
)
}
if (booting) return <div className="login"><p className="note">Starting…</p></div>
// Which shop this PC IS comes before who is standing at it. An installer on a
// brand new counter has a code and often no account yet, and every screen
// behind here is about a shop this PC does not have one of. Unless there is
// no head office at all, which is the other supported answer.
if (!session?.claimed && !session?.standalone) return <Setup onDone={setSession} />
if (!session?.standalone && !session?.logged_in) return <Login onDone={setSession} />
const views = VIEWS.filter(v => !v.cloud || !session.standalone)
const Current = views.find(v => v.id === view)?.View ?? Live
if (linking) {
return <Setup onDone={s => { setLinking(false); setSession(s) }}
onCancel={() => setLinking(false)} />
}
return (
<div className="shell">
<aside className="side">
<div className="brand">
<h1>Behavision</h1>
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
</div>
<nav className="nav">
{views.map(v => (
<button key={v.id} onClick={() => setView(v.id)}
aria-current={v.id === view ? 'page' : undefined}>
<span className="glyph">{v.glyph}</span>{v.label}
</button>
))}
</nav>
<EngineBox />
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
{session.standalone
? <>
<div className="note" style={{ marginBottom: 8 }}>
Running on its own
</div>
<button className="btn sm" style={{ width: '100%' }}
onClick={() => setLinking(true)}>
Link to head office
</button>
</>
: <>
<div className="note" style={{ marginBottom: 8 }}>
{session.user?.email}
</div>
<button className="btn sm" style={{ width: '100%' }}
onClick={async () => setSession(await api.logout())}>
Sign out
</button>
</>}
</div>
</aside>
<main className="main"><Current session={session} /></main>
</div>
)
}
// Always visible, because "is recognition actually running" is the question
// behind every other screen — an empty Live page means something completely
// different depending on the answer.
function EngineBox() {
const { data, reload } = usePolled(() => api.engineStatus(), 5000)
const [busy, setBusy] = useState(false)
const s = data ?? { state: 'stopped' }
const act = useCallback(async fn => {
setBusy(true)
try { await fn() } catch (e) { alert(message(e)) }
finally { setBusy(false); reload() }
}, [reload])
const running = s.state === 'running'
const cams = Object.values(s.cameras ?? {})
const up = cams.filter(Boolean).length
let tone = 'idle', text = 'Stopped'
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
else if (running && up < cams.length) { tone = 'warn'; text = `${up} of ${cams.length} cameras` }
else if (running) { tone = 'ok'; text = `Watching ${up} camera${up === 1 ? '' : 's'}` }
return (
<div className="enginebox">
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
{s.recognition_model && (
<span className="label">Model: {s.recognition_model}</span>
)}
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
<div className="actions">
<button className="btn sm" disabled={busy || running}
onClick={() => act(api.startEngine)}>Start</button>
<button className="btn sm" disabled={busy || !running}
onClick={() => act(api.stopEngine)}>Stop</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,64 @@
// The single seam between React and Go.
//
// Wails injects bound methods at window.go.main.App.*. Calling them through
// here rather than importing generated bindings means `npm run build` works
// without running `wails generate`, and it gives one place to handle the
// "engine not running yet" case that every screen has to survive.
const app = () => window?.go?.main?.App
export const isDesktop = () => Boolean(app())
async function call(name, ...args) {
const a = app()
if (!a || typeof a[name] !== 'function') {
throw new Error(`${name} is unavailable — run this inside the Behavision app`)
}
return a[name](...args)
}
// Every binding the UI uses, named as the UI thinks of them.
export const api = {
session: () => call('Session'),
login: (email, password) => call('Login', email, password),
logout: () => call('Logout'),
// The one-shot installation code that links this PC to a shop.
claim: (code) => call('Claim', code),
// Set this PC up on its own, with no head office at all.
runStandalone: () => call('RunStandalone'),
engineStatus: () => call('EngineStatus'),
startEngine: () => call('StartEngine'),
stopEngine: () => call('StopEngine'),
cameras: () => call('Cameras'),
testCamera: (cam) => call('TestCamera', cam),
saveCamera: (id, cam) => call('SaveCamera', id, cam),
deleteCamera: (id) => call('DeleteCamera', id),
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
placementResult: (id) => call('PlacementResult', id),
streamURL: (id) => call('StreamURL', id),
live: () => call('Live'),
pipelineStatus: () => call('PipelineStatus'),
localIdentities: (n) => call('LocalIdentities', n),
localSightings: (n) => call('LocalSightings', n),
footfall: (from, to, bucket) => call('Footfall', from, to, bucket),
sites: () => call('Sites'),
visitorHistory: (id, limit) => call('VisitorHistory', id, limit),
visitorPhoto: (id) => call('VisitorPhoto', id),
forgetCustomer: (id) => call('ForgetCustomer', id),
sales: (from, to) => call('Sales', from, to),
customers: (q, limit) => call('Customers', q, limit),
saveProfile: (p) => call('SaveProfile', p),
recordPurchase: (id, amount, items, notes) =>
call('RecordPurchase', id, amount, items, notes),
}
// Errors from Go arrive as strings or Error objects depending on the path.
// Normalising here keeps every catch block in the UI to one line.
export function message(err) {
if (!err) return 'Something went wrong.'
if (typeof err === 'string') return err
return err.message || String(err)
}

View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { message } from './bridge.js'
// One hook for every screen that loads and refreshes.
//
// It exists because the naive version has two bugs every screen would repeat:
// a slow response arriving after the user navigated away sets state on an
// unmounted component, and a poll that fires while the previous request is
// still running stacks up requests against an engine that is already slow.
export function usePolled(fn, intervalMs, deps = []) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [loading, setLoading] = useState(true)
const alive = useRef(true)
const busy = useRef(false)
const run = useCallback(async () => {
if (busy.current) return
busy.current = true
try {
const result = await fn()
if (!alive.current) return
setData(result)
setError(null)
} catch (err) {
if (alive.current) setError(message(err))
} finally {
busy.current = false
if (alive.current) setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
useEffect(() => {
alive.current = true
run()
if (!intervalMs) return () => { alive.current = false }
const id = setInterval(run, intervalMs)
return () => { alive.current = false; clearInterval(id) }
}, [run, intervalMs])
return { data, error, loading, reload: run }
}
export function fmtTime(ts) {
if (!ts) return '—'
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
if (isNaN(d)) return '—'
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function fmtDate(ts) {
if (!ts) return '—'
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
if (isNaN(d)) return '—'
return d.toLocaleDateString([], { day: 'numeric', month: 'short' })
}
export function daysAgo(n) {
const d = new Date()
d.setDate(d.getDate() - n)
return d.toISOString().slice(0, 10)
}
export function today() { return new Date().toISOString().slice(0, 10) }

View File

@@ -0,0 +1,8 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './styles.css'
createRoot(document.getElementById('root')).render(
<React.StrictMode><App /></React.StrictMode>
)

View File

@@ -0,0 +1,252 @@
/* Behavision desktop — an instrument panel, not a website.
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
but not cramped, and state readable at a glance from across a counter. */
:root {
--ground: #0E1317;
--surface: #161D23;
--surface-2: #1D262D;
--line: #27333B;
--line-soft: #1F2A31;
--ink: #E7EEF3;
--ink-2: #B4C2CC;
--muted: #7C8B97;
--accent: #45B0C7;
--accent-dim:#123039;
--ok: #4FB37B;
--warn: #E0A33A;
--bad: #E0655A;
--radius: 8px;
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; }
html, body, #root { height: 100%; }
body {
background: var(--ground);
color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
overflow: hidden;
user-select: none;
}
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* ---------------------------------------------------------------- shell -- */
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
.side {
background: var(--surface); border-right: 1px solid var(--line);
display: flex; flex-direction: column; min-height: 0;
}
.side .brand {
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
}
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
.nav button {
display: flex; align-items: center; gap: 10px; width: 100%;
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
}
.nav button:hover { background: var(--surface-2); color: var(--ink); }
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
display: block; line-height: 1.4; }
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
.main { min-width: 0; min-height: 0; overflow-y: auto; }
.page { padding: 22px 26px 40px; max-width: 1180px; }
.page > header { margin-bottom: 18px; }
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
/* --------------------------------------------------------------- pieces -- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 16px;
}
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
.grid { display: grid; gap: 14px; }
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
font-variant-numeric: tabular-nums; line-height: 1.1; }
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.dot.ok { background: var(--ok); }
.dot.warn { background: var(--warn); }
.dot.bad { background: var(--bad); }
.dot.idle { background: var(--muted); }
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
color: var(--muted); white-space: nowrap; }
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
.btn {
background: var(--surface-2); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
color: var(--ink); white-space: nowrap;
}
.btn:hover:not(:disabled) { background: #26323a; }
.btn:disabled { opacity: .45; cursor: default; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
font-weight: 600; }
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
.btn.danger { color: var(--bad); border-color: #4a2823; }
.btn.sm { padding: 4px 9px; font-size: 12px; }
.field { display: block; margin-bottom: 12px; }
.field span { display: block; font-size: 11.5px; color: var(--muted);
margin-bottom: 4px; letter-spacing: .01em; }
.field input, .field select, .field textarea {
width: 100%; background: var(--ground); border: 1px solid var(--line);
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
user-select: text;
}
.field input:focus, .field select:focus, .field textarea:focus {
border-color: var(--accent); outline: none;
}
.field textarea { resize: vertical; min-height: 66px; }
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted); font-weight: 600;
padding: 8px 10px; border-bottom: 1px solid var(--line); }
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
tr:last-child td { border-bottom: 0; }
tbody tr.click { cursor: pointer; }
tbody tr.click:hover { background: var(--surface-2); }
td.num { font-variant-numeric: tabular-nums; text-align: right; }
.tablewrap { overflow-x: auto; }
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
.err {
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
}
.note { color: var(--muted); font-size: 12.5px; }
.mono { font-family: var(--mono); font-size: 12px; }
/* --------------------------------------------------------------- login --- */
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
.login .box { width: 100%; max-width: 380px; }
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
.login form { background: var(--surface); border: 1px solid var(--line);
border-radius: 10px; padding: 20px; }
.login .btn { width: 100%; margin-top: 6px; }
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
text-align: center; line-height: 1.5; }
/* The second way out of the setup screen: a shop with no head office. Styled
quieter than the form above it because linking is still the common case,
but present, because for a single-till shop it is the only one that works. */
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
border-top: 1px solid var(--line-soft); }
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
.login .alt .btn { margin-top: 0; }
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--accent);
text-decoration: underline; text-underline-offset: 3px; }
.linkbtn:hover { color: var(--ink); }
/* ---------------------------------------------------------------- live --- */
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
overflow: hidden; }
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
.feed .cap { display: flex; justify-content: space-between; align-items: center;
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
.events { list-style: none; max-height: 420px; overflow-y: auto; }
.events li { display: flex; gap: 9px; align-items: baseline;
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.events li:last-child { border-bottom: 0; }
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
flex: none; }
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
background: var(--surface-2); color: var(--muted); }
.tag.new { background: #17364f; color: #86c2ec; }
.tag.seen { background: #14301f; color: #7fcb9c; }
.tag.miss { background: #3a1c1a; color: #eb9a92; }
/* -------------------------------------------------------------- charts --- */
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
gap: 2px; min-width: 0; }
.bars .seg { border-radius: 2px 2px 0 0; }
.bars .seg.ret { background: var(--accent); }
.bars .seg.new { background: #2f6f81; }
.axis { display: flex; justify-content: space-between; color: var(--muted);
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
margin-right: 5px; vertical-align: -1px; }
/* --------------------------------------------------------------- drawer -- */
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
display: flex; justify-content: flex-end; z-index: 30; }
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
/* Close lives in the sticky header (.who) now. Positioned against the fixed
overlay it stayed put while the sheet scrolled underneath it, printing the
button on top of whatever happened to be at the top of the viewport. */
/* -- customer record ---------------------------------------------------- */
/* Full-bleed sticky header: a customer record is long enough to scroll, and
both the name and the way out have to stay reachable. The negative margins
cancel the panel's padding so the background covers the full width. */
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
align-items: flex-start; background: var(--surface);
margin: -20px -22px 18px; padding: 20px 22px 14px;
border-bottom: 1px solid var(--line-soft); }
.who .grow { flex: 1; min-width: 0; }
.who h3 { margin-bottom: 2px; }
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
object-fit: cover; background: var(--ground);
border: 1px solid var(--line); }
.avatar.none { display: grid; place-items: center; color: var(--muted);
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
.timeline li:last-child { border-bottom: 0; }
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
flex: none; min-width: 108px; }
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
/* Visually separated from Save: this is the one control in the sheet that
cannot be undone, and it must not read as just another button in a row. */
.danger-zone { margin-top: 22px; border-color: #4a2823; }
.danger-zone > h3 { color: var(--bad); }
.danger-zone .note { margin-bottom: 10px; }
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
margin-bottom: 12px; }
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 5px; }
.confirm .lbl.bad { color: var(--bad); }
.confirm ul { list-style: none; font-size: 12.5px; }
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
.confirm li::before { content: '·'; position: absolute; left: 2px;
color: var(--muted); }
.confirm .row { display: flex; gap: 8px; }

View File

@@ -0,0 +1,280 @@
import { useEffect, useRef, useState } from 'react'
import { api, message } from '../bridge.js'
import { usePolled } from '../hooks.js'
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
max_width: 1280 }
export default function Cameras() {
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
const [editing, setEditing] = useState(null)
const [check, setCheck] = useState(null)
const cams = data ?? []
async function remove(id) {
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
}
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Cameras</h2>
<p>Add a camera, check it can see faces properly, then it starts working.</p>
</div>
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
Add camera
</button>
</header>
{error && <div className="err">{error}</div>}
<div className="card">
{cams.length === 0
? <div className="empty">No cameras yet.</div>
: <div className="tablewrap">
<table>
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
<tbody>
{cams.map(c => (
<tr key={c.id}>
<td>{c.id}</td>
<td className="mono">{c.url}</td>
<td>
{c.connected === undefined
? <span className="pill"><i className="dot idle" />stopped</span>
: c.connected
? <span className="pill ok"><i className="dot ok" />live</span>
: <span className="pill bad"><i className="dot bad" />offline</span>}
</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
<button className="btn sm" onClick={() => setCheck(c.id)}>
Check placement
</button>{' '}
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
<button className="btn sm danger" onClick={() => remove(c.id)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
onSaved={() => { setEditing(null); reload() }} />}
{check && <PlacementSheet id={check} onClose={() => setCheck(null)} />}
</div>
)
}
function CameraSheet({ cam, onClose, onSaved }) {
const isNew = !cam.id
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
path: cam.path || (isNew ? MAKES[0].path : '') })
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
const [test, setTest] = useState(null)
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const set = k => e => setF({ ...f, [k]: e.target.value })
// Only overwrite the path when the preset has one, so choosing "I know the
// path" does not wipe what the installer already typed.
function chooseMake(e) {
const m = makeById(e.target.value)
setMake(m.id)
setF(prev => ({ ...prev, path: m.path || prev.path }))
}
// Blank means "leave alone", never "clear". The engine never returns a
// stored password, so sending an empty one would wipe it on every edit.
function payload() {
const out = {}
for (const [k, v] of Object.entries(f)) {
if (v === '' || v === null || v === undefined) continue
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
}
return out
}
async function runTest() {
setBusy('test'); setError(null); setTest(null)
try { setTest(await api.testCamera(payload())) }
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
async function save(e) {
e.preventDefault()
setBusy('save'); setError(null)
try { await api.saveCamera(isNew ? '' : cam.id, payload()); onSaved() }
catch (e) { setError(message(e)) } finally { setBusy(null) }
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>{isNew ? 'Add camera' : cam.id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Test the connection before saving — a wrong address is the most common mistake.
</p>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Name</span>
<input value={f.id} onChange={set('id')} disabled={!isNew}
placeholder="entrance" required autoComplete="off" />
</label>
{/* The highest-value field on this form. The address and the
password are on a label or in the installer's notes; the RTSP
path is not written anywhere a shop owner would look, and getting
it wrong produces "could not open stream", which reads like a
password problem and is not. */}
<label className="field"><span>Make of camera</span>
<select value={make} onChange={chooseMake}>
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
</select>
</label>
{makeById(make).note && (
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
{makeById(make).note}
</p>
)}
<div className="fieldrow">
<label className="field"><span>Camera address</span>
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
autoComplete="off" />
</label>
<label className="field"><span>Port</span>
<input value={f.port} onChange={set('port')} inputMode="numeric"
autoComplete="off" />
</label>
</div>
<label className="field"><span>Stream path</span>
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
autoComplete="off" />
</label>
<div className="fieldrow">
{/* A text input next to a password input is a sign-in form as far
as the webview is concerned, so without this the browser offers
the operator's own Behavision email as the camera's username -
which fails with a message about credentials that points at the
camera. "off" alone is frequently ignored; a non-login name and
new-password on the secret are what actually work. */}
<label className="field"><span>Username</span>
<input value={f.username} onChange={set('username')}
name="camera-account" autoComplete="off" />
</label>
<label className="field"><span>Password</span>
<input type="password" value={f.password} onChange={set('password')}
name="camera-secret" autoComplete="new-password"
placeholder={cam.has_password ? '(unchanged)' : ''} />
</label>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
{busy === 'test' ? 'Connecting…' : 'Test connection'}
</button>
<button className="btn primary" disabled={!!busy || !f.id}>
{busy === 'save' ? 'Saving…' : 'Save'}
</button>
</div>
{test && (
<div style={{ marginTop: 14 }}>
{test.ok
? <>
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
Connected — {test.width}×{test.height}
</p>
{test.snapshot && (
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
borderRadius: 6, border: '1px solid var(--line)' }}
src={`data:image/jpeg;base64,${test.snapshot}`} />
)}
</>
: <div className="err">{test.error}</div>}
</div>
)}
</form>
</div>
</div>
)
}
// The commissioning wizard. This is what stops a site being signed off with a
// camera that recognises nobody — the failure that otherwise shows up weeks
// later as a footfall report that was always zero.
function PlacementSheet({ id, onClose }) {
const [state, setState] = useState({ verdict: 'starting', advice: [] })
const [error, setError] = useState(null)
const timer = useRef(null)
useEffect(() => {
let alive = true
;(async () => {
try {
setState(await api.startPlacement(id, 25))
timer.current = setInterval(async () => {
try {
const r = await api.placementResult(id)
if (!alive) return
setState(r)
if (!r.running) clearInterval(timer.current)
} catch (e) { if (alive) setError(message(e)) }
}, 1000)
} catch (e) { if (alive) setError(message(e)) }
})()
return () => { alive = false; clearInterval(timer.current) }
}, [id])
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<button className="btn sm close" onClick={onClose}>Close</button>
<h3>Placement check — {id}</h3>
<p className="note" style={{ marginBottom: 18 }}>
Walk past the camera the way a customer would, a few times.
</p>
{error && <div className="err">{error}</div>}
<div className="card">
<div style={{ fontSize: 15, fontWeight: 600,
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
{state.headline || 'Starting…'}
</div>
{state.running && (
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
overflow: 'hidden', margin: '12px 0' }}>
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
transition: 'width .4s linear' }} />
</div>
)}
{state.advice?.length > 0 && (
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
</ul>
)}
{state.quality?.n > 0 && (
<p className="note" style={{ marginTop: 12 }}>
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
median quality {state.quality.p50} ·
gate {state.gate} ·
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
import VisitHistory from './VisitHistory.jsx'
import EraseCustomer from './EraseCustomer.jsx'
// The in-store form. Two jobs in one sheet: capture who this person is, and
// record what they bought — because staff have the customer in front of them
// once, and asking them to open a second screen means the sale never gets
// recorded.
export default function CustomerForm({ customer, session, onClose, onSaved }) {
const [f, setF] = useState({
full_name: customer.full_name ?? '',
phone: customer.phone ?? '',
email: customer.email ?? '',
gender: '',
date_of_birth: '',
notes: '',
consent: customer.has_consent ?? false,
})
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const [erasing, setErasing] = useState(false)
const [photo, setPhoto] = useState(null)
const fetched = useCustomerPhoto(customer.id)
const shown = photo ?? fetched
// Erasure destroys a record permanently, so it is a manager's decision. The
// server enforces this too — this only avoids offering a button that would
// come back 403.
const role = session?.user?.role
const canErase = ['admin', 'owner', 'manager'].includes(role)
const set = k => e => setF({ ...f, [k]: e.target.value })
async function save(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
await api.saveProfile({ visitor_id: customer.id, ...f })
const amount = parseFloat(purchase.amount)
if (!isNaN(amount) && amount > 0) {
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
await api.recordPurchase(customer.id, amount, items, purchase.notes)
}
onSaved()
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
<div className="panel">
<div className="who">
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
onBroken={() => setPhoto({ available: false,
reason: 'The photo could not be loaded.' })} />
<div className="grow">
<h3>{customer.full_name || customer.label}</h3>
<p className="note">
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
</p>
{shown && !shown.available && shown.reason &&
<p className="note">{shown.reason}</p>}
</div>
<button type="button" className="btn sm" onClick={onClose}>Close</button>
</div>
<form onSubmit={save}>
{error && <div className="err">{error}</div>}
<div className="card" style={{ marginBottom: 14 }}>
<h3>Customer details</h3>
<label className="field">
<span>Full name</span>
<input value={f.full_name} onChange={set('full_name')} autoFocus />
</label>
<div className="fieldrow">
<label className="field">
<span>Phone</span>
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
</label>
<label className="field">
<span>Email</span>
<input value={f.email} onChange={set('email')} type="email" />
</label>
</div>
<div className="fieldrow">
<label className="field">
<span>Gender</span>
<select value={f.gender} onChange={set('gender')}>
<option value="">Not recorded</option>
<option>Female</option><option>Male</option><option>Other</option>
</select>
</label>
<label className="field">
<span>Date of birth</span>
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
</label>
</div>
<label className="field">
<span>Notes</span>
<textarea value={f.notes} onChange={set('notes')}
placeholder="Preferences, sizes, anything worth remembering" />
</label>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Purchase (optional)</h3>
<div className="fieldrow">
<label className="field">
<span>Amount</span>
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
</label>
<label className="field">
<span>Items</span>
<input value={purchase.items} placeholder="shirt, belt"
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
</label>
</div>
<p className="note">Leave the amount blank if they did not buy anything —
a visit without a sale is still worth recording.</p>
</div>
<div className="card" style={{ marginBottom: 16 }}>
<h3>Consent</h3>
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
onChange={e => setF({ ...f, consent: e.target.checked })} />
<span>This customer agreed to us keeping their details and
recognising them on future visits.</span>
</label>
<p className="note" style={{ marginTop: 10 }}>
Recorded with the date and who collected it. They can withdraw it
at any time, which erases their face data.
</p>
</div>
<div className="card" style={{ marginBottom: 14 }}>
<h3>Visits</h3>
<VisitHistory customer={customer} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn primary" disabled={busy}>
{busy ? 'Saving…' : 'Save'}
</button>
<button type="button" className="btn" onClick={onClose}>Cancel</button>
</div>
{canErase && (
<div className="card danger-zone">
<h3>At the customer's request</h3>
{erasing
? <EraseCustomer customer={customer}
onCancel={() => setErasing(false)}
onDone={onSaved} />
: <>
<p className="note">
Erase this person's face data, photo and details. Their
past visits stay in your footfall figures, without their
name.
</p>
<button type="button" className="btn danger"
onClick={() => setErasing(true)}>
Erase this customer…
</button>
</>}
</div>
)}
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'
import { api } from '../bridge.js'
// The customer's face, when there is one.
//
// Fetched when the sheet opens rather than stored with the customer row: the
// server hands out a signed link that expires in minutes, deliberately, so
// that "delete my data" can actually make a picture stop loading. A link kept
// in a list rendered an hour ago is a broken image.
//
// The fetch lives in a hook and happens ONCE per sheet, because the server
// writes an audit_log row for every read of a face image — "who looked at my
// customers" has to be answerable — and a component that fetched its own copy
// for the picture and again for the caption would put two rows in that log for
// one glance at one person.
export function useCustomerPhoto(id) {
const [photo, setPhoto] = useState(null)
useEffect(() => {
let alive = true
setPhoto(null)
api.visitorPhoto(id)
.then(p => { if (alive) setPhoto(p) })
// A failure to load a photo must never take the customer record with
// it: the name and phone number are what staff opened this for.
.catch(() => { if (alive) setPhoto({ available: false, reason: '' }) })
return () => { alive = false }
}, [id])
return photo
}
// No photo is the normal case — images are off by default — so this renders
// initials, not an error.
export default function CustomerPhoto({ photo, name, onBroken }) {
if (photo?.available) {
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
onError={onBroken} />
}
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
.map(w => w[0].toUpperCase()).join('') || '?'
return (
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
<span>{initials}</span>
</div>
)
}

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtDate } from '../hooks.js'
import CustomerForm from './CustomerForm.jsx'
// The customer database, and the form staff fill in when someone walks in.
export default function Customers({ session }) {
const [query, setQuery] = useState('')
const [selected, setSelected] = useState(null)
const { data, error, reload } = usePolled(
() => api.customers(query, 200), 30000, [query])
const rows = data ?? []
const named = rows.filter(r => r.has_profile).length
return (
<div className="page">
<header>
<h2>Customers</h2>
<p>Everyone this business has recognised. Fill in details once and they
are known at every store.</p>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Known people" value={rows.length || '—'} />
<Stat label="With details" value={named || '—'}
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
</div>
<div className="card">
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
placeholder="Search by name or phone"
value={query} onChange={e => setQuery(e.target.value)} />
<button className="btn" onClick={reload}>Refresh</button>
</div>
{rows.length === 0
? <div className="empty">
No customers yet. They appear here the first time a camera sees them.
</div>
: <div className="tablewrap">
<table>
<thead>
<tr>
<th>Customer</th><th>Phone</th>
<th className="num">Visits</th>
<th>First seen</th><th>Last seen</th><th>Details</th>
</tr>
</thead>
<tbody>
{rows.map(c => (
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
<td>{c.full_name || <span className="note">{c.label}</span>}</td>
<td className="mono">{c.phone || '—'}</td>
<td className="num">{c.visit_count}</td>
<td>{fmtDate(c.first_seen_at)}</td>
<td>{fmtDate(c.last_seen_at)}</td>
<td>
{c.has_profile
? <span className="pill ok"><i className="dot ok" />captured</span>
: <span className="pill warn"><i className="dot warn" />needed</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>}
</div>
{selected && (
<CustomerForm customer={selected} session={session}
onClose={() => setSelected(null)}
onSaved={() => { setSelected(null); reload() }} />
)}
</div>
)
}
function Stat({ label, value, sub }) {
return (
<div className="card stat">
<h3>{label}</h3><div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// Erasing a customer, at that customer's request.
//
// This is a legal obligation with a destructive implementation, so the screen
// does two things a plain "are you sure" cannot. It says exactly what is
// destroyed and exactly what is kept — staff are asked "will you delete my
// data?" by a real person standing in front of them and have to be able to
// answer truthfully — and it requires the customer's name to be typed, because
// this sits next to Save in a sheet used all day and a misclick is
// unrecoverable.
//
// What is kept is not an oversight. Visits stay (unlinked): they are the
// shop's own footfall history, and silently rewriting last quarter's numbers
// because one customer exercised a right is both wrong and detectable. Consent
// stays, revoked: deleting it destroys the proof of what we were permitted to
// do and when, which is the first thing an auditor asks for.
export default function EraseCustomer({ customer, onCancel, onDone }) {
const name = customer.full_name || customer.label
const [typed, setTyped] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const confirmed = typed.trim().toLowerCase() === name.trim().toLowerCase()
async function erase() {
setBusy(true); setError(null)
try {
await api.forgetCustomer(customer.id)
onDone()
} catch (err) {
// The server deletes stored photos before it touches the database and
// refuses the whole request if one fails, so a failure here means
// nothing was erased. Say so — the alternative is a shop believing a
// request was honoured when it was not.
setError(message(err))
setBusy(false)
}
}
return (
<div className="confirm">
<h4>Erase {name}?</h4>
<div className="cols">
<div>
<p className="lbl bad">Deleted for good</p>
<ul>
<li>Their face data — they will not be recognised again</li>
<li>Their photo</li>
<li>Their name, phone, email and notes</li>
</ul>
</div>
<div>
<p className="lbl">Kept</p>
<ul>
<li>Past visits, with their name removed — your footfall figures
do not change</li>
<li>The consent record, marked withdrawn, as proof of what was
agreed</li>
</ul>
</div>
</div>
<p className="note">This cannot be undone. If they come back they will be
recorded as a new customer.</p>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Type <b>{name}</b> to confirm</span>
<input value={typed} onChange={e => setTyped(e.target.value)}
autoFocus autoComplete="off" spellCheck="false"
aria-label={`Type ${name} to confirm erasure`} />
</label>
<div className="row">
<button type="button" className="btn danger"
disabled={!confirmed || busy} onClick={erase}>
{busy ? 'Erasing…' : 'Erase permanently'}
</button>
<button type="button" className="btn" onClick={onCancel} disabled={busy}>
Cancel
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, daysAgo, today } from '../hooks.js'
const RANGES = [
{ label: '7 days', days: 7, bucket: 'day' },
{ label: '30 days', days: 30, bucket: 'day' },
{ label: '90 days', days: 90, bucket: 'week' },
]
export default function Footfall() {
const [range, setRange] = useState(RANGES[0])
const { data, error, loading } = usePolled(
() => api.footfall(daysAgo(range.days), today(), range.bucket),
60000, [range.days, range.bucket])
const sites = usePolled(() => api.sites(), 60000, [])
const points = data?.points ?? []
const peak = Math.max(1, ...points.map(p => p.visitors))
// Both numbers come from the server, and neither is the sum of the chart.
//
// A customer who came on Monday and Thursday is ONE person and TWO
// bucket-visitors, so adding the bars up gives a headcount that is silently
// too high. Summing new + returning is wrong a second way: a site sending
// counts without face templates produces visits that are real footfall but
// an unknown person, and those are counted in neither.
const unique = data?.total ?? 0
const visits = data?.visits ?? 0
const totalNew = points.reduce((n, p) => n + (p.new ?? 0), 0)
const totalRet = points.reduce((n, p) => n + (p.returning ?? 0), 0)
const identified = totalNew + totalRet
const gate = data?.fraction_below_gate ?? 0
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Footfall</h2>
<p>Visitors over time, split by whether we had seen them before.</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{RANGES.map(r => (
<button key={r.label} className="btn sm"
aria-pressed={r.days === range.days}
style={r.days === range.days
? { background: 'var(--accent-dim)', color: 'var(--accent)',
borderColor: 'var(--accent)' } : undefined}
onClick={() => setRange(r)}>{r.label}</button>
))}
</div>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People" value={unique || '—'}
sub={visits ? `${visits} visit${visits === 1 ? '' : 's'} in total` : null} />
<Stat label="New" value={totalNew || '—'} />
<Stat label="Returning" value={totalRet || '—'}
sub={identified ? `${Math.round(100 * totalRet / identified)}% of recognised visits` : null} />
<Stat label="Below quality gate"
value={data ? `${Math.round(gate * 100)}%` : '—'}
tone={gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : undefined}
sub={data?.worst_site ? `worst: ${data.worst_site}` : 'Faces seen but too poor to count'} />
</div>
<div className="card">
<h3>Visitors per {range.bucket}</h3>
{loading && !points.length ? <div className="empty">Loading…</div>
: points.length === 0 ? <div className="empty">No visits recorded in this period.</div>
: <>
<div className="bars">
{points.map((p, i) => (
<div className="col" key={i}
title={`${bucketLabel(p.bucket, range.bucket)}: ${p.visitors} visitor${p.visitors === 1 ? '' : 's'}`}>
<div className="seg new" style={{ height: `${(p.new / peak) * 100}%` }} />
<div className="seg ret" style={{ height: `${(p.returning / peak) * 100}%` }} />
</div>
))}
</div>
<div className="axis">
<span>{bucketLabel(points[0]?.bucket, range.bucket)}</span>
<span>{bucketLabel(points[points.length - 1]?.bucket, range.bucket)}</span>
</div>
<div className="key">
<span><i style={{ background: 'var(--accent)' }} />Returning</span>
<span><i style={{ background: '#2f6f81' }} />New</span>
{data?.timezone && <span style={{ marginLeft: 'auto', opacity: 0.6 }}>
times in {data.timezone}</span>}
</div>
</>}
</div>
<Sites sites={sites.data} error={sites.error} />
{gate > 0.5 && (
<div className="err" style={{ marginTop: 16 }}>
More than half the faces {data.worst_site ? `at ${data.worst_site}` : 'this site'} saw
were too poor to count, so this chart understates real footfall. Run a
placement check on that camera before trusting these numbers.
</div>
)}
</div>
)
}
// A site that has stopped reporting looks exactly like a site with no
// customers - the same row of zeroes - and only one of them is something to
// act on. This is the difference, shown next to the chart it explains.
function Sites({ sites, error }) {
if (error || !sites || sites.length === 0) return null
return (
<div className="card" style={{ marginTop: 16 }}>
<h3>Sites</h3>
<table>
<tbody>
{sites.map(s => (
<tr key={s.site_id}>
<td><b>{s.name}</b></td>
<td>
{/* .dot is sized, so it needs a box: a bare span is inline
and would collapse to nothing inside a table cell. */}
<span className={`dot ${s.online ? 'ok' : 'bad'}`}
style={{ display: 'inline-block', marginRight: 6 }} />
{s.online ? 'Reporting' : 'Not reporting'}
</td>
<td>{s.cameras_total
? `${s.cameras_up}/${s.cameras_total} camera${s.cameras_total === 1 ? '' : 's'} connected`
: 'no cameras'}</td>
{/* Dropped events are footfall this site permanently lost, so it
has to be visible rather than inferred from a dip in a graph. */}
<td>{s.dropped > 0
? <span style={{ color: 'var(--bad)' }}>{s.dropped} events lost</span>
: s.queued > 0 ? `${s.queued} queued` : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// Buckets come back as local wall time with no offset, labelled by the
// timezone in the report. Parsing them as a Date would re-interpret them in
// the viewer's zone and shift every label by hours.
function bucketLabel(bucket, size) {
if (!bucket) return ''
const [date, time] = bucket.split('T')
if (size === 'hour') return `${date.slice(5)} ${(time || '').slice(0, 5)}`
return date
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,192 @@
import { useEffect, useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, fmtTime } from '../hooks.js'
// What is happening right now. The first screen a shop manager opens, so it
// answers "is it working" before it answers anything else.
export default function Live() {
const { data, error } = usePolled(() => api.live(), 3000)
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
const cams = useCameraFeeds()
const cameras = data?.stats?.cameras ?? []
const gallery = data?.stats?.gallery ?? {}
const events = data?.events ?? []
// fraction_below_gate is the number that decides a site: what share of the
// faces this camera saw were too poor to enrol. Surfaced here rather than
// buried, because a high value looks exactly like "a quiet day".
const worst = cameras.reduce((acc, c) => {
const f = c?.pipeline?.best_quality?.fraction_below_gate
return typeof f === 'number' && f > acc ? f : acc
}, 0)
return (
<div className="page">
<header>
<h2>Live</h2>
<p>Cameras, recent detections, and whether this site is recognising people.</p>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="People known" value={gallery.identities ?? '—'} />
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
<Stat label="Cameras live"
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
<Stat label="Below quality gate"
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
: 'Share of faces too poor to enrol'} />
</div>
<Pipeline pipe={pipe} />
<div className="grid cols-2">
<div>
<div className="card">
<h3>Cameras</h3>
{cameras.length === 0
? <div className="empty">No cameras yet. Add one in Cameras.</div>
: <div className="feeds">
{cameras.map(c => (
<div className="feed" key={c.camera_id}>
{cams[c.camera_id]
? <img src={cams[c.camera_id]} alt={c.camera_id} />
: <div style={{ aspectRatio: '16/9' }} />}
<div className="cap">
<span>{c.camera_id}</span>
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
{c.connected ? 'live' : 'offline'}
</span>
</div>
</div>
))}
</div>}
</div>
</div>
<div className="card">
<h3>Recent detections</h3>
{events.length === 0
? <div className="empty">Nothing detected yet.</div>
: <ul className="events">
{events.map((e, i) => <EventRow key={i} e={e} />)}
</ul>}
</div>
</div>
</div>
)
}
// Whether anything is actually reaching head office. Without this the app can
// look perfectly healthy while every detection piles up on disk unsent — which
// is exactly what it did before the bridge existed.
function Pipeline({ pipe }) {
if (!pipe) return null
// A PC set up on its own is not "not linked yet" — nothing is coming, and
// saying so with an idle dot beside a count of zero reads as a fault.
if (pipe.standalone) {
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 10, alignItems: 'center' }}>
<i className="dot ok" />
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
<span className="note">Recognition and customers stay here.</span>
</div>
)
}
const stuck = pipe.claimed && !pipe.broker_up
return (
<div className="card" style={{ marginBottom: 16, display: 'flex',
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
<strong style={{ fontSize: 13 }}>
{!pipe.claimed ? 'Not linked to head office'
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
</strong>
</span>
<span className="note">{pipe.accepted} recorded today</span>
{pipe.queued > 0 && (
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
{pipe.queued} waiting to send
</span>
)}
{pipe.dropped > 0 && (
<span className="note" style={{ color: 'var(--bad)' }}>
{pipe.dropped} lost — this PC was offline too long
</span>
)}
</div>
)
}
function EventRow({ e }) {
const cls = e.type === 'person.new' ? 'new'
: e.type === 'person.seen' ? 'seen'
: e.type === 'person.missed' ? 'miss' : ''
const age = e.data?.age ?? e.data?.age_range
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
return (
<li>
<span className="when">{fmtTime(e.ts)}</span>
<span className={`tag ${cls}`}>{label(e.type)}</span>
<span style={{ flex: 1, minWidth: 0 }}>
{e.data?.label || e.camera_id}
{extra && <span className="note"> · {extra}</span>}
</span>
</li>
)
}
// The event names are internal; a shop manager should not have to learn them.
function label(type) {
return {
'person.new': 'new',
'person.seen': 'returning',
'person.missed': 'missed',
'camera.up': 'camera up',
'camera.down': 'camera down',
'identity.merged': 'merged',
}[type] ?? type
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
{value}
</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}
// Stream URLs are fetched once per camera and then left alone: reassigning an
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
// make every feed flicker permanently.
function useCameraFeeds() {
const [urls, setUrls] = useState({})
const { data } = usePolled(() => api.cameras(), 10000)
useEffect(() => {
let cancelled = false
;(async () => {
const next = {}
for (const cam of data ?? []) {
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
}
const changed = Object.keys(next).length !== Object.keys(urls).length ||
Object.keys(next).some(k => next[k] !== urls[k])
if (!cancelled && changed) setUrls(next)
})()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data])
return urls
}

View File

@@ -0,0 +1,52 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// The gate. Nothing else in the app is reachable until this succeeds, because
// the broker credentials and the customer database both live behind it.
export default function Login({ onDone }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
async function submit(e) {
e.preventDefault()
setBusy(true); setError(null)
try {
onDone(await api.login(email.trim(), password))
} catch (err) {
setError(message(err))
} finally {
setBusy(false)
}
}
return (
<div className="login">
<div className="box">
<h1>Behavision</h1>
<p className="lead">Sign in to connect this PC to your store.</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Email</span>
<input type="email" value={email} autoComplete="username" required
autoFocus onChange={e => setEmail(e.target.value)} />
</label>
<label className="field">
<span>Password</span>
<input type="password" value={password} autoComplete="current-password"
required onChange={e => setPassword(e.target.value)} />
</label>
<button className="btn primary" disabled={busy || !email || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="foot">
Signing in downloads this store's recognition models and connects it
to your account. Nothing is sent until a camera is set up.
</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react'
import { api } from '../bridge.js'
import { usePolled, daysAgo, today } from '../hooks.js'
const RANGES = [{ label: '7 days', days: 7 }, { label: '30 days', days: 30 },
{ label: '90 days', days: 90 }]
// Footfall against sales. The number the business actually judges the product
// by: how many of the people who walked in bought something.
export default function Sales() {
const [range, setRange] = useState(RANGES[1])
const { data, error } = usePolled(
() => api.sales(daysAgo(range.days), today()), 60000, [range.days])
const cur = data?.currency || 'INR'
const money = n => typeof n === 'number'
? new Intl.NumberFormat(undefined, { style: 'currency', currency: cur,
maximumFractionDigits: 0 }).format(n)
: '—'
const conv = data?.conversion ?? 0
return (
<div className="page">
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
<div>
<h2>Sales summary</h2>
<p>How many of the people who came in actually bought something.</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{RANGES.map(r => (
<button key={r.label} className="btn sm"
style={r.days === range.days
? { background: 'var(--accent-dim)', color: 'var(--accent)',
borderColor: 'var(--accent)' } : undefined}
onClick={() => setRange(r)}>{r.label}</button>
))}
</div>
</header>
{error && <div className="err">{error}</div>}
<div className="grid cols-4" style={{ marginBottom: 16 }}>
<Stat label="Visitors" value={data?.visitors ?? '—'} />
<Stat label="Bought something" value={data?.purchasers ?? '—'} />
<Stat label="Conversion" value={data ? `${Math.round(conv * 100)}%` : '—'}
tone={conv >= 0.3 ? 'ok' : conv > 0 ? 'warn' : undefined} />
<Stat label="Revenue" value={money(data?.revenue)}
sub={data ? `${money(data.average_basket)} average basket` : null} />
</div>
<div className="card">
<h3>Visitors who bought</h3>
{!data ? <div className="empty">Loading…</div>
: data.visitors === 0
? <div className="empty">No visits recorded in this period.</div>
: <>
<div style={{ display: 'flex', height: 34, borderRadius: 6,
overflow: 'hidden', border: '1px solid var(--line)' }}>
<div style={{ width: `${conv * 100}%`, background: 'var(--accent)' }} />
<div style={{ flex: 1, background: 'var(--surface-2)' }} />
</div>
<div className="key">
<span><i style={{ background: 'var(--accent)' }} />
Bought — {data.purchasers}</span>
<span><i style={{ background: 'var(--surface-2)' }} />
Left without buying — {data.visitors - data.purchasers}</span>
</div>
</>}
</div>
<p className="note" style={{ marginTop: 14 }}>
Sales come from what staff enter on the customer form. A visit with no
amount counts as a visit that did not convert, which is what makes this
figure meaningful.
</p>
</div>
)
}
function Stat({ label, value, sub, tone }) {
return (
<div className="card stat">
<h3>{label}</h3>
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
{sub && <div className="sub">{sub}</div>}
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { useState } from 'react'
import { api, message } from '../bridge.js'
// Linking this PC to a shop — the first thing that happens on a new install,
// and until now the one thing the app could not do.
//
// It comes BEFORE sign-in on purpose. The installer standing at a new counter
// has an installation code and, quite often, no account of their own yet; the
// PC's identity is not a person's identity. The endpoint behind this is
// deliberately unauthenticated for the same reason — requiring a login first
// would mean shipping a password to every shop that installs the software.
export default function Setup({ onDone, onCancel }) {
const [code, setCode] = useState('')
const [busy, setBusy] = useState(null)
const [error, setError] = useState(null)
const [alone, setAlone] = useState(false)
async function submit(e) {
e.preventDefault()
setBusy('claim'); setError(null)
try {
onDone(await api.claim(code))
} catch (err) {
setError(message(err))
} finally {
setBusy(null)
}
}
async function standalone() {
setBusy('alone'); setError(null)
try {
onDone(await api.runStandalone())
} catch (err) {
setError(message(err))
} finally {
setBusy(null)
}
}
return (
<div className="login">
<div className="box">
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
<p className="lead">
Type the installation code for this shop. You only do this once.
</p>
<form onSubmit={submit}>
{error && <div className="err">{error}</div>}
<label className="field">
<span>Installation code</span>
{/* Uppercase and letter-spaced because the code arrives read aloud
down a phone or photographed off a screen. Spaces, dashes and
case are stripped on the server, so what is typed here can be
as untidy as it needs to be. */}
<input value={code} autoFocus required
placeholder="ABCDEF-123456-GHIJKL-789012"
autoComplete="off" spellCheck="false"
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
onChange={e => setCode(e.target.value)} />
</label>
<button className="btn primary" disabled={!!busy || code.trim().length < 6}>
{busy === 'claim' ? 'Linking…' : 'Link this PC'}
</button>
</form>
<p className="foot">
The code works once. Ask whoever manages your shops for it — they can
create one from the Behavision platform, under the shop.
</p>
{/* The second way out of this screen, and the reason it exists.
Recognition, the cameras and this shop's own gallery all run on
this PC and need no server, so a shop with one till and no head
office was being blocked from adding a camera until somebody
issued it a code — the software refusing to do the thing it is
for. Linking later is still one click away, and it keeps the
visits already recorded here. */}
<div className="alt">
{onCancel
? <button type="button" className="linkbtn" onClick={onCancel}>
Not now — go back
</button>
: !alone
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
No head office — set this PC up on its own
</button>
: <>
<p className="note">
This PC will watch its cameras and recognise returning
customers on its own. Nothing is sent anywhere. You can link
it to head office later without losing anything recorded
here.
</p>
<button type="button" className="btn" disabled={!!busy}
onClick={standalone}>
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
</button>
</>}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { api } from '../bridge.js'
import { usePolled } from '../hooks.js'
// One customer's timeline.
//
// The endpoint and the binding for this both already existed and nothing
// called them, which meant the product could recognise a returning customer
// and then had no screen able to say when they had been before — the single
// question staff ask about a regular.
//
// Not polled: a record sheet open on a counter should not re-query every few
// seconds, and the visit that matters is happening at the counter, not in the
// list.
export default function VisitHistory({ customer }) {
const { data, error, loading } = usePolled(
() => api.visitorHistory(customer.id, 50), 0, [customer.id])
if (loading) return <p className="note">Loading visits…</p>
if (error) return <p className="note">Could not load visits: {error}</p>
const visits = data ?? []
if (visits.length === 0) {
return <p className="note">No recorded visits yet.</p>
}
return (
<ul className="timeline">
{visits.map(v => (
<li key={v.id}>
<span className="when">{whenLabel(v.occurred_at)}</span>
<span className="where">
{v.site || 'this store'}
{v.camera_id ? <span className="note"> · {v.camera_id}</span> : null}
</span>
{v.is_new_visitor && <span className="tag new">first visit</span>}
</li>
))}
</ul>
)
}
// The server returns visit times as an instant; showing the date and the time
// of day matters more than precision here — "Tuesday afternoon" is how staff
// remember a customer.
function whenLabel(iso) {
const d = new Date(iso)
if (isNaN(d)) return '—'
return d.toLocaleString([], {
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
})
}

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
// Wails serves these from an embedded FS at the root, so relative asset
// paths are required - absolute ones 404 inside the webview.
assetsDir: 'assets',
emptyOutDir: true,
},
base: './',
})

29
desktop/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module github.com/loyaly/behavision-desktop
go 1.22
// The desktop app is the agent with a face on it: same supervisor, same
// durable spool, same broker client, all already tested. A replace rather
// than a copy so there is exactly one implementation of each.
replace github.com/loyaly/behavision-agent => ../agent
require (
fyne.io/systray v1.12.2
github.com/loyaly/behavision-agent v0.0.0
github.com/wailsapp/wails/v2 v2.9.2
)
require (
github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/wailsapp/go-webview2 v1.0.16 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.20.0 // indirect
)

94
desktop/go.sum Normal file
View File

@@ -0,0 +1,94 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.0 h1:T8TuMhFB6TUMIUm0oRrSbgJudTFw9csT3ZK09w0t4Pg=
github.com/leaanthony/go-ansi-parser v1.6.0/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.3 h1:Fnt+/B6NjQOVuCWOKYRREZnjGyvg+mEhd1nkkA04aTQ=
github.com/leaanthony/gosod v1.0.3/go.mod h1:BJ2J+oHsQIyIQpnLPjnqFGTMnOZXDbvWtRCSG7jGxs4=
github.com/leaanthony/slicer v1.5.0/go.mod h1:FwrApmf8gOrpzEWM2J/9Lh79tyq8KTX5AzRtwV7m4AY=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.0 h1:2n0d2BwPVXSUq5yhe8lJPHdxevE2qK5G99PMStMZMaI=
github.com/leaanthony/u v1.1.0/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tkrajina/go-reflector v0.5.6 h1:hKQ0gyocG7vgMD2M3dRlYN6WBBOmdoOzJ6njQSepKdE=
github.com/tkrajina/go-reflector v0.5.6/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.16 h1:wffnvnkkLvhRex/aOrA3R7FP7rkvOqL/bir1br7BekU=
github.com/wailsapp/go-webview2 v1.0.16/go.mod h1:Uk2BePfCRzttBBjFrBmqKGJd41P6QIHeV9kTgIeOZNo=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.9.2 h1:Xb5YRTos1w5N7DTMyYegWaGukCP2fIaX9WF21kPPF2k=
github.com/wailsapp/wails/v2 v2.9.2/go.mod h1:uehvlCwJSFcBq7rMCGfk4rxca67QQGsbg5Nm4m9UnBs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

130
desktop/icons.go Normal file
View File

@@ -0,0 +1,130 @@
package main
import (
"bytes"
"encoding/binary"
"image"
"image/color"
"image/png"
"runtime"
agentpaths "github.com/loyaly/behavision-agent/pkg/paths"
)
// iconFor renders the tray icon at run time rather than embedding four PNGs.
//
// A 16x16 filled circle is all the taskbar shows at this size, and generating
// it means the four states cannot drift apart visually or have one file go
// missing from a build.
//
// The encoding is per-platform and is NOT cosmetic. systray writes these bytes
// to a temp file and, on Windows, hands the path to LoadImageW with
// IMAGE_ICON|LR_LOADFROMFILE — which decodes .ico and nothing else. A PNG
// there returns 0, systray logs "unable to set icon", and the product ships
// with no tray icon at all: the one control surface a shop manager has.
func iconFor(state string) []byte {
img := circle(colorFor(state))
if runtime.GOOS == "windows" {
return encodeICO(img)
}
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
// colorFor maps engine state to the only thing the taskbar conveys at 16px.
func colorFor(state string) color.RGBA {
switch state {
case "ok":
return color.RGBA{R: 0x2E, G: 0x9E, B: 0x60, A: 0xFF} // green
case "warn":
return color.RGBA{R: 0xE0, G: 0xA3, B: 0x3A, A: 0xFF} // amber
case "error":
return color.RGBA{R: 0xD1, G: 0x4B, B: 0x3F, A: 0xFF} // red
default:
return color.RGBA{R: 0x86, G: 0x93, B: 0x9E, A: 0xFF} // grey
}
}
const iconSize = 16
func circle(c color.RGBA) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, iconSize, iconSize))
const r = 6.5
cx, cy := float64(iconSize)/2-0.5, float64(iconSize)/2-0.5
for y := 0; y < iconSize; y++ {
for x := 0; x < iconSize; x++ {
dx, dy := float64(x)-cx, float64(y)-cy
d := dx*dx + dy*dy
switch {
case d <= (r-1)*(r-1):
img.SetRGBA(x, y, c)
case d <= r*r:
// One-pixel feathered edge; a hard-aliased circle looks broken
// next to every other icon in the tray.
a := uint8(float64(c.A) * (r*r - d) / (r*r - (r-1)*(r-1)))
img.SetRGBA(x, y, color.RGBA{R: c.R, G: c.G, B: c.B, A: a})
}
}
}
return img
}
// encodeICO writes a single-image .ico holding an uncompressed 32-bit DIB.
//
// Vista and later also accept a PNG stored inside the .ico container, which
// would be a dozen lines instead of forty. It is not worth the risk: which
// Windows builds accept PNG-in-ICO through LoadImage rather than through the
// newer imaging APIs is genuinely murky, the failure is silent, and it would
// only ever be discovered on a customer's counter. A DIB is what every version
// of Windows has always loaded.
func encodeICO(img *image.RGBA) []byte {
w, h := img.Bounds().Dx(), img.Bounds().Dy()
// 1bpp AND mask, each row padded to a 4-byte boundary. Unused with a 32-bit
// alpha channel, but the format requires it to be present and sized.
maskRow := ((w + 31) / 32) * 4
xor := w * h * 4
dib := 40 + xor + maskRow*h
var b bytes.Buffer
// ICONDIR
binary.Write(&b, binary.LittleEndian, uint16(0)) // reserved
binary.Write(&b, binary.LittleEndian, uint16(1)) // type: icon
binary.Write(&b, binary.LittleEndian, uint16(1)) // image count
// ICONDIRENTRY. 256 is encoded as 0 in these byte fields; at 16px it is moot.
b.WriteByte(byte(w))
b.WriteByte(byte(h))
b.WriteByte(0) // palette size: none
b.WriteByte(0) // reserved
binary.Write(&b, binary.LittleEndian, uint16(1)) // colour planes
binary.Write(&b, binary.LittleEndian, uint16(32)) // bits per pixel
binary.Write(&b, binary.LittleEndian, uint32(dib)) // bytes in resource
binary.Write(&b, binary.LittleEndian, uint32(6+16)) // offset to that data
// BITMAPINFOHEADER. Height is doubled because the DIB nominally stacks the
// colour image and the AND mask; omitting the doubling renders half an icon
// stretched over the whole square.
binary.Write(&b, binary.LittleEndian, uint32(40))
binary.Write(&b, binary.LittleEndian, int32(w))
binary.Write(&b, binary.LittleEndian, int32(h*2))
binary.Write(&b, binary.LittleEndian, uint16(1))
binary.Write(&b, binary.LittleEndian, uint16(32))
binary.Write(&b, binary.LittleEndian, uint32(0)) // BI_RGB, uncompressed
binary.Write(&b, binary.LittleEndian, uint32(xor+maskRow*h))
for i := 0; i < 4; i++ { // resolution and palette counts, all unused
binary.Write(&b, binary.LittleEndian, uint32(0))
}
// Pixels: BGRA, bottom-up. Go's image is top-down and RGBA, so both the row
// order and the channel order invert here.
for y := h - 1; y >= 0; y-- {
for x := 0; x < w; x++ {
c := img.RGBAAt(x, y)
b.Write([]byte{c.B, c.G, c.R, c.A})
}
}
b.Write(make([]byte, maskRow*h)) // all-zero: every pixel opaque per the mask
return b.Bytes()
}
func logsDir() string { return agentpaths.StateRoot() }

93
desktop/icons_test.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"bytes"
"encoding/binary"
"image/png"
"runtime"
"testing"
)
// The tray icon shipped as a PNG for a while. systray hands the bytes to
// LoadImageW, which decodes .ico only, so Windows logged one line and drew
// nothing — and nothing on a Mac could notice. These tests are the substitute
// for the Windows box we do not have.
func TestEncodeICOIsAValidIconFile(t *testing.T) {
b := encodeICO(circle(colorFor("ok")))
if len(b) < 22 {
t.Fatalf("far too short: %d bytes", len(b))
}
if got := b[:6]; !bytes.Equal(got, []byte{0, 0, 1, 0, 1, 0}) {
t.Errorf("ICONDIR = % x, want 00 00 01 00 01 00", got)
}
if b[6] != iconSize || b[7] != iconSize {
t.Errorf("entry is %dx%d, want %dx%d", b[6], b[7], iconSize, iconSize)
}
if bpp := binary.LittleEndian.Uint16(b[12:14]); bpp != 32 {
t.Errorf("bits per pixel = %d, want 32", bpp)
}
// A wrong length here loads as a truncated or garbage icon rather than
// failing outright, which is the harder version of this bug to spot.
size := binary.LittleEndian.Uint32(b[14:18])
off := binary.LittleEndian.Uint32(b[18:22])
if off != 22 {
t.Errorf("image offset = %d, want 22", off)
}
if int(off)+int(size) != len(b) {
t.Errorf("entry claims %d bytes at %d, file is %d", size, off, len(b))
}
if h := binary.LittleEndian.Uint32(b[22:26]); h != 40 {
t.Errorf("BITMAPINFOHEADER size = %d, want 40", h)
}
// Height must be doubled for the implied AND mask or Windows draws the
// bottom half of the icon stretched over the whole square.
if hh := int32(binary.LittleEndian.Uint32(b[30:34])); hh != int32(iconSize*2) {
t.Errorf("biHeight = %d, want %d", hh, iconSize*2)
}
}
func TestEncodeICOPixelsAreBGRABottomUp(t *testing.T) {
want := colorFor("error") // red: distinguishable from B and G if swapped
img := circle(want)
b := encodeICO(img)
// Centre of the circle, which is solid fill. Bottom-up means image row
// iconSize/2 lands at DIB row iconSize/2-1 counting from the start.
row := iconSize - 1 - iconSize/2
i := 22 + 40 + (row*iconSize+iconSize/2)*4
got := b[i : i+4]
if !bytes.Equal(got, []byte{want.B, want.G, want.R, 0xFF}) {
t.Errorf("centre pixel = % x, want % x (BGRA)",
got, []byte{want.B, want.G, want.R, 0xFF})
}
}
func TestIconForMatchesThePlatformDecoder(t *testing.T) {
b := iconFor("ok")
pngMagic := []byte{0x89, 'P', 'N', 'G'}
if runtime.GOOS == "windows" {
if bytes.HasPrefix(b, pngMagic) {
t.Fatal("Windows tray icon is a PNG; LoadImageW will refuse it")
}
if !bytes.HasPrefix(b, []byte{0, 0, 1, 0}) {
t.Fatalf("Windows tray icon is not an ICO: % x", b[:4])
}
return
}
if _, err := png.Decode(bytes.NewReader(b)); err != nil {
t.Fatalf("non-Windows tray icon is not a decodable PNG: %v", err)
}
}
// The four states exist to be told apart at a glance; identical bytes would
// mean the icon never changes and the amber "running but blind" state — the
// one that otherwise goes unnoticed for weeks — looks exactly like healthy.
func TestEveryStateLooksDifferent(t *testing.T) {
seen := map[string]string{}
for _, s := range []string{"ok", "warn", "error", "stopped"} {
k := string(iconFor(s))
if prev, dup := seen[k]; dup {
t.Errorf("%q and %q render identically", prev, s)
}
seen[k] = s
}
}

View File

@@ -0,0 +1,504 @@
// Package cloud talks to the Behavision server at mcp.loyaly.ai.
//
// Everything a store PC sends to head office goes over MQTT; this is the
// request/response half — logging in, reading reports, saving the customer
// form. A store PC never holds database credentials, so every one of these is
// a call the server authorises against the session token.
package cloud
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ErrUnauthorized means the session is gone. The UI shows the login sheet
// again rather than an error dialog - an expired token is an ordinary event,
// not a fault.
var ErrUnauthorized = errors.New("session expired")
type Client struct {
Base string
http *http.Client
mu sync.RWMutex
token string
refresh string
user User
// Held across a whole refresh so concurrent screens cannot each spend the
// single-use refresh token.
refreshMu sync.Mutex
onRefresh func(Session)
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
ClientID string `json:"client_id"`
Client string `json:"client_name"`
}
type Session struct {
Token string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User User `json:"user"`
}
func New(base string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"),
http: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *Client) SetSession(s Session) {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = s.Token, s.RefreshToken, s.User
}
func (c *Client) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.token, c.refresh, c.user = "", "", User{}
}
func (c *Client) User() User {
c.mu.RLock()
defer c.mu.RUnlock()
return c.user
}
func (c *Client) LoggedIn() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.token != ""
}
// do sends a request, refreshing the session once if the access token has
// expired.
//
// The body is marshalled up front and kept, because a retry has to send it
// again and an io.Reader is spent after the first attempt - a bug that only
// shows up twelve hours after a shop PC was last touched, which is the worst
// possible time to find it.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var raw []byte
if body != nil {
var err error
if raw, err = json.Marshal(body); err != nil {
return err
}
}
err := c.send(ctx, method, path, raw, out)
if !errors.Is(err, errTokenExpired) {
return err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)
}
// APIError carries the server's machine-readable code alongside the prose.
//
// Some codes are not failures at all: a customer with no photo is the default
// configuration of this product, not a fault, and a caller cannot tell that
// from the message text. Error() still returns the server's own words, so
// anything that only prints the error is unaffected.
type APIError struct {
Status int
Code string
Message string
}
func (e *APIError) Error() string { return e.Message }
// codeOf reports the server's error code, or "" for anything else.
func codeOf(err error) string {
var ae *APIError
if errors.As(err, &ae) {
return ae.Code
}
return ""
}
// errTokenExpired is internal: callers see either success or ErrUnauthorized.
// An expiring access token is an ordinary event that the client handles on its
// own, not something every screen should have to know about.
var errTokenExpired = errors.New("access token expired")
func (c *Client) send(ctx context.Context, method, path string, raw []byte, out any) error {
var rdr io.Reader
if raw != nil {
rdr = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if raw != nil {
req.Header.Set("Content-Type", "application/json")
}
c.mu.RLock()
tok := c.token
c.mu.RUnlock()
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("cannot reach %s: %w", c.Base, err)
}
defer resp.Body.Close()
// The server returns {error, message, detail}; showing `message` puts the
// server's own words in front of the user instead of a status code.
var e struct {
Message string `json:"message"`
Error string `json:"error"`
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
_ = json.Unmarshal(body, &e)
}
switch {
case resp.StatusCode == http.StatusUnauthorized && e.Error == "token_expired":
return errTokenExpired
case resp.StatusCode == http.StatusUnauthorized:
return ErrUnauthorized
case resp.StatusCode >= 400:
msg := e.Message
if msg == "" {
msg = fmt.Sprintf("%s %s: %s", method, path, resp.Status)
}
return &APIError{Status: resp.StatusCode, Code: e.Error, Message: msg}
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
// Refresh swaps the refresh token for a new pair.
//
// Serialised behind refreshMu so a screen that fires four polls at once does
// not spend the refresh token four times - the server rotates it on use, so
// three of those four would race and lose, logging the shop out at random.
func (c *Client) Refresh(ctx context.Context) error {
c.refreshMu.Lock()
defer c.refreshMu.Unlock()
c.mu.RLock()
before, refresh := c.token, c.refresh
c.mu.RUnlock()
if refresh == "" {
return ErrUnauthorized
}
var s Session
if err := c.send(ctx, http.MethodPost, "/api/auth/refresh",
mustJSON(map[string]string{"refresh_token": refresh}), &s); err != nil {
return err
}
c.mu.Lock()
// Another goroutine may have refreshed while this one waited on the lock;
// its tokens are the live ones and must not be overwritten by ours.
if c.token == before {
c.token, c.refresh = s.Token, s.RefreshToken
if s.User.Email != "" {
c.user = s.User
}
}
c.mu.Unlock()
if c.onRefresh != nil {
// So the caller can persist the rotated tokens. Without this a PC that
// refreshes and then reboots comes back holding a refresh token the
// server already invalidated.
c.onRefresh(s)
}
return nil
}
// OnRefresh registers a callback fired whenever the session rotates.
func (c *Client) OnRefresh(fn func(Session)) { c.onRefresh = fn }
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err) // a map of strings cannot fail to marshal
}
return b
}
func (c *Client) Login(ctx context.Context, email, password string) (Session, error) {
var s Session
err := c.do(ctx, http.MethodPost, "/api/auth/login",
map[string]string{"email": email, "password": password}, &s)
if err != nil {
return Session{}, err
}
c.SetSession(s)
return s, nil
}
func (c *Client) Me(ctx context.Context) (User, error) {
var u User
err := c.do(ctx, http.MethodGet, "/api/auth/me", nil, &u)
if err == nil {
c.mu.Lock()
c.user = u
c.mu.Unlock()
}
return u, err
}
// Logout revokes the session server-side as well as forgetting it here.
// Clearing only the local copy leaves a live token on a machine somebody is
// about to hand back.
func (c *Client) Logout(ctx context.Context) error {
err := c.do(ctx, http.MethodPost, "/api/auth/logout", nil, nil)
c.Clear()
return err
}
// Session returns the current tokens so the caller can persist them.
func (c *Client) Session() Session {
c.mu.RLock()
defer c.mu.RUnlock()
return Session{Token: c.token, RefreshToken: c.refresh, User: c.user}
}
// Bootstrap is what a freshly installed PC asks for after the operator logs
// in: which models to fetch, and the broker credentials for this site. The
// installer ships none of this, so a leaked build hands out nothing.
type Bootstrap struct {
SiteID string `json:"site_id"`
SiteName string `json:"site_name"`
SiteSlug string `json:"site_slug"`
// ClientSlug and SiteSlug are what the agent's topic prefix is built from,
// and the server derives ClientSlug from the broker username so the two
// cannot disagree with the broker's ACL.
ClientSlug string `json:"client_slug"`
MQTTURL string `json:"mqtt_url"`
MQTTUser string `json:"mqtt_username"`
MQTTPass string `json:"mqtt_password"`
// AgentToken is this PC's own credential for the HTTPS API - asking for an
// image upload URL, pulling its camera list. Not the broker password: they
// authenticate different things, so rotating one must not break the other.
AgentToken string `json:"agent_token"`
CACert string `json:"ca_cert"`
Models []Model `json:"models"`
}
type Model struct {
Name string `json:"name"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
Bytes int64 `json:"bytes"`
}
func (c *Client) Bootstrap(ctx context.Context, siteToken string) (Bootstrap, error) {
var b Bootstrap
return b, c.do(ctx, http.MethodPost, "/api/agent/enrol",
map[string]string{"site_token": siteToken}, &b)
}
type FootfallPoint struct {
Bucket string `json:"bucket"`
Visitors int `json:"visitors"`
New int `json:"new"`
Returning int `json:"returning"`
}
type FootfallReport struct {
From string `json:"from"`
To string `json:"to"`
Bucket string `json:"bucket"`
TZ string `json:"timezone"`
Points []FootfallPoint `json:"points"`
// Total is unique people over the whole window; Visits counts every
// appearance. Summing Points gives neither - a customer who came on Monday
// and Thursday is one Total and two bucket-visitors - so both ship rather
// than letting a screen add up the chart and call it a headcount.
Total int `json:"total"`
Visits int `json:"visits"`
// Share of faces the cameras saw that fell below the enrolment gate. A
// footfall figure from a badly placed camera is wrong in a way nobody can
// see, so the number ships with its own confidence.
FractionBelowGate float64 `json:"fraction_below_gate"`
WorstSite string `json:"worst_site,omitempty"`
}
// SiteHealth distinguishes "no customers" from "this shop's PC has been
// unplugged for a week" - two identical rows of zeroes with completely
// different responses.
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`
Name string `json:"name"`
Timezone string `json:"timezone"`
Online bool `json:"online"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
LastEventAt string `json:"last_event_at"`
RecognitionModel string `json:"recognition_model"`
AgentVersion string `json:"agent_version"`
CamerasUp int `json:"cameras_up"`
CamerasTotal int `json:"cameras_total"`
FractionBelowGate float64 `json:"fraction_below_gate"`
Queued int `json:"queued"`
Dropped int64 `json:"dropped"`
}
func (c *Client) Sites(ctx context.Context) ([]SiteHealth, error) {
var out []SiteHealth
return out, c.do(ctx, http.MethodGet, "/api/sites", nil, &out)
}
// Visit is one appearance in a customer's timeline.
type Visit struct {
ID string `json:"id"`
OccurredAt string `json:"occurred_at"`
Site string `json:"site"`
CameraID string `json:"camera_id"`
IsNew bool `json:"is_new_visitor"`
Similarity float64 `json:"similarity"`
Quality float64 `json:"quality"`
Attributes map[string]any `json:"attributes"`
}
// Photo is a customer's face image, or a plain statement that there isn't one.
//
// Absence is modelled as data rather than as an error because it is the
// ordinary case: images are off by default, so most deployments answer
// "no photo" for every customer forever. Returning an error there would put a
// red failure box on screen for a system working exactly as configured, and a
// UI that cries wolf is a UI whose real errors get ignored.
type Photo struct {
URL string `json:"url"`
ExpiresIn int `json:"expires_in"`
Available bool `json:"available"`
Reason string `json:"reason"`
}
// VisitorImage fetches a short-lived signed link to this customer's photo.
//
// The link expires (the server decides how soon, and says so), so it is
// fetched when a screen opens rather than cached alongside the customer.
func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) {
var out Photo
err := c.do(ctx, http.MethodGet,
"/api/visitors/"+url.PathEscape(id)+"/image", nil, &out)
if err != nil {
switch codeOf(err) {
case "no_image":
return Photo{Reason: "No photo of this customer has been captured."}, nil
case "images_disabled":
return Photo{Reason: "This system is not storing customer photos."}, nil
}
return Photo{}, err
}
out.Available = out.URL != ""
return out, nil
}
// ForgetVisitor erases a customer: face template, photo and profile.
//
// Irreversible by design — a soft-deleted face template is a retained
// photograph by another name, because template inversion reconstructs a
// recognisable face from it. The server refuses the whole request rather than
// report a partial erasure, so an error here means nothing was deleted.
func (c *Client) ForgetVisitor(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete,
"/api/visitors/"+url.PathEscape(id), nil, nil)
}
func (c *Client) VisitorHistory(ctx context.Context, id string, limit int) ([]Visit, error) {
var out []Visit
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors/%s/history?limit=%d", url.PathEscape(id), limit),
nil, &out)
}
func (c *Client) Footfall(ctx context.Context, from, to, bucket string) (FootfallReport, error) {
var r FootfallReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/footfall?from=%s&to=%s&bucket=%s", from, to, bucket),
nil, &r)
}
type SalesReport struct {
Visitors int `json:"visitors"`
Purchasers int `json:"purchasers"`
Conversion float64 `json:"conversion"`
Revenue float64 `json:"revenue"`
AvgBasket float64 `json:"average_basket"`
Currency string `json:"currency"`
}
func (c *Client) Sales(ctx context.Context, from, to string) (SalesReport, error) {
var r SalesReport
return r, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/reports/conversion?from=%s&to=%s", from, to), nil, &r)
}
type Customer struct {
ID string `json:"id"`
Label string `json:"label"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
VisitCount int `json:"visit_count"`
FirstSeenAt string `json:"first_seen_at"`
LastSeenAt string `json:"last_seen_at"`
HasProfile bool `json:"has_profile"`
HasConsent bool `json:"has_consent"`
}
func (c *Client) Customers(ctx context.Context, query string, limit int) ([]Customer, error) {
var out []Customer
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/visitors?q=%s&limit=%d",
url.QueryEscape(query), limit), nil, &out)
}
// Profile is the in-store form. PUT rather than POST: a staff member
// resubmitting on a bad connection must not create a second record for the
// same person.
type Profile struct {
VisitorID string `json:"visitor_id"`
FullName string `json:"full_name"`
Phone string `json:"phone"`
Email string `json:"email"`
Gender string `json:"gender"`
DateOfBirth string `json:"date_of_birth"`
Notes string `json:"notes"`
Consent bool `json:"consent"`
}
func (c *Client) SaveProfile(ctx context.Context, p Profile) error {
return c.do(ctx, http.MethodPut,
"/api/visitors/"+url.PathEscape(p.VisitorID)+"/profile", p, nil)
}
func (c *Client) RecordPurchase(ctx context.Context, visitorID string,
amount float64, items []string, notes string) error {
return c.do(ctx, http.MethodPost, "/api/purchases", map[string]any{
"visitor_id": visitorID, "amount": amount,
"items": items, "source": "manual", "notes": notes,
}, nil)
}

View File

@@ -0,0 +1,139 @@
package cloud
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
func serve(t *testing.T, h http.HandlerFunc) *Client {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := New(srv.URL)
c.SetSession(Session{Token: "test-token"})
return c
}
func fail(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": code, "message": msg})
}
// A customer with no photo is the DEFAULT configuration of this product, not a
// fault. If it surfaced as an error the record sheet would show a red failure
// box for every customer in every shop that has not turned images on.
func TestNoPhotoIsNotAnError(t *testing.T) {
for _, tc := range []struct{ code, want string }{
{"no_image", "No photo"},
{"images_disabled", "not storing"},
} {
t.Run(tc.code, func(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusNotFound, tc.code, "server prose")
})
p, err := c.VisitorImage(context.Background(), "abc")
if err != nil {
t.Fatalf("returned an error for a normal state: %v", err)
}
if p.Available {
t.Error("Available should be false when there is no photo")
}
if p.Reason == "" {
t.Error("a missing photo must come with an explanation")
}
})
}
}
func TestPhotoReturnsTheSignedLink(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("Authorization = %q", got)
}
json.NewEncoder(w).Encode(map[string]any{
"url": "https://example.test/signed", "expires_in": 900})
})
p, err := c.VisitorImage(context.Background(), "abc")
if err != nil {
t.Fatal(err)
}
if !p.Available || p.URL != "https://example.test/signed" || p.ExpiresIn != 900 {
t.Fatalf("got %+v", p)
}
}
// A real failure must still be a failure: silently rendering initials would
// hide a broken server behind a design that looks intentional.
func TestPhotoServerErrorIsAnError(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusInternalServerError, "server_error", "boom")
})
if _, err := c.VisitorImage(context.Background(), "abc"); err == nil {
t.Fatal("a 500 must not be reported as 'no photo'")
}
}
// The server deletes stored images before it touches the database and refuses
// the whole request if one fails, so an error here means NOTHING was erased.
// Swallowing it would tell a shop a legal request had been honoured when it
// had not.
func TestForgetVisitorSurfacesFailure(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
t.Errorf("method = %s, want DELETE", r.Method)
}
fail(w, http.StatusBadGateway, "storage_error",
"The photo could not be deleted, so nothing was erased.")
})
err := c.ForgetVisitor(context.Background(), "abc")
if err == nil {
t.Fatal("a refused erasure must not look like success")
}
if err.Error() != "The photo could not be deleted, so nothing was erased." {
t.Errorf("lost the server's own words: %q", err)
}
}
func TestForgetVisitorSucceedsOn204(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
if err := c.ForgetVisitor(context.Background(), "abc"); err != nil {
t.Fatal(err)
}
}
// APIError carries the code without changing what anything that prints the
// error sees — every existing screen relies on that text.
func TestAPIErrorKeepsServerMessage(t *testing.T) {
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
fail(w, http.StatusForbidden, "forbidden",
"Your account cannot delete customer records.")
})
err := c.ForgetVisitor(context.Background(), "abc")
if err.Error() != "Your account cannot delete customer records." {
t.Errorf("message = %q", err)
}
var ae *APIError
if !errors.As(err, &ae) || ae.Code != "forbidden" || ae.Status != 403 {
t.Errorf("code not preserved: %+v", ae)
}
}
// An id with a slash or a space must not silently address a different route.
func TestVisitorIDIsPathEscaped(t *testing.T) {
var got string
c := serve(t, func(w http.ResponseWriter, r *http.Request) {
got = r.URL.EscapedPath()
w.WriteHeader(http.StatusNoContent)
})
c.ForgetVisitor(context.Background(), "a b/c") //nolint:errcheck
if got != "/api/visitors/a%20b%2Fc" {
t.Errorf("path = %q", got)
}
}

View File

@@ -0,0 +1,134 @@
// Package local talks to the Python recognition engine running on this PC.
//
// The desktop app is a CLIENT of the engine and never imports it. The engine
// owns the cameras, the models and the SQLite gallery; two processes touching
// one webcam or one WAL is the failure this separation exists to prevent.
package local
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type Client struct {
Base string
User string
Password string
http *http.Client
}
func New(base, user, password string) *Client {
return &Client{
Base: strings.TrimRight(base, "/"), User: user, Password: password,
// Generous: a camera Test opens an RTSP stream and can legitimately
// take ten seconds against a slow NVR.
http: &http.Client{Timeout: 45 * time.Second},
}
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.User != "" {
req.SetBasicAuth(c.User, c.Password)
}
resp, err := c.http.Do(req)
if err != nil {
// The single most common state on a fresh install: the engine has not
// been started yet. Say that, rather than surfacing a dial error the
// user cannot act on.
return fmt.Errorf("engine not reachable at %s (is it running?): %w", c.Base, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("engine %s %s: %s: %s", method, path, resp.Status,
strings.TrimSpace(string(msg)))
}
if out == nil {
return nil
}
return json.NewDecoder(io.LimitReader(resp.Body, 8<<20)).Decode(out)
}
func (c *Client) Health(ctx context.Context) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/health", nil, &out)
}
func (c *Client) Stats(ctx context.Context) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/stats", nil, &out)
}
func (c *Client) Events(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/events?limit=%d", limit), nil, &out)
}
func (c *Client) Cameras(ctx context.Context) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
}
func (c *Client) AddCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras", cam, &out)
}
func (c *Client) UpdateCamera(ctx context.Context, id string, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPatch, "/api/cameras/"+id, cam, &out)
}
func (c *Client) DeleteCamera(ctx context.Context, id string) error {
return c.do(ctx, http.MethodDelete, "/api/cameras/"+id, nil, nil)
}
func (c *Client) TestCamera(ctx context.Context, cam map[string]any) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
}
func (c *Client) StartPlacementCheck(ctx context.Context, id string, seconds float64) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodPost, "/api/cameras/"+id+"/commission",
map[string]any{"seconds": seconds}, &out)
}
func (c *Client) PlacementResult(ctx context.Context, id string) (map[string]any, error) {
var out map[string]any
return out, c.do(ctx, http.MethodGet, "/api/cameras/"+id+"/commission", nil, &out)
}
func (c *Client) Identities(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/identities?limit=%d", limit), nil, &out)
}
func (c *Client) Sightings(ctx context.Context, limit int) ([]map[string]any, error) {
var out []map[string]any
return out, c.do(ctx, http.MethodGet,
fmt.Sprintf("/api/sightings?limit=%d", limit), nil, &out)
}

66
desktop/main.go Normal file
View File

@@ -0,0 +1,66 @@
// Command behavision-desktop is the store-facing application: a tray icon, a
// window, and the supervisor for the recognition engine.
//
// It is one process rather than three because the tray, the window and the
// supervisor all need the same state, and because a user who quits the tray
// expects recognition to stop. It is deliberately NOT a Windows service: a
// service runs in session 0 and cannot draw a tray icon, and spawning a child
// process needs no elevation while controlling a service does.
package main
import (
"context"
"embed"
"log"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/windows"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
//go:embed all:frontend/dist
var assets embed.FS
func main() {
app := NewApp()
tray := newTray(app)
err := wails.Run(&options.App{
Title: "Behavision",
Width: 1280,
Height: 820,
// Small enough to still be usable on a cramped shop-counter monitor.
MinWidth: 1024,
MinHeight: 640,
AssetServer: &assetserver.Options{Assets: assets},
// Closing the window hides it rather than quitting: the engine must
// keep recognising after a shop assistant clicks the X, and the tray
// is where they get the window back.
HideWindowOnClose: true,
OnStartup: func(ctx context.Context) {
app.startup(ctx)
tray.start(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
runtime.Hide(ctx)
return true // prevent the close
},
OnShutdown: func(ctx context.Context) {
tray.stop()
app.StopEngine()
},
Bind: []any{app},
Windows: &windows.Options{
WebviewIsTransparent: false,
WindowIsTranslucent: false,
// A shop PC is not a developer machine; a stray right-click that
// opens devtools looks like the software is broken.
DisableWindowIcon: false,
},
})
if err != nil {
log.Fatalf("behavision-desktop: %v", err)
}
}

174
desktop/tray.go Normal file
View File

@@ -0,0 +1,174 @@
package main
import (
"context"
"fmt"
"sync"
"time"
"fyne.io/systray"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// tray is the always-present control surface. Wails v2 has no systray of its
// own, so this drives fyne.io/systray alongside the window.
//
// It is a CLIENT of the app, not a second copy of it: everything it shows
// comes from EngineStatus(), so the tray and the dashboard can never disagree
// about whether recognition is running.
type tray struct {
app *App
once sync.Once
quit chan struct{}
mStatus *systray.MenuItem
mOpen *systray.MenuItem
mStart *systray.MenuItem
mStop *systray.MenuItem
mLogs *systray.MenuItem
mQuit *systray.MenuItem
}
func newTray(a *App) *tray { return &tray{app: a, quit: make(chan struct{})} }
func (t *tray) start(ctx context.Context) {
t.once.Do(func() {
go systray.Run(func() { t.onReady(ctx) }, func() {})
})
}
func (t *tray) stop() {
select {
case <-t.quit:
default:
close(t.quit)
}
systray.Quit()
}
func (t *tray) onReady(ctx context.Context) {
systray.SetTitle("Behavision")
systray.SetTooltip("Behavision — starting")
systray.SetIcon(iconFor("stopped"))
t.mStatus = systray.AddMenuItem("Starting…", "")
t.mStatus.Disable()
systray.AddSeparator()
t.mOpen = systray.AddMenuItem("Open dashboard", "Show the Behavision window")
systray.AddSeparator()
t.mStart = systray.AddMenuItem("Start recognition", "Start the engine")
t.mStop = systray.AddMenuItem("Stop recognition", "Stop the engine")
t.mLogs = systray.AddMenuItem("Open logs folder", "")
systray.AddSeparator()
t.mQuit = systray.AddMenuItem("Quit Behavision", "Stops recognition")
go t.poll(ctx)
for {
select {
case <-t.quit:
return
case <-t.mOpen.ClickedCh:
runtime.Show(ctx)
case <-t.mStart.ClickedCh:
t.app.StartEngine()
case <-t.mStop.ClickedCh:
t.app.StopEngine()
case <-t.mLogs.ClickedCh:
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
case <-t.mQuit.ClickedCh:
// Quitting the tray stops recognition. Leaving the engine running
// with no visible control is worse than stopping it: nobody would
// know it was still watching.
t.app.StopEngine()
runtime.Quit(ctx)
return
}
}
}
// poll keeps the icon honest. The colour answers the only question a shop
// manager glancing at the taskbar has: is it working right now.
func (t *tray) poll(ctx context.Context) {
tick := time.NewTicker(5 * time.Second)
defer tick.Stop()
for {
select {
case <-t.quit:
return
case <-ctx.Done():
return
case <-tick.C:
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
}
}
}
// describe collapses engine state into the three things worth showing.
//
// "Running but no camera connected" is deliberately amber, not green: the
// process is fine and the product is not working, and that is exactly the
// state that otherwise goes unnoticed for weeks.
func describe(s EngineStatus) (state, label string) {
switch {
case s.State == "stopped":
return "stopped", "Stopped"
case s.State == "failed":
return "error", "Failed — " + firstLine(s.Error)
case s.State == "backoff":
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
case !s.Reachable:
return "warn", "Starting…"
case len(s.Cameras) == 0:
return "warn", "Running — no cameras configured"
default:
up := 0
for _, ok := range s.Cameras {
if ok {
up++
}
}
if up == 0 {
return "error", fmt.Sprintf("No camera connected (0 of %d)", len(s.Cameras))
}
if up < len(s.Cameras) {
return "warn", fmt.Sprintf("%d of %d cameras live", up, len(s.Cameras))
}
return "ok", fmt.Sprintf("Watching %d camera%s", up, plural(up))
}
}
func plural(n int) string {
if n == 1 {
return ""
}
return "s"
}
func firstLine(s string) string {
for i, r := range s {
if r == '\n' {
return s[:i]
}
}
if len(s) > 60 {
return s[:60] + "…"
}
return s
}

17
desktop/wails.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "Behavision",
"outputfilename": "Behavision",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": { "name": "Loyaly" },
"info": {
"companyName": "Loyaly",
"productName": "Behavision",
"productVersion": "0.1.0",
"copyright": "© Loyaly",
"comments": "Footfall and customer recognition for retail"
}
}