diff --git a/Tools/Controller/README.md b/Tools/Controller/README.md index ca2007547..f56d57cbd 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 cc57eb029..7bf14d003 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, primary.RequestQuit, 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 { diff --git a/Tools/Controller/cmd/controller/native_web_shell.go b/Tools/Controller/cmd/controller/native_web_shell.go index 5bbfaab45..d7fd2e465 100644 --- a/Tools/Controller/cmd/controller/native_web_shell.go +++ b/Tools/Controller/cmd/controller/native_web_shell.go @@ -14,9 +14,30 @@ 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 func(), + 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, + 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 5f2184a69..61fe057a0 100644 --- a/Tools/Controller/cmd/controller/native_web_shell_test.go +++ b/Tools/Controller/cmd/controller/native_web_shell_test.go @@ -1,11 +1,122 @@ package main import ( + "context" + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "runtime" "testing" + "pccontroller.local/controller/internal/appconfig" + "pccontroller.local/controller/internal/control" "pccontroller.local/controller/internal/nativeshell" ) +type testNativeShell struct{} + +func (*testNativeShell) Close() error { return nil } + +func TestPrimaryNativeShellOwnsDefaultTrayAndHonorsExplicitOptOut(t *testing.T) { + original := startNativeShell + t.Cleanup(func() { startNativeShell = original }) + + called := 0 + want := &testNativeShell{} + startNativeShell = func( + context.Context, func(), 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 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 c6c4bc9e2..8329bcaf5 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 diff --git a/docs/Getting-Started-and-Operations.md b/docs/Getting-Started-and-Operations.md index 727ec51e0..9439f99ef 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 3a4ae5c0b..e67b8fa3d 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