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
252 lines
7.8 KiB
Go
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)
|
|
}
|