Files
Behavision/desktop/tray.go
Suriyakumarvijayanayagam 02b2c5bc39 "Open dashboard" in the tray did nothing reliable, and there is a Mac build
Reported from the shipped Windows app. Three faults in one call, and the
first is why it failed rather than merely misbehaved.

runtime.Show is implemented by Wails as a bare mainWindow.Show(), while
runtime.WindowShow wraps the identical work in runtime.LockOSThread. Win32
window operations have to run on the thread owning the window's message
pump, and the tray's handler runs on the SYSTRAY's goroutine, which is
never that thread. An unlocked Win32 call from an arbitrary goroutine is
the bug.

Two more that would each have been enough on their own:

- Showing is not un-minimising. Hidden and minimised are different states
  and Show only fixes the first, so a window the user minimised stayed
  minimised.
- Windows refuses the foreground to a process that does not already hold
  it, so the window came back BEHIND whatever was being looked at.
  Clicking a tray icon is by definition a moment when this app is not in
  front, so that is not an edge case here - it is every time. The
  always-on-top flip is the ordinary way to ask, and it is why this now
  runs in a goroutine rather than on the menu loop, which must not sleep.

The same four calls fix OnSecondInstanceLaunch, which had the same shape
and is reached far more often: double-clicking the desktop icon while the
app is already running.

OnBeforeClose used runtime.Hide against a reopen that used WindowShow -
different calls on Windows, one thread-locked and one not. Paired now.

And a Mac build, because the question came up and the answer turned out
to be yes. Wails' darwin frontend references UTType without linking
UniformTypeIdentifiers, so the build failed at the LINK step after
compiling everything - which reads like a broken toolchain rather than
one missing flag. There was no Mac version because of that, not because
of a design limit. darwin_link.go declares the framework in source rather
than leaving it as a CGO_LDFLAGS incantation, for the same reason
deploy.sh now finds Go itself. Verified: plain `go build` produces a
16 MB arm64 binary on this Mac, and the Windows build is unchanged.

Worth knowing for whoever edits that file: the comment directly above
`import "C"` is cgo's C preamble, not documentation. The first attempt put
the explanation there and the prose was compiled as C.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-29 17:42:39 +05:30

252 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() {
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:
// 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)
}