From 9201baecfcc07d4cb6ce04e4eba57e5ae750172b Mon Sep 17 00:00:00 2001 From: Atomic Deploy Date: Thu, 1 Oct 2026 21:45:46 +0330 Subject: [PATCH 1/2] fix(host): restore tray for default launch --- Tools/Controller/README.md | 5 ++- Tools/Controller/cmd/controller/main.go | 40 ++++++++++++++----- .../cmd/controller/native_web_shell.go | 21 ++++++++++ .../cmd/controller/native_web_shell_test.go | 36 +++++++++++++++++ docs/Getting-Started-and-Operations.md | 10 +++-- docs/Host-Configuration-and-Integrations.md | 8 ++-- 6 files changed, 100 insertions(+), 20 deletions(-) diff --git a/Tools/Controller/README.md b/Tools/Controller/README.md index ca200754..f56d57cb 100644 --- a/Tools/Controller/README.md +++ b/Tools/Controller/README.md @@ -204,8 +204,9 @@ pushes status, board events, host events, and global page actions. Those event lines are interleaved in the bounded terminal transcript and remain available in the filterable Activity page. -On Windows, a primary-owning `web` process also provides a native tray menu by -default; `--no-tray` disables it. Its tooltip and status row use the +On Windows, the local interactive primary (default/TUI or explicit `web`) also +provides one native tray menu by default; an explicit `--no-tray` disables it. +Attached secondary TUIs do not create duplicates. Its tooltip and status row use the authenticated controller state, not merely an open HTTP/WebSocket listener. Dashboard, Controls, Workbench, Updates, and Settings actions exist only while the board is connected. Connect/Reconnect and Exit remain available while diff --git a/Tools/Controller/cmd/controller/main.go b/Tools/Controller/cmd/controller/main.go index cc57eb02..275eeb0d 100644 --- a/Tools/Controller/cmd/controller/main.go +++ b/Tools/Controller/cmd/controller/main.go @@ -748,17 +748,15 @@ func runWebWithInitialAction( if initial.Kind != "" { applyInitialWebAction(ctx, primary, runtime, engine, initial) } - if !*noTray { - nativeShell, shellErr := startNativeWebShell(ctx, cancel, appURL, runtime, store, primary) - if shellErr != nil { - fmt.Fprintln(stderr, "native web shell:", shellErr) - } else { - defer func() { - if closeErr := nativeShell.Close(); closeErr != nil { - fmt.Fprintln(stderr, "close native web shell:", closeErr) - } - }() - } + nativeShell, shellErr := startPrimaryNativeShell(ctx, cancel, appURL, runtime, store, primary, *noTray) + if shellErr != nil { + fmt.Fprintln(stderr, "native web shell:", shellErr) + } else if nativeShell != nil { + defer func() { + if closeErr := nativeShell.Close(); closeErr != nil { + fmt.Fprintln(stderr, "close native web shell:", closeErr) + } + }() } primaryReadyOnce.Do(func() { close(primaryReady) }) go watchConfiguration(ctx, store, runtime, connection) @@ -1007,6 +1005,7 @@ func runTUIWithInitialAction( return err } noAuto := flags.Bool("no-auto", false, "start with automatic connection paused") + noTray := flags.Bool("no-tray", false, "run the local primary without a native system-tray menu") ipcAddress := flags.String("ipc-addr", "", "attach the full TUI to an existing controller IPC host:port") ipcToken := flags.String("ipc-token", "", "bearer token for --ipc-addr (prefer --ipc-token-ref)") ipcTokenReference := flags.String("ipc-token-ref", "", "resolve the remote IPC bearer token from an OS-vault or environment reference") @@ -1173,6 +1172,24 @@ func runTUIWithInitialAction( } claim = nil defer primary.Close() + appURL, err := browserURL(currentPrimaryEndpoint().Listen) + if err != nil { + _ = primary.Close() + _ = runtime.Close() + return err + } + nativeShell, shellErr := startPrimaryNativeShell( + watchContext, stopWatching, appURL, runtime, store, primary, *noTray, + ) + if shellErr != nil { + fmt.Fprintln(stderr, "native TUI shell:", shellErr) + } else if nativeShell != nil { + defer func() { + if closeErr := nativeShell.Close(); closeErr != nil { + fmt.Fprintln(stderr, "close native TUI shell:", closeErr) + } + }() + } appActions := primary.AppActions() navigationReporter, err := hostui.NewNavigationReporter(*syncNavigation, "") if err != nil { @@ -1369,6 +1386,7 @@ func runTUIWithInitialAction( case <-primary.QuitRequested(): program.Quit() case <-watchContext.Done(): + program.Quit() } }() _, err = program.Run() diff --git a/Tools/Controller/cmd/controller/native_web_shell.go b/Tools/Controller/cmd/controller/native_web_shell.go index 5bbfaab4..3832bef8 100644 --- a/Tools/Controller/cmd/controller/native_web_shell.go +++ b/Tools/Controller/cmd/controller/native_web_shell.go @@ -14,6 +14,27 @@ import ( "pccontroller.local/controller/internal/productidentity" ) +var startNativeShell = startNativeWebShell + +// startPrimaryNativeShell is the single lifecycle gate for every interactive +// local primary. Keeping default/TUI and explicit web mode on this path avoids +// silently losing the tray when command dispatch changes while still allowing +// an explicit headless opt-out. +func startPrimaryNativeShell( + ctx context.Context, + stop context.CancelFunc, + appURL string, + runtime *control.Runtime, + store *appconfig.Store, + primary *primaryIPC, + disabled bool, +) (nativeshell.Shell, error) { + if disabled { + return nil, nil + } + return startNativeShell(ctx, stop, appURL, runtime, store, primary) +} + func startNativeWebShell( ctx context.Context, stop context.CancelFunc, diff --git a/Tools/Controller/cmd/controller/native_web_shell_test.go b/Tools/Controller/cmd/controller/native_web_shell_test.go index 5f2184a6..18d2010f 100644 --- a/Tools/Controller/cmd/controller/native_web_shell_test.go +++ b/Tools/Controller/cmd/controller/native_web_shell_test.go @@ -1,11 +1,47 @@ package main import ( + "context" "testing" + "pccontroller.local/controller/internal/appconfig" + "pccontroller.local/controller/internal/control" "pccontroller.local/controller/internal/nativeshell" ) +type testNativeShell struct{ closed bool } + +func (shell *testNativeShell) Close() error { + shell.closed = true + return nil +} + +func TestPrimaryNativeShellOwnsDefaultTrayAndHonorsExplicitOptOut(t *testing.T) { + original := startNativeShell + t.Cleanup(func() { startNativeShell = original }) + + called := 0 + want := &testNativeShell{} + startNativeShell = func( + context.Context, context.CancelFunc, string, + *control.Runtime, *appconfig.Store, *primaryIPC, + ) (nativeshell.Shell, error) { + called++ + return want, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + got, err := startPrimaryNativeShell(ctx, cancel, "http://127.0.0.1:8787/", nil, nil, nil, false) + if err != nil || got != want || called != 1 { + t.Fatalf("enabled primary shell=(%v,%v), calls=%d", got, err, called) + } + got, err = startPrimaryNativeShell(ctx, cancel, "", nil, nil, nil, true) + if err != nil || got != nil || called != 1 { + t.Fatalf("disabled primary shell=(%v,%v), calls=%d", got, err, called) + } +} + func TestNativeWebPageURL(t *testing.T) { got, err := nativeWebPageURL("http://127.0.0.1:8787/", " Settings ") if err != nil { diff --git a/docs/Getting-Started-and-Operations.md b/docs/Getting-Started-and-Operations.md index 727ec51e..9439f99e 100644 --- a/docs/Getting-Started-and-Operations.md +++ b/docs/Getting-Started-and-Operations.md @@ -99,8 +99,10 @@ Use `--no-open` when another application will navigate to the WebUI: Tools\Controller\bin\controller.exe web --no-open --port COM18 ``` -On Windows, a primary-owning web process also provides a native tray menu by -default. Add `--no-tray` when that shell is not wanted. Its connection label is +On Windows, every local interactive primary (the default/TUI launch or explicit +`web` mode) provides one native tray menu by default. A secondary TUI attaches +to the existing owner and never creates a duplicate. Add `--no-tray` to an +explicit `tui` or `web` command when that shell is not wanted. Its connection label is derived from authenticated serial state, page links appear only while a board is connected, and both automatic launch and tray actions refuse to open a page while offline. Connect/Reconnect and Exit remain available in either state. @@ -112,8 +114,8 @@ its `appLogoOverride`; Windows is never asked to silently render a text-only product toast. `desktop test` sends a branded, actionable end-to-end diagnostic. `desktop uninstall` removes only entries whose ownership still matches that executable and AppUserModelID. Both launch links target the -packaged `.exe` with `web` arguments and use icon index zero from that same -executable, never a separate `.ico` file. The Desktop location comes from the +packaged `.exe` without feature-suppressing arguments and use icon index zero +from that same executable, never a separate `.ico` file. The Desktop location comes from the Windows per-user known folder, including a redirected Desktop. `shortcut_ready`/`shortcut` describe the Start Menu entry; diff --git a/docs/Host-Configuration-and-Integrations.md b/docs/Host-Configuration-and-Integrations.md index 3a4ae5c0..e67b8fa3 100644 --- a/docs/Host-Configuration-and-Integrations.md +++ b/docs/Host-Configuration-and-Integrations.md @@ -328,14 +328,16 @@ automations, host menus, global hotkeys, notifications, integration managers, and the same guarded command dispatcher. `--no-open` keeps that process and HTTP service running without opening a browser. -On Windows, that primary-owning process starts a native tray menu unless -`--no-tray` is supplied. It shows authoritative connected, reconnecting, +On Windows, every local interactive primary starts one native tray menu unless +an explicit `tui` or `web` command supplies `--no-tray`. The default flag-free +launch is the primary TUI plus this tray; attached secondary TUIs do not create +duplicates. It shows authoritative connected, reconnecting, paused, or offline state. Dashboard, Controls, Workbench, Updates, and Settings links are present only for an authenticated controller; Connect/Reconnect and Exit remain available otherwise. State is checked again at dispatch, so a disconnect while the menu is open cannot launch a stale page. -This is currently an in-process web-primary tray, not a Windows service. The +This is currently an in-process interactive-primary tray, not a Windows service. The tracked service split keeps a privileged, headless, session-independent serial owner separate from an unelevated per-user tray client. That client will attach through local IPC, launch or foreground Win32, TUI, or WebUI From 3bffa17bf943068e25e48370224308476b6d15c0 Mon Sep 17 00:00:00 2001 From: Atomic Deploy Date: Thu, 1 Oct 2026 22:08:52 +0330 Subject: [PATCH 2/2] fix(host): preserve integration cleanup on tray exit --- Tools/Controller/cmd/controller/main.go | 3 +- .../cmd/controller/native_web_shell.go | 4 +- .../cmd/controller/native_web_shell_test.go | 87 +++++++++++++++++-- Tools/Controller/cmd/controller/primary.go | 7 ++ 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/Tools/Controller/cmd/controller/main.go b/Tools/Controller/cmd/controller/main.go index 275eeb0d..7bf14d00 100644 --- a/Tools/Controller/cmd/controller/main.go +++ b/Tools/Controller/cmd/controller/main.go @@ -1179,7 +1179,7 @@ func runTUIWithInitialAction( return err } nativeShell, shellErr := startPrimaryNativeShell( - watchContext, stopWatching, appURL, runtime, store, primary, *noTray, + watchContext, primary.RequestQuit, appURL, runtime, store, primary, *noTray, ) if shellErr != nil { fmt.Fprintln(stderr, "native TUI shell:", shellErr) @@ -1386,7 +1386,6 @@ func runTUIWithInitialAction( case <-primary.QuitRequested(): program.Quit() case <-watchContext.Done(): - program.Quit() } }() _, err = program.Run() diff --git a/Tools/Controller/cmd/controller/native_web_shell.go b/Tools/Controller/cmd/controller/native_web_shell.go index 3832bef8..d7fd2e46 100644 --- a/Tools/Controller/cmd/controller/native_web_shell.go +++ b/Tools/Controller/cmd/controller/native_web_shell.go @@ -22,7 +22,7 @@ var startNativeShell = startNativeWebShell // an explicit headless opt-out. func startPrimaryNativeShell( ctx context.Context, - stop context.CancelFunc, + stop func(), appURL string, runtime *control.Runtime, store *appconfig.Store, @@ -37,7 +37,7 @@ func startPrimaryNativeShell( func startNativeWebShell( ctx context.Context, - stop context.CancelFunc, + stop func(), appURL string, runtime *control.Runtime, store *appconfig.Store, diff --git a/Tools/Controller/cmd/controller/native_web_shell_test.go b/Tools/Controller/cmd/controller/native_web_shell_test.go index 18d2010f..61fe057a 100644 --- a/Tools/Controller/cmd/controller/native_web_shell_test.go +++ b/Tools/Controller/cmd/controller/native_web_shell_test.go @@ -2,6 +2,11 @@ package main import ( "context" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "runtime" "testing" "pccontroller.local/controller/internal/appconfig" @@ -9,12 +14,9 @@ import ( "pccontroller.local/controller/internal/nativeshell" ) -type testNativeShell struct{ closed bool } +type testNativeShell struct{} -func (shell *testNativeShell) Close() error { - shell.closed = true - return nil -} +func (*testNativeShell) Close() error { return nil } func TestPrimaryNativeShellOwnsDefaultTrayAndHonorsExplicitOptOut(t *testing.T) { original := startNativeShell @@ -23,7 +25,7 @@ func TestPrimaryNativeShellOwnsDefaultTrayAndHonorsExplicitOptOut(t *testing.T) called := 0 want := &testNativeShell{} startNativeShell = func( - context.Context, context.CancelFunc, string, + context.Context, func(), string, *control.Runtime, *appconfig.Store, *primaryIPC, ) (nativeshell.Shell, error) { called++ @@ -42,6 +44,79 @@ func TestPrimaryNativeShellOwnsDefaultTrayAndHonorsExplicitOptOut(t *testing.T) } } +func TestTUIPrimaryCompositionOwnsTrayWithoutCancelingIntegrationContext(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve native shell contract test path") + } + parsed, err := parser.ParseFile( + token.NewFileSet(), filepath.Join(filepath.Dir(testFile), "main.go"), nil, 0, + ) + if err != nil { + t.Fatal(err) + } + var function *ast.FuncDecl + for _, declaration := range parsed.Decls { + candidate, candidateOK := declaration.(*ast.FuncDecl) + if candidateOK && candidate.Name.Name == "runTUIWithInitialAction" { + function = candidate + break + } + } + if function == nil { + t.Fatal("runTUIWithInitialAction composition root was not found") + } + + primaryStart := token.NoPos + shellStarts := 0 + ast.Inspect(function.Body, func(node ast.Node) bool { + call, callOK := node.(*ast.CallExpr) + if !callOK { + return true + } + name, nameOK := call.Fun.(*ast.Ident) + if !nameOK { + return true + } + if name.Name == "startPrimaryIPCClaimed" { + primaryStart = call.Pos() + return true + } + if name.Name != "startPrimaryNativeShell" { + return true + } + shellStarts++ + if primaryStart == token.NoPos || call.Pos() <= primaryStart { + t.Error("native shell must start only after this process becomes the local primary") + } + if len(call.Args) != 7 { + t.Fatalf("startPrimaryNativeShell has %d arguments; want 7", len(call.Args)) + } + exit, exitOK := call.Args[1].(*ast.SelectorExpr) + if !exitOK { + t.Error("TUI tray Exit must be a primary method") + return true + } + owner, ownerOK := exit.X.(*ast.Ident) + if !ownerOK || owner.Name != "primary" || exit.Sel.Name != "RequestQuit" { + t.Error("TUI tray Exit must signal primary.RequestQuit without canceling integration context") + } + disabled, disabledOK := call.Args[6].(*ast.StarExpr) + if !disabledOK { + t.Error("TUI tray startup must receive the explicit opt-out value") + return true + } + disabledName, disabledNameOK := disabled.X.(*ast.Ident) + if !disabledNameOK || disabledName.Name != "noTray" { + t.Error("TUI tray startup must forward the explicit --no-tray flag") + } + return true + }) + if shellStarts != 1 { + t.Fatalf("runTUIWithInitialAction starts %d native shells; want exactly one local-primary startup", shellStarts) + } +} + func TestNativeWebPageURL(t *testing.T) { got, err := nativeWebPageURL("http://127.0.0.1:8787/", " Settings ") if err != nil { diff --git a/Tools/Controller/cmd/controller/primary.go b/Tools/Controller/cmd/controller/primary.go index c6c4bc9e..8329bcaf 100644 --- a/Tools/Controller/cmd/controller/primary.go +++ b/Tools/Controller/cmd/controller/primary.go @@ -572,6 +572,13 @@ func (server *primaryIPC) QuitRequested() <-chan struct{} { return server.quit } +func (server *primaryIPC) RequestQuit() { + if server == nil { + return + } + server.quitOnce.Do(func() { close(server.quit) }) +} + func (server *primaryIPC) AppActions() <-chan hostui.AppAction { if server == nil || server.actions == nil { return nil