Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Tools/Controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions Tools/Controller/cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 22 additions & 1 deletion Tools/Controller/cmd/controller/native_web_shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
111 changes: 111 additions & 0 deletions Tools/Controller/cmd/controller/native_web_shell_test.go
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
DRSDavidSoft marked this conversation as resolved.
Resolved
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 {
Expand Down
7 changes: 7 additions & 0 deletions Tools/Controller/cmd/controller/primary.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions docs/Getting-Started-and-Operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions docs/Host-Configuration-and-Integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading