Files
Behavision/desktop/tray.go
Suriyakumarvijayanayagam e33761d6d0 A Mac build, and the two ways it crashed first
Chosen deliberately as a DEVELOPER build, not a product. Indian retail
counters are Windows; shipping a Mac product means an Apple Developer
account, notarisation, a second installer format, a second frozen engine
and DPAPI having no macOS equivalent - a permanent second platform for
customers who do not have Macs. What a Mac build is worth is demoing the
desktop app on the machine it is written on, without needing the Windows
box.

It built after one missing framework (previous commit) and then crashed
within a second, twice, both times in the tray:

  systray.Run              SIGTRAP inside cgo. nativeLoop() takes the
                           macOS main run loop for itself and Wails
                           already has it. macOS has exactly one.
  RunWithExternalLoop      "NSWindow should only be instantiated on the
                           main thread!" - it registers in the existing
                           NSApplication rather than starting a second,
                           but still builds AppKit objects, and Wails'
                           OnStartup is not the main thread.

Making it work needs the status item created through a main-queue
dispatch inside Wails' lifecycle. That is real work for a build whose
purpose is a demo, so macOS has no tray and the file says so at length
rather than leaving the next person to rediscover both crashes.

The consequence is handled rather than left lying. With no tray there is
no way back from a hidden window and no way to quit, so hiding on close
would strand a running engine behind no window, no tray and no control -
force-quit or nothing. On macOS closing the window therefore quits, and
OnShutdown stops the engine. Same rule the tray's Quit already follows:
never leave it watching with no visible control. Windows is untouched,
where hiding is correct because the tray is how it comes back.

The runner is split by build tag rather than branched at runtime because
the two platforms need different systray ENTRY POINTS, not different
arguments.

Verified: 18 seconds up, zero crash markers, 88 MB resident, and an
honest "engine not installed yet" instead of a crash - against a
throwaway data dir so it claimed nothing and touched no camera. The
frozen Mac engine is deliberately not built; the app takes an engine
command from config, which is how the dev setup already points at the
venv.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 11:44:49 +05:30

250 lines
7.8 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() { startSystray(func() { t.onReady(ctx) }) })
}
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:
// In a goroutine, like Start and Stop: this sleeps, and a menu
// loop that sleeps is a tray that ignores the next click.
go openWindow(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
}
// openWindow brings the dashboard back, and it takes four calls rather than
// the one that was here.
//
// `runtime.Show` was wrong three times over, and the first is the one that
// made it fail rather than merely misbehave:
//
// 1. WRONG THREAD. Wails implements Show() as a bare `mainWindow.Show()`,
// while WindowShow() wraps the same work in runtime.LockOSThread. Win32
// window operations have to run on the thread owning the window's message
// pump; this is called from the SYSTRAY's goroutine, which is never that
// thread. An unlocked call from an arbitrary goroutine is why clicking
// "Open dashboard" did nothing reliable.
//
// 2. Showing is not un-minimising. A hidden window and a minimised one are
// different states and Show only fixes the first, so a window the user
// minimised stayed minimised.
//
// 3. Windows will not let a process that is not already in the foreground
// take it - the shell refuses, and the window comes back BEHIND whatever
// is being looked at. Clicking a tray icon is by definition a moment when
// this application is not in the foreground, so that is not an edge case
// here, it is every time.
//
// The always-on-top flip is the ordinary way to ask for the foreground anyway.
// It is brief and it is why this cannot run on the menu loop.
func openWindow(ctx context.Context) {
runtime.WindowUnminimise(ctx)
runtime.WindowShow(ctx)
runtime.WindowSetAlwaysOnTop(ctx, true)
time.Sleep(200 * time.Millisecond)
runtime.WindowSetAlwaysOnTop(ctx, false)
}