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
This commit is contained in:
2026-09-30 11:44:49 +05:30
parent 02b2c5bc39
commit e33761d6d0
4 changed files with 51 additions and 4 deletions

View File

@@ -12,6 +12,7 @@ import (
"context"
"embed"
"log"
runtime2 "runtime"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -66,13 +67,23 @@ func main() {
// Closing the window hides it rather than quitting: the engine must
// keep recognising after a shop assistant clicks the X, and the tray
// is where they get the window back.
HideWindowOnClose: true,
// Windows hides on close because the tray is how the window comes
// back and how recognition is stopped. macOS has no tray here (see
// tray_run_darwin.go), so hiding would leave a running engine with no
// window, no tray and no way to reach either - force-quit or nothing.
// Closing the window therefore quits, which also stops the engine
// through OnShutdown. Same rule as the tray's Quit: never leave it
// watching with no visible control.
HideWindowOnClose: runtime2.GOOS == "windows",
OnStartup: func(ctx context.Context) {
ctxRef = ctx
app.startup(ctx)
tray.start(ctx)
},
OnBeforeClose: func(ctx context.Context) bool {
if runtime2.GOOS != "windows" {
return false // let it close, and OnShutdown stops the engine
}
// WindowHide, not Hide. They are different calls on Windows -
// WindowHide locks the OS thread for the Win32 work and Hide does
// not - and the tray's reopen uses WindowShow, so hiding through

View File

@@ -55,9 +55,7 @@ func (t *tray) start(ctx context.Context) {
if trayDisabled() {
return
}
t.once.Do(func() {
go systray.Run(func() { t.onReady(ctx) }, func() {})
})
t.once.Do(func() { startSystray(func() { t.onReady(ctx) }) })
}
func (t *tray) stop() {

View File

@@ -0,0 +1,28 @@
//go:build darwin
package main
// There is no tray on macOS, and that is a decision rather than an omission.
//
// macOS has exactly ONE main run loop and AppKit insists that windows and
// status items are created on it. Wails already owns that loop. Two attempts,
// both crashing within a second of launch:
//
// systray.Run -> SIGTRAP inside cgo: nativeLoop() takes the
// main loop for itself, and Wails has it
// systray.RunWithExternalLoop -> "NSWindow should only be instantiated on
// the main thread!" - it registers in the
// existing NSApplication 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' own lifecycle, which is real work for a build whose entire
// purpose is demoing on a developer's Mac. Windows is the platform this ships
// to and its tray is the shop manager's only control surface; here the window
// is right there in the Dock.
//
// The consequence is handled rather than left: with no tray there would be no
// way back from a hidden window and no way to quit, so on macOS closing the
// window stops the engine and exits. See main.go.
func startSystray(onReady func()) {}

View File

@@ -0,0 +1,10 @@
//go:build windows
package main
import "fyne.io/systray"
// systray.Run owns a message loop, and on Windows it is free to have its own:
// the tray lives in its own thread with its own pump, beside the one Wails
// runs for the window. A goroutine is all it needs.
func startSystray(onReady func()) { go systray.Run(onReady, func() {}) }