Files
Behavision/desktop/tray.go
Suriyakumarvijayanayagam 81e2c605b9 The first five minutes, as the product and not as a developer's first run
The first launch was a code box with a link under it, then an empty
Live screen with 'No cameras' in amber in a far corner, then a form
asking for an IP address, and for the first few minutes of all of it
the engine silently downloading 275 MB with nothing on screen but a
stopped-looking status. Walked in a browser with the new mock; nobody
who was not an installer would have got through it.

Now: a welcome that asks the one question a shop owner can answer -
managed from a head office, or on this PC only - with each path in a
sentence; a Getting Started checklist on Live that reads its three steps
from the engine and ticks them itself (recognition ready, camera added
and connected, camera proven by a walk-past), with the one button for
the next step, and that disappears the moment somebody is recognised;
and the model download reported as a percentage in the tray, the
sidebar and the checklist, parsed by the supervisor from the engine's
own progress lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-21 12:32:19 +05:30

217 lines
6.2 KiB
Go

package main
import (
"context"
"fmt"
"os"
"sync"
"time"
"fyne.io/systray"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// BEHAVISION_NO_TRAY runs the window with no tray icon.
//
// It exists so the UI can be looked at on a Mac. fyne.io/systray's nativeLoop
// must own the main thread on macOS - a Cocoa requirement, not a library
// choice - and Wails already holds it, so starting both kills the process with
// a SIGTRAP inside cgo before a single screen is drawn. On Windows, which is
// what ships, a tray on its own goroutine is fine. That asymmetry is why this
// went unnoticed for so long: the shop-floor UI had never once been run on the
// platform it is developed on, so every screen in it was unreviewed.
//
// Deliberately an environment variable and NOT a GOOS check. A build that
// quietly drops the tray on some platform is how a shop PC ends up with no
// control surface at all - the one thing a shop manager has - and it would
// fail exactly where nobody is watching. Nothing is skipped unless a person
// asked for it, by name, on this run.
const noTrayEnv = "BEHAVISION_NO_TRAY"
func trayDisabled() bool { return os.Getenv(noTrayEnv) != "" }
// 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) {
if trayDisabled() {
return
}
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() on a systray that was never started is not a no-op in
// v1.12.2, so the guard has to be on both ends or quitting the window
// takes the process down with it - a crash on exit, which is the failure
// most likely to be shrugged off as "it closed, fine".
if trayDisabled() {
return
}
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:
// Never on the menu loop itself: a stop waits for the process to
// exit, and a menu that is deaf for the duration looks broken.
go func() { t.app.StartEngine(); t.refresh() }()
case <-t.mStop.ClickedCh:
go func() { t.app.StopEngine(); t.refresh() }()
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:
t.refresh()
}
}
}
// refresh redraws the icon and the menu from EngineStatus - the same source
// the window reads, so the two cannot disagree.
func (t *tray) refresh() {
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" || s.State == "starting" || s.State == "backoff"
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":
// The supervisor's error is already a sentence (port in use, missing
// library); show it whole, because it is the thing to act on.
return "error", "Not running — " + firstLine(s.Error)
case s.State == "backoff":
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
case !s.Reachable && s.Progress != nil:
return "warn", fmt.Sprintf("Downloading %s… %d%%", s.Progress.What, s.Progress.Percent)
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
}