Files
Behavision/desktop/app.go
Suriyakumarvijayanayagam dad04e8cda 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
2026-09-04 11:14:18 +05:30

688 lines
23 KiB
Go

// 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
}