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