`wails build` had never been executed against this project - CLAUDE.md says so plainly - so every screen the shop floor actually touches was unreviewed. Running it found why nobody had. fyne.io/systray's nativeLoop must own the main thread on macOS, a Cocoa requirement, and Wails already holds it. Starting both kills the process with a SIGTRAP inside cgo before a single pixel is drawn. On Windows, which is what ships, a tray on its own goroutine is fine - so the one platform the whole team develops on was the one platform that could not open the app, and the UI went unlooked-at as a result. BEHAVISION_NO_TRAY runs the window without the tray, the same escape hatch BEHAVISION_ALLOW_PLAINTEXT_MQTT already is for the broker. Deliberately an environment variable and NOT a GOOS check: a build that quietly drops the tray is how a shop PC ends up with no control surface at all, and it would fail where nobody is watching. The guard is on stop() as well, because systray.Quit() on a systray that never started is not a no-op in v1.12.2 - it would turn closing the window into a crash on exit, the failure most likely to be shrugged off as "it closed, fine". go.mod gains the indirect dependencies the darwin build pulls in. No version moved: the committed list was written by a windows-only build, which never resolves that part of the Wails tree. Verified: GOOS=windows build, go vet, and the agent suite all still pass, and the packaged .app runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qiy5iKfz4L8S4vRaYPBdaU
205 lines
5.5 KiB
Go
205 lines
5.5 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:
|
|
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
|
|
}
|