️ perf(native-window): 优化 AI 独立窗口启动与交互

- 关闭时隐藏保活,重开复用现有进程并通过可见性版本消除竞态
- 拆分 AI 与工作台资源,使用启动状态白名单减少序列化开销
- 修复设置中心层级、Windows 前台切换及独立窗口关闭交互
- 补齐状态同步、断线重放与窗口生命周期回归测试
This commit is contained in:
Syngnat
2026-07-21 23:40:17 +08:00
parent 62a045b39d
commit fda0733f11
38 changed files with 3147 additions and 1051 deletions

View File

@@ -43,14 +43,15 @@ type Bridge struct {
kind string
client *http.Client
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
ready bool
onReady func() OperationResult
terminal string
closeOnce sync.Once
emitToWails func(context.Context, string, ...any)
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
ready bool
onReady func() OperationResult
terminal string
closeOnce sync.Once
emitToWails func(context.Context, string, ...any)
allowParentForeground func() error
}
func newBridge(options ChildOptions) *Bridge {
@@ -63,11 +64,12 @@ func newBridge(options ChildOptions) *Bridge {
ForceAttemptHTTP2: false,
}
return &Bridge{
parentURL: strings.TrimRight(options.ParentURL, "/"),
token: options.Token,
windowID: options.ID,
kind: options.Kind,
client: &http.Client{Transport: transport},
parentURL: strings.TrimRight(options.ParentURL, "/"),
token: options.Token,
windowID: options.ID,
kind: options.Kind,
client: &http.Client{Transport: transport},
allowParentForeground: grantParentForegroundAccess,
emitToWails: func(ctx context.Context, name string, args ...any) {
wailsRuntime.EventsEmit(ctx, name, args...)
},
@@ -145,6 +147,10 @@ func (b *Bridge) FocusWindow(id string) OperationResult {
return b.control(controlRequest{Action: "focus", ID: id})
}
func (b *Bridge) HideWindow(id string) OperationResult {
return b.control(controlRequest{Action: "hide", ID: id})
}
func (b *Bridge) CloseWindow(id string) OperationResult {
return b.control(controlRequest{Action: "close", ID: id})
}
@@ -171,8 +177,8 @@ func (b *Bridge) control(request controlRequest) OperationResult {
return result
}
// Action acknowledges child readiness or forwards sync, attach, or close state
// to the main window.
// Action acknowledges child readiness or forwards sync, hide, attach, or close
// state to the main window.
func (b *Bridge) Action(action string, payload any) OperationResult {
normalizedAction := strings.ToLower(strings.TrimSpace(action))
if normalizedAction == "ready" {
@@ -180,6 +186,12 @@ func (b *Bridge) Action(action string, payload any) OperationResult {
return result
}
}
if normalizedAction == "open-ai-settings" && b.allowParentForeground != nil {
// The detached child owns the current user interaction on Windows. Grant
// the parent permission immediately before it attempts to take focus. This
// is best-effort so an OS rejection never blocks the settings action itself.
_ = b.allowParentForeground()
}
var result OperationResult
status, err := b.doJSON(context.Background(), http.MethodPost, ActionPath, actionRequest{Action: action, Payload: payload}, &result)
@@ -410,13 +422,36 @@ func (b *Bridge) replayPendingCommand(ctx context.Context) error {
if status != http.StatusOK {
return fmt.Errorf("detached command-state replay failed with status %d", status)
}
if strings.TrimSpace(command.ID) != b.windowID || command.Action != "close" {
if strings.TrimSpace(command.ID) != b.windowID ||
(command.Action != "close" && command.Action != "hide" && command.Action != "focus") {
return fmt.Errorf("detached command-state replay is invalid")
}
visibilityRevision := positiveVisibilityRevision(command.Payload)
if command.Action == "focus" && visibilityRevision == 0 {
return fmt.Errorf("detached command-state focus revision is invalid")
}
b.emitRuntimeEvent(CommandEventName, command)
return nil
}
func (b *Bridge) acknowledgeFocus(ctx context.Context, visibilityRevision uint64) error {
if visibilityRevision == 0 {
return fmt.Errorf("detached focus acknowledgement revision is invalid")
}
var result OperationResult
status, err := b.doJSON(ctx, http.MethodPost, CommandStatePath, commandStateRequest{
Action: "ack-focus",
VisibilityRevision: visibilityRevision,
}, &result)
if err != nil {
return err
}
if status != http.StatusOK || !result.Success {
return fmt.Errorf("detached focus acknowledgement failed with status %d", status)
}
return nil
}
func (b *Bridge) replayHostState(ctx context.Context) error {
var snapshot HostStateRequest
status, err := b.doJSON(ctx, http.MethodGet, HostStatePath, nil, &snapshot)
@@ -492,12 +527,15 @@ type Control struct {
closeFallbackGeneration uint64
closeFallbackDelay time.Duration
closeCommitted bool
visibilityRevision uint64
domReady bool
frontendReady bool
focusPending bool
focusPendingRevision uint64
visible bool
emitCommand func(context.Context, childCommand)
showWindow func(context.Context)
hideWindow func(context.Context)
focusWindow func(context.Context)
quit func(context.Context)
}
@@ -512,6 +550,9 @@ func newControl(bridge *Bridge) *Control {
showWindow: func(ctx context.Context) {
wailsRuntime.WindowShow(ctx)
},
hideWindow: func(ctx context.Context) {
wailsRuntime.WindowHide(ctx)
},
focusWindow: func(ctx context.Context) {
wailsRuntime.WindowUnminimise(ctx)
wailsRuntime.Show(ctx)
@@ -586,7 +627,10 @@ func (c *Control) Present() OperationResult {
presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow}
if c.focusPending && c.focusWindow != nil {
c.focusPending = false
presentation.visibilityRevision = c.focusPendingRevision
c.focusPendingRevision = 0
presentation.focus = c.focusWindow
presentation.bridge = c.bridge
}
c.mu.Unlock()
presentation.run()
@@ -594,9 +638,11 @@ func (c *Control) Present() OperationResult {
}
type childWindowPresentation struct {
ctx context.Context
show func(context.Context)
focus func(context.Context)
ctx context.Context
show func(context.Context)
focus func(context.Context)
bridge *Bridge
visibilityRevision uint64
}
func (p childWindowPresentation) run() {
@@ -605,6 +651,12 @@ func (p childWindowPresentation) run() {
}
if p.focus != nil {
p.focus(p.ctx)
if p.bridge != nil && p.visibilityRevision > 0 {
// The parent must retain its pending focus until the native focus
// callback has actually run. A failed acknowledgement deliberately
// leaves that pending command available for the next SSE reconnect.
_ = p.bridge.acknowledgeFocus(p.ctx, p.visibilityRevision)
}
}
}
@@ -616,7 +668,10 @@ func (c *Control) takeInitialPresentationLocked() childWindowPresentation {
presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow}
if c.focusPending && c.focusWindow != nil {
c.focusPending = false
presentation.visibilityRevision = c.focusPendingRevision
c.focusPendingRevision = 0
presentation.focus = c.focusWindow
presentation.bridge = c.bridge
}
return presentation
}
@@ -643,6 +698,44 @@ func (c *Control) Close() OperationResult {
return OperationResult{Success: true}
}
// Hide parks this child window while keeping its process, WebView, and React
// tree alive. Visibility revisions make the transition monotonic: a delayed
// hide from an older close request cannot win over a newer host focus.
func (c *Control) Hide(visibilityRevision uint64) OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.mu.Lock()
if visibilityRevision < c.visibilityRevision {
currentRevision := c.visibilityRevision
c.mu.Unlock()
return OperationResult{
Success: true,
Message: "stale native window hide ignored",
VisibilityRevision: currentRevision,
}
}
ctx := c.ctx
hide := c.hideWindow
if ctx == nil || hide == nil {
c.mu.Unlock()
return operationFailure("native window is not ready")
}
if c.closeCommitted {
c.mu.Unlock()
return operationFailure("native window close is already committed")
}
c.visibilityRevision = visibilityRevision
c.visible = false
c.focusPending = false
c.focusPendingRevision = 0
c.invalidateCloseFallbackLocked()
c.mu.Unlock()
c.closeGate.cancel()
hide(ctx)
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}
// CancelClose keeps the child alive after a failed final frontend flush. It
// also invalidates any native-close fallback so a retry starts a fresh grace
// period instead of inheriting the old timeout.
@@ -743,10 +836,32 @@ func (c *Control) invalidateCloseFallbackLocked() {
}
func (c *Control) Focus() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.mu.RLock()
visibilityRevision := c.visibilityRevision
c.mu.RUnlock()
return c.FocusRevision(visibilityRevision)
}
// FocusRevision raises the window only when the request is at least as new as
// the last visibility transition observed by this child.
func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.mu.Lock()
if visibilityRevision < c.visibilityRevision {
currentRevision := c.visibilityRevision
c.mu.Unlock()
return OperationResult{
Success: true,
Message: "stale native window focus ignored",
VisibilityRevision: currentRevision,
}
}
c.visibilityRevision = visibilityRevision
ctx := c.ctx
focus := c.focusWindow
if ctx == nil || focus == nil {
@@ -755,12 +870,21 @@ func (c *Control) Focus() OperationResult {
}
if !c.visible {
c.focusPending = true
c.focusPendingRevision = visibilityRevision
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
return OperationResult{Success: true}
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}
c.focusPending = false
c.focusPendingRevision = 0
presentation := childWindowPresentation{
ctx: ctx,
focus: focus,
bridge: c.bridge,
visibilityRevision: visibilityRevision,
}
c.mu.Unlock()
focus(ctx)
return OperationResult{Success: true}
presentation.run()
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}

View File

@@ -125,3 +125,112 @@ func TestBridgeReplaysPendingCloseWhenEventStreamReconnects(t *testing.T) {
t.Fatalf("replayed command = %#v", received)
}
}
func TestBridgeReplaysPendingFocusWithoutAcknowledgingBeforeNativeFocus(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
commandPayload, err := json.Marshal(childCommand{
ID: "ai-chat",
Action: "focus",
Payload: visibilityCommandPayload{VisibilityRevision: 7},
})
if err != nil {
t.Fatalf("marshal focus command: %v", err)
}
acknowledgements := 0
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
status := http.StatusNoContent
body := ""
switch {
case request.URL.Path == EventsPath:
status = http.StatusOK
body = ": connected\n\n"
case request.URL.Path == CommandStatePath && request.Method == http.MethodGet:
status = http.StatusOK
body = string(commandPayload)
case request.URL.Path == CommandStatePath && request.Method == http.MethodPost:
acknowledgements++
status = http.StatusOK
body = `{"success":true,"id":"ai-chat","visibilityRevision":7}`
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})
var received childCommand
bridge.mu.Lock()
bridge.ctx = context.Background()
bridge.emitToWails = func(_ context.Context, name string, args ...any) {
if name == CommandEventName && len(args) == 1 {
received, _ = args[0].(childCommand)
}
}
bridge.mu.Unlock()
if err := bridge.consumeEventStream(context.Background()); err != nil {
t.Fatalf("consume reconnected stream: %v", err)
}
if received.Action != "focus" || positiveVisibilityRevision(received.Payload) != 7 {
t.Fatalf("replayed focus command = %#v", received)
}
if acknowledgements != 0 {
t.Fatalf("focus acknowledgements before native focus = %d, want 0", acknowledgements)
}
}
func TestBridgeDeliversLiveFocusWithoutAcknowledgingBeforeNativeFocus(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
eventPayload, err := json.Marshal(bridgeEvent{
Name: CommandEventName,
Args: []any{childCommand{
ID: "ai-chat",
Action: "focus",
Payload: visibilityCommandPayload{VisibilityRevision: 9},
}},
})
if err != nil {
t.Fatalf("marshal focus event: %v", err)
}
acknowledgements := 0
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
status := http.StatusNoContent
body := ""
switch {
case request.URL.Path == EventsPath:
status = http.StatusOK
body = "data: " + string(eventPayload) + "\n\n"
case request.URL.Path == CommandStatePath && request.Method == http.MethodPost:
acknowledgements++
status = http.StatusOK
body = `{"success":true,"id":"ai-chat","visibilityRevision":9}`
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})
bridge.mu.Lock()
bridge.ctx = context.Background()
bridge.emitToWails = func(context.Context, string, ...any) {}
bridge.mu.Unlock()
if err := bridge.consumeEventStream(context.Background()); err != nil {
t.Fatalf("consume live focus stream: %v", err)
}
if acknowledgements != 0 {
t.Fatalf("focus acknowledgements before native focus = %d, want 0", acknowledgements)
}
}

View File

@@ -16,6 +16,35 @@ func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error)
return f(request)
}
func TestBridgeHideActionDoesNotCommitTerminalState(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"success":true,"id":"ai-chat","visibilityRevision":1}`,
)),
Header: make(http.Header),
}, nil
})
result := bridge.Action("hide", map[string]any{"id": "ai-chat", "kind": "ai-chat"})
if !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("hide Action result = %#v", result)
}
bridge.mu.Lock()
terminal := bridge.terminal
bridge.mu.Unlock()
if terminal != "" {
t.Fatalf("hide action committed terminal state %q", terminal)
}
}
func TestCloseGateVetoesAndRequestsFrontendUntilExitIsAllowed(t *testing.T) {
var gate closeGate

View File

@@ -92,7 +92,7 @@ func buildDockMenuSnapshot(windows []WindowInfo) []dockMenuWindow {
current := make([]WindowInfo, 0, len(windows))
for _, window := range windows {
window.ID = strings.TrimSpace(window.ID)
if window.ID == "" || !window.Ready || window.CloseSent {
if window.ID == "" || !window.Ready || window.CloseSent || window.Hidden {
continue
}
current = append(current, window)

View File

@@ -8,6 +8,7 @@ import (
func TestBuildDockMenuSnapshotIncludesOnlyCurrentReadyWindows(t *testing.T) {
windows := []WindowInfo{
{ID: "closing", Title: "Closing", PID: 42, OpenedAt: 1, Ready: true, CloseSent: true},
{ID: "hidden", Title: "Hidden", PID: 48, OpenedAt: 3, Ready: true, Hidden: true},
{ID: "not-ready", Title: "Starting", PID: 43, OpenedAt: 2},
{ID: " result-2 ", Title: " Result 2 ", PID: 44, OpenedAt: 40, Ready: true},
{ID: "workbench-1", Title: "Workbench", PID: 45, OpenedAt: 20, Ready: true},

View File

@@ -0,0 +1,7 @@
//go:build !windows
package nativewindow
func grantParentForegroundAccess() error {
return nil
}

View File

@@ -0,0 +1,82 @@
package nativewindow
import (
"errors"
"io"
"net/http"
"strings"
"testing"
)
func TestBridgeAllowsParentForegroundImmediatelyBeforeOpeningAISettings(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
steps := make([]string, 0, 2)
bridge.allowParentForeground = func() error {
steps = append(steps, "allow-parent-foreground")
return nil
}
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
steps = append(steps, "post-action")
return successfulForegroundActionResponse(), nil
})
if result := bridge.Action("open-ai-settings", map[string]any{"id": "ai-chat"}); !result.Success {
t.Fatalf("open-ai-settings result = %#v", result)
}
if got := strings.Join(steps, ","); got != "allow-parent-foreground,post-action" {
t.Fatalf("open-ai-settings sequence = %q", got)
}
}
func TestBridgeStillOpensAISettingsWhenForegroundPermissionFails(t *testing.T) {
bridge := newBridge(ChildOptions{ParentURL: "http://127.0.0.1:43119", ID: "ai-chat", Kind: "ai-chat"})
bridge.allowParentForeground = func() error {
return errors.New("permission denied")
}
posts := 0
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
posts++
return successfulForegroundActionResponse(), nil
})
if result := bridge.Action("open-ai-settings", nil); !result.Success {
t.Fatalf("open-ai-settings result = %#v", result)
}
if posts != 1 {
t.Fatalf("parent action posts = %d, want 1", posts)
}
}
func TestBridgeDoesNotGrantForegroundForOtherActions(t *testing.T) {
bridge := newBridge(ChildOptions{ParentURL: "http://127.0.0.1:43119", ID: "ai-chat", Kind: "ai-chat"})
grants := 0
bridge.allowParentForeground = func() error {
grants++
return nil
}
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
return successfulForegroundActionResponse(), nil
})
for _, action := range []string{"sync", "attach", "close", "host-event"} {
if result := bridge.Action(action, nil); !result.Success {
t.Fatalf("%s result = %#v", action, result)
}
}
if grants != 0 {
t.Fatalf("foreground grants = %d, want 0", grants)
}
}
func successfulForegroundActionResponse() *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"ai-chat"}`)),
Header: make(http.Header),
}
}

View File

@@ -0,0 +1,28 @@
//go:build windows
package nativewindow
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
var allowSetForegroundWindowProc = windows.NewLazySystemDLL("user32.dll").NewProc("AllowSetForegroundWindow")
var allowSetForegroundWindow = func(processID uint32) bool {
result, _, _ := allowSetForegroundWindowProc.Call(uintptr(processID))
return result != 0
}
func grantParentForegroundAccess() error {
parentProcessID := os.Getppid()
if parentProcessID <= 0 {
return fmt.Errorf("invalid parent process ID %d", parentProcessID)
}
if !allowSetForegroundWindow(uint32(parentProcessID)) {
return fmt.Errorf("AllowSetForegroundWindow rejected parent process %d", parentProcessID)
}
return nil
}

View File

@@ -0,0 +1,40 @@
//go:build windows
package nativewindow
import (
"os"
"testing"
)
func TestGrantParentForegroundAccessTargetsDirectParent(t *testing.T) {
original := allowSetForegroundWindow
t.Cleanup(func() {
allowSetForegroundWindow = original
})
var processID uint32
allowSetForegroundWindow = func(candidate uint32) bool {
processID = candidate
return true
}
if err := grantParentForegroundAccess(); err != nil {
t.Fatalf("grantParentForegroundAccess error = %v", err)
}
if processID != uint32(os.Getppid()) {
t.Fatalf("foreground process ID = %d, want parent %d", processID, os.Getppid())
}
}
func TestGrantParentForegroundAccessReportsWindowsRejection(t *testing.T) {
original := allowSetForegroundWindow
t.Cleanup(func() {
allowSetForegroundWindow = original
})
allowSetForegroundWindow = func(uint32) bool { return false }
if err := grantParentForegroundAccess(); err == nil {
t.Fatal("grantParentForegroundAccess error = nil, want Windows rejection")
}
}

View File

@@ -48,19 +48,21 @@ type processExit struct {
}
type windowEntry struct {
info WindowInfo
payload any
hostState HostStateRequest
ownerID string
process childProcess
exitReason string
closeGeneration uint64
actionRevision int64
ready chan struct{}
done chan processExit
readyOnce sync.Once
doneOnce sync.Once
acknowledged bool
info WindowInfo
payload any
hostState HostStateRequest
ownerID string
process childProcess
exitReason string
closeGeneration uint64
visibilityRevision uint64
pendingFocusRevision uint64
actionRevision int64
ready chan struct{}
done chan processExit
readyOnce sync.Once
doneOnce sync.Once
acknowledged bool
}
// Manager owns the loopback bridge and the registry of detached Wails child
@@ -292,7 +294,20 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
m.mu.Unlock()
return operationFailure("native window manager is not running")
}
if _, exists := m.windows[request.ID]; exists {
if existing, exists := m.windows[request.ID]; exists {
if existing.info.CloseSent {
m.mu.Unlock()
return closingWindowRetryFailure(request.ID)
}
// A parked child keeps its WebView and React tree alive. Refresh the
// bootstrap snapshot and remembered geometry before raising it so a
// subsequent frontend resume can hydrate from the newest host state.
existing.payload = request.Payload
existing.info.Title = request.Title
existing.info.X = request.X
existing.info.Y = request.Y
existing.info.Width = request.Width
existing.info.Height = request.Height
m.mu.Unlock()
result := m.Focus(request.ID)
result.ID = request.ID
@@ -401,25 +416,93 @@ func (m *Manager) Focus(id string) OperationResult {
return operationFailure("native window manager is unavailable")
}
id = strings.TrimSpace(id)
m.mu.RLock()
m.mu.Lock()
entry, exists := m.windows[id]
var bounds *WindowBounds
if exists {
bounds = windowBoundsFromInfo(entry.info)
}
emitToChild := m.emitToChild
shared := m.shared
m.mu.RUnlock()
if !exists {
m.mu.Unlock()
return operationFailure("native window was not found")
}
command := childCommand{ID: id, Action: "focus"}
if entry.info.CloseSent {
m.mu.Unlock()
return closingWindowRetryFailure(id)
}
wasHidden := entry.info.Hidden
// Every explicit focus is a separately acknowledged visibility intent.
// Advancing even while already visible prevents an acknowledgement for an
// older focus from clearing a newer request that arrived during an SSE gap.
entry.visibilityRevision++
entry.info.Hidden = false
entry.pendingFocusRevision = entry.visibilityRevision
visibilityRevision := entry.visibilityRevision
bounds := windowBoundsFromInfo(entry.info)
emitToChild := m.emitToChild
shared := m.shared
m.mu.Unlock()
command := childCommand{
ID: id,
Action: "focus",
Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision},
}
if emitToChild != nil {
emitToChild(id, CommandEventName, command)
} else if shared != nil {
shared.EmitTo(id, CommandEventName, command)
}
return OperationResult{Success: true, ID: id, Bounds: bounds}
if wasHidden {
publishDetachedDockMenuSnapshot(m)
}
return OperationResult{
Success: true,
ID: id,
Bounds: bounds,
VisibilityRevision: visibilityRevision,
}
}
// Hide parks a detached child without terminating its process. Repeated hides
// reuse the same visibility revision, while the next Focus advances it so a
// delayed child-side hide cannot conceal a newly focused window.
func (m *Manager) Hide(id string) OperationResult {
if m == nil {
return operationFailure("native window manager is unavailable")
}
id = strings.TrimSpace(id)
m.mu.Lock()
entry, exists := m.windows[id]
if !exists {
m.mu.Unlock()
return operationFailure("native window was not found")
}
if entry.info.CloseSent {
m.mu.Unlock()
return operationFailure("native window is closing")
}
if !entry.info.Hidden {
entry.visibilityRevision++
entry.info.Hidden = true
}
entry.pendingFocusRevision = 0
visibilityRevision := entry.visibilityRevision
emitToChild := m.emitToChild
shared := m.shared
m.mu.Unlock()
command := childCommand{
ID: id,
Action: "hide",
Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision},
}
if emitToChild != nil {
emitToChild(id, CommandEventName, command)
} else if shared != nil {
shared.EmitTo(id, CommandEventName, command)
}
publishDetachedDockMenuSnapshot(m)
return OperationResult{
Success: true,
ID: id,
VisibilityRevision: visibilityRevision,
}
}
func windowBoundsFromRequest(request OpenRequest) *WindowBounds {
@@ -455,6 +538,7 @@ func (m *Manager) requestClose(id string, reason string) OperationResult {
entry.exitReason = reason
}
entry.info.CloseSent = true
entry.pendingFocusRevision = 0
entry.closeGeneration++
closeGeneration := entry.closeGeneration
process := entry.process
@@ -605,6 +689,10 @@ type hostStateInvalidationPayload struct {
Revision int64 `json:"revision"`
}
type visibilityCommandPayload struct {
VisibilityRevision uint64 `json:"visibilityRevision"`
}
func cloneHostStoreState(storeState map[string]any) (map[string]any, error) {
if storeState == nil {
return nil, fmt.Errorf("native host-state storeState is required")
@@ -743,6 +831,14 @@ func operationFailure(message string) OperationResult {
return OperationResult{Success: false, Message: message}
}
func closingWindowRetryFailure(id string) OperationResult {
return OperationResult{
Success: false,
ID: strings.TrimSpace(id),
Message: "native window is closing; retry after it exits",
}
}
func validateOpenRequest(request OpenRequest) error {
if len(request.ID) > 256 || strings.ContainsAny(request.ID, "\r\n\x00") {
return fmt.Errorf("native window id is invalid")
@@ -769,6 +865,7 @@ func (m *Manager) shutdown() {
ids = append(ids, id)
entry.exitReason = ExitReasonParentShutdown
entry.info.CloseSent = true
entry.pendingFocusRevision = 0
entry.closeGeneration++
}
httpServer := m.httpServer
@@ -927,6 +1024,10 @@ func (m *Manager) handleHostState(w http.ResponseWriter, r *http.Request) {
}
func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
m.handleCommandStateAck(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
@@ -940,12 +1041,33 @@ func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) {
return
}
closeSent := entry.info.CloseSent
hidden := entry.info.Hidden
visibilityRevision := entry.visibilityRevision
pendingFocusRevision := entry.pendingFocusRevision
reason := entry.exitReason
m.mu.RUnlock()
if !closeSent {
if !closeSent && !hidden && pendingFocusRevision == 0 {
w.WriteHeader(http.StatusNoContent)
return
}
if hidden && !closeSent {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(childCommand{
ID: id,
Action: "hide",
Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision},
})
return
}
if !closeSent {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(childCommand{
ID: id,
Action: "focus",
Payload: visibilityCommandPayload{VisibilityRevision: pendingFocusRevision},
})
return
}
if strings.TrimSpace(reason) == "" {
reason = ExitReasonRequested
}
@@ -957,6 +1079,51 @@ func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) {
})
}
type commandStateRequest struct {
Action string `json:"action"`
VisibilityRevision uint64 `json:"visibilityRevision"`
}
func (m *Manager) handleCommandStateAck(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var request commandStateRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10))
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid detached command acknowledgement", http.StatusBadRequest)
return
}
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
if request.Action != "ack-focus" || request.VisibilityRevision == 0 {
http.Error(w, "invalid detached command acknowledgement", http.StatusBadRequest)
return
}
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
m.mu.Lock()
entry, exists := m.windows[id]
if !exists {
m.mu.Unlock()
http.Error(w, "unknown detached window", http.StatusNotFound)
return
}
message := ""
if entry.pendingFocusRevision == request.VisibilityRevision {
entry.pendingFocusRevision = 0
} else {
message = "stale focus acknowledgement ignored"
}
visibilityRevision := entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
ID: id,
Message: message,
VisibilityRevision: visibilityRevision,
})
}
func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -971,7 +1138,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
}
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
switch request.Action {
case "ready", "sync", "attach", "close", "cancel-close", "host-event", "open-ai-settings":
case "ready", "sync", "attach", "close", "hide", "cancel-close", "host-event", "open-ai-settings":
default:
http.Error(w, "unsupported detached action", http.StatusBadRequest)
return
@@ -988,17 +1155,21 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
}
if actionUsesRevision(request.Action) && revision > 0 {
if revision <= entry.actionRevision {
visibilityRevision := entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
ID: id,
Message: "stale detached action ignored",
Success: true,
ID: id,
Message: "stale detached action ignored",
VisibilityRevision: visibilityRevision,
})
return
}
entry.actionRevision = revision
}
eventAction := request.Action
visibilityRevision := uint64(0)
if request.Action == "ready" {
entry.info.Ready = true
entry.readyOnce.Do(func() {
@@ -1008,15 +1179,39 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
})
} else if request.Action == "attach" {
entry.exitReason = ExitReasonAttached
entry.pendingFocusRevision = 0
} else if request.Action == "close" && entry.exitReason == "" {
entry.exitReason = ExitReasonWindowClosed
entry.pendingFocusRevision = 0
} else if request.Action == "hide" {
requestedVisibilityRevision := positiveVisibilityRevision(request.Payload)
switch {
case requestedVisibilityRevision == 0:
if !entry.info.Hidden {
entry.visibilityRevision++
entry.info.Hidden = true
}
entry.pendingFocusRevision = 0
visibilityRevision = entry.visibilityRevision
case requestedVisibilityRevision < entry.visibilityRevision:
// Preserve the final child snapshot, but do not let an old hide
// transition close a window that the host has already focused again.
visibilityRevision = requestedVisibilityRevision
eventAction = "sync"
default:
entry.visibilityRevision = requestedVisibilityRevision
entry.info.Hidden = true
entry.pendingFocusRevision = 0
visibilityRevision = requestedVisibilityRevision
}
request.Payload = withVisibilityRevision(request.Payload, visibilityRevision)
} else if request.Action == "cancel-close" {
m.cancelCloseLocked(entry)
}
info := entry.info
ownerID := entry.ownerID
m.mu.Unlock()
if request.Action == "ready" || request.Action == "cancel-close" {
if request.Action == "ready" || request.Action == "hide" || request.Action == "cancel-close" {
publishDetachedDockMenuSnapshot(m)
}
@@ -1024,16 +1219,76 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
m.emitDetached(Event{
ID: info.ID,
Kind: info.Kind,
Action: request.Action,
Action: eventAction,
Payload: withOwnerWindowID(request.Payload, ownerID),
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{Success: true, ID: id})
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
ID: id,
VisibilityRevision: visibilityRevision,
})
}
func actionUsesRevision(action string) bool {
return action == "sync" || action == "attach" || action == "close"
return action == "sync" || action == "attach" || action == "close" || action == "hide"
}
func positiveVisibilityRevision(payload any) uint64 {
switch typed := payload.(type) {
case visibilityCommandPayload:
return typed.VisibilityRevision
case *visibilityCommandPayload:
if typed != nil {
return typed.VisibilityRevision
}
}
record, ok := payload.(map[string]any)
if !ok {
return 0
}
return positiveUintRevision(record["visibilityRevision"])
}
func positiveUintRevision(value any) uint64 {
switch typed := value.(type) {
case float64:
if typed > 0 && typed <= 9_007_199_254_740_991 && math.Trunc(typed) == typed {
return uint64(typed)
}
case json.Number:
revision, err := typed.Int64()
if err == nil && revision > 0 {
return uint64(revision)
}
case uint64:
return typed
case uint:
return uint64(typed)
case int64:
if typed > 0 {
return uint64(typed)
}
case int:
if typed > 0 {
return uint64(typed)
}
}
return 0
}
func withVisibilityRevision(payload any, visibilityRevision uint64) any {
result := make(map[string]any)
if source, ok := payload.(map[string]any); ok {
for key, value := range source {
result[key] = value
}
} else if payload != nil {
result["value"] = payload
}
result["visibilityRevision"] = visibilityRevision
return result
}
func positiveActionRevision(payload any) int64 {
@@ -1105,6 +1360,12 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
break
}
result = m.Focus(request.ID)
case "hide":
if !m.ownsWindow(request.ID, ownerID) {
result = operationFailure("native window is not owned by this window")
break
}
result = m.Hide(request.ID)
case "close":
if !m.ownsWindow(request.ID, ownerID) {
result = operationFailure("native window is not owned by this window")

View File

@@ -473,6 +473,318 @@ func TestManagerRoutesCommandsAndAIStreamsToOnlyTheirTargetWindow(t *testing.T)
}
}
func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{
ID: "ai-chat",
Kind: "ai-chat",
X: 10,
Y: 20,
Width: 440,
Height: 720,
},
}
commands := make(chan childCommand, 3)
manager.emitToChild = func(targetID string, name string, args ...any) {
if targetID != "ai-chat" || name != CommandEventName {
t.Fatalf("unexpected target event %q %q", targetID, name)
}
commands <- args[0].(childCommand)
}
firstHide := manager.Hide("ai-chat")
if !firstHide.Success || firstHide.VisibilityRevision != 1 {
t.Fatalf("first Hide result = %#v", firstHide)
}
firstHideCommand := <-commands
if firstHideCommand.Action != "hide" ||
firstHideCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 1 {
t.Fatalf("first hide command = %#v", firstHideCommand)
}
secondHide := manager.Hide("ai-chat")
if !secondHide.Success || secondHide.VisibilityRevision != 1 {
t.Fatalf("second Hide result = %#v", secondHide)
}
secondHideCommand := <-commands
if secondHideCommand.Action != "hide" ||
secondHideCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 1 {
t.Fatalf("second hide command = %#v", secondHideCommand)
}
focus := manager.Focus("ai-chat")
if !focus.Success || focus.VisibilityRevision != 2 {
t.Fatalf("Focus result = %#v", focus)
}
focusCommand := <-commands
if focusCommand.Action != "focus" ||
focusCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 2 {
t.Fatalf("focus command = %#v", focusCommand)
}
manager.mu.RLock()
hidden := manager.windows["ai-chat"].info.Hidden
manager.mu.RUnlock()
if hidden {
t.Fatal("focused window remained hidden in manager state")
}
}
func TestManagerReplaysLatestFocusUntilChildAcknowledgesIt(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{
ID: "ai-chat",
Kind: "ai-chat",
Hidden: true,
},
visibilityRevision: 1,
}
// Simulate a disconnected child: reliable SSE has no subscriber and the
// immediate delivery therefore disappears.
manager.emitToChild = func(string, string, ...any) {}
first := manager.Focus("ai-chat")
second := manager.Focus("ai-chat")
if !first.Success || first.VisibilityRevision != 2 ||
!second.Success || second.VisibilityRevision != 3 {
t.Fatalf("Focus results = %#v %#v", first, second)
}
replay := func() (*httptest.ResponseRecorder, childCommand) {
t.Helper()
request := authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
var command childCommand
if recorder.Code == http.StatusOK {
if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil {
t.Fatalf("decode command state: %v", err)
}
}
return recorder, command
}
recorder, command := replay()
if recorder.Code != http.StatusOK || command.Action != "focus" ||
positiveVisibilityRevision(command.Payload) != 3 {
t.Fatalf("pending focus replay = status %d command %#v", recorder.Code, command)
}
staleAck := strings.NewReader(`{"action":"ack-focus","visibilityRevision":2}`)
staleRequest := authenticatedRequest(manager, http.MethodPost, CommandStatePath, "ai-chat", staleAck)
staleRecorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(staleRecorder, staleRequest)
if staleRecorder.Code != http.StatusOK {
t.Fatalf("stale focus ack status = %d body=%s", staleRecorder.Code, staleRecorder.Body.String())
}
if recorder, command = replay(); recorder.Code != http.StatusOK ||
command.Action != "focus" || positiveVisibilityRevision(command.Payload) != 3 {
t.Fatalf("focus after stale ack = status %d command %#v", recorder.Code, command)
}
latestAck := strings.NewReader(`{"action":"ack-focus","visibilityRevision":3}`)
latestRequest := authenticatedRequest(manager, http.MethodPost, CommandStatePath, "ai-chat", latestAck)
latestRecorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(latestRecorder, latestRequest)
if latestRecorder.Code != http.StatusOK {
t.Fatalf("latest focus ack status = %d body=%s", latestRecorder.Code, latestRecorder.Body.String())
}
if recorder, _ = replay(); recorder.Code != http.StatusNoContent {
t.Fatalf("command state after focus ack = %d body=%s, want 204", recorder.Code, recorder.Body.String())
}
}
func TestManagerCommandStatePrioritizesHideAndCloseOverPendingFocus(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Hidden: true},
visibilityRevision: 1,
}
manager.emitToChild = func(string, string, ...any) {}
if result := manager.Focus("ai-chat"); !result.Success {
t.Fatalf("Focus result = %#v", result)
}
if result := manager.Hide("ai-chat"); !result.Success {
t.Fatalf("Hide result = %#v", result)
}
request := authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
var command childCommand
if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil {
t.Fatalf("decode hidden command state: %v", err)
}
if recorder.Code != http.StatusOK || command.Action != "hide" {
t.Fatalf("hidden command state = status %d command %#v", recorder.Code, command)
}
if result := manager.Close("ai-chat"); !result.Success {
t.Fatalf("Close result = %#v", result)
}
request = authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil)
recorder = httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil {
t.Fatalf("decode closing command state: %v", err)
}
if recorder.Code != http.StatusOK || command.Action != "close" {
t.Fatalf("closing command state = status %d command %#v", recorder.Code, command)
}
}
func TestManagerFocusAndOpenRejectClosingWindowForRetry(t *testing.T) {
manager := newHTTPTestManager(t)
starter := &fakeProcessStarter{}
manager.started = true
manager.endpoint = "http://127.0.0.1:43119"
manager.starter = starter
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{
ID: "ai-chat",
Kind: "ai-chat",
Title: "Existing",
X: 10,
Y: 20,
Width: 440,
Height: 720,
Hidden: true,
CloseSent: true,
},
payload: map[string]any{"snapshot": "existing"},
visibilityRevision: 4,
}
for name, result := range map[string]OperationResult{
"focus": manager.Focus("ai-chat"),
"open": manager.Open(OpenRequest{
ID: "ai-chat",
Kind: "ai-chat",
Title: "Replacement",
Payload: map[string]any{"snapshot": "replacement"},
X: 30,
Y: 40,
Width: 500,
Height: 800,
}),
} {
if result.Success || !strings.Contains(result.Message, "closing") ||
!strings.Contains(result.Message, "retry") {
t.Fatalf("%s result = %#v, want explicit retry failure", name, result)
}
}
manager.mu.RLock()
entry := manager.windows["ai-chat"]
payload := entry.payload.(map[string]any)["snapshot"]
info := entry.info
revision := entry.visibilityRevision
manager.mu.RUnlock()
if payload != "existing" || info.Title != "Existing" || info.X != 10 || info.Y != 20 ||
!info.Hidden || revision != 4 {
t.Fatalf("closing entry was mutated: info=%#v payload=%#v revision=%d", info, payload, revision)
}
starter.mu.Lock()
starts := len(starter.specs)
starter.mu.Unlock()
if starts != 0 {
t.Fatalf("closing entry spawned %d replacement processes, want 0", starts)
}
}
func TestHideActionParksWithoutCommittingTerminalState(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true},
}
events := make(chan Event, 2)
manager.runtimeCtx = context.Background()
manager.emitToWails = func(_ context.Context, name string, args ...any) {
if name == MainEventName && len(args) == 1 {
events <- args[0].(Event)
}
}
body := strings.NewReader(`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":1}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("hide status = %d body=%s", recorder.Code, recorder.Body.String())
}
var result OperationResult
if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil {
t.Fatalf("decode hide result: %v", err)
}
if !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("hide result = %#v", result)
}
event := receiveEvent(t, events)
if event.Action != "hide" || event.ID != "ai-chat" {
t.Fatalf("hide event = %#v", event)
}
payload := event.Payload.(map[string]any)
if payload["visibilityRevision"] != uint64(1) {
t.Fatalf("hide payload = %#v", payload)
}
manager.mu.RLock()
entry := manager.windows["ai-chat"]
hidden := entry.info.Hidden
closeSent := entry.info.CloseSent
exitReason := entry.exitReason
manager.mu.RUnlock()
if !hidden || closeSent || exitReason != "" {
t.Fatalf(
"parked state = hidden %v closeSent %v reason %q",
hidden,
closeSent,
exitReason,
)
}
}
func TestStaleHideActionCannotOverrideNewerFocus(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true, Hidden: true},
visibilityRevision: 1,
actionRevision: 1,
}
events := make(chan Event, 1)
manager.runtimeCtx = context.Background()
manager.emitToWails = func(_ context.Context, name string, args ...any) {
if name == MainEventName && len(args) == 1 {
events <- args[0].(Event)
}
}
if result := manager.Focus("ai-chat"); !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("Focus result = %#v", result)
}
body := strings.NewReader(
`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":2,"visibilityRevision":1}}`,
)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("stale hide status = %d body=%s", recorder.Code, recorder.Body.String())
}
event := receiveEvent(t, events)
if event.Action != "sync" {
t.Fatalf("stale hide event = %#v, want sync", event)
}
manager.mu.RLock()
entry := manager.windows["ai-chat"]
hidden := entry.info.Hidden
revision := entry.visibilityRevision
manager.mu.RUnlock()
if hidden || revision != 2 {
t.Fatalf("state after stale hide = hidden %v revision %d", hidden, revision)
}
}
func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{

View File

@@ -14,6 +14,7 @@ func detachedRuntimeBridgeScript() string {
|| typeof bridge.WindowID !== 'function'
|| typeof bridge.OpenWindow !== 'function'
|| typeof bridge.FocusWindow !== 'function'
|| typeof bridge.HideWindow !== 'function'
|| typeof bridge.CloseWindow !== 'function'
|| typeof bridge.CloseOwnedWindows !== 'function'
) {
@@ -41,6 +42,9 @@ func detachedRuntimeBridgeScript() string {
Focus: function (id) {
return bridge.FocusWindow(String(id || ''));
},
Hide: function (id) {
return bridge.HideWindow(String(id || ''));
},
Close: function (id) {
return bridge.CloseWindow(String(id || ''));
},
@@ -103,6 +107,15 @@ func detachedRuntimeBridgeScript() string {
detail: { reason: String(reason || '') }
}));
};
var visibilityRevisionOf = function (command) {
var value = Number(command && command.payload && command.payload.visibilityRevision);
return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0;
};
var requestGracefulHide = function (command) {
window.dispatchEvent(new CustomEvent('` + GracefulHideRequestEventName + `', {
detail: { visibilityRevision: visibilityRevisionOf(command) }
}));
};
var runtime = window.runtime || {};
if (typeof runtime.EventsOnMultiple === 'function') {
@@ -111,8 +124,14 @@ func detachedRuntimeBridgeScript() string {
if (!command || String(command.id || '') !== windowID) return;
if (command.action === 'close') {
requestGracefulClose(command.reason);
} else if (command.action === 'focus' && control && typeof control.Focus === 'function') {
control.Focus();
} else if (command.action === 'hide') {
requestGracefulHide(command);
} else if (command.action === 'focus' && control) {
if (typeof control.FocusRevision === 'function') {
control.FocusRevision(visibilityRevisionOf(command));
} else if (typeof control.Focus === 'function') {
control.Focus();
}
}
});
}, -1);

View File

@@ -28,6 +28,7 @@ func TestRuntimeExposesParentWindowManagerInsideDetachedChildren(t *testing.T) {
"Manager: parentWindowManager",
"bridge.OpenWindow(request || {})",
"bridge.FocusWindow",
"bridge.HideWindow",
"bridge.CloseWindow",
} {
if !strings.Contains(script, expected) {
@@ -36,6 +37,23 @@ func TestRuntimeExposesParentWindowManagerInsideDetachedChildren(t *testing.T) {
}
}
func TestRuntimeRoutesRevisionedHideAndFocusCommands(t *testing.T) {
script := detachedRuntimeBridgeScript()
for _, expected := range []string{
GracefulHideRequestEventName,
"requestGracefulHide(command)",
"visibilityRevisionOf(command)",
"control.FocusRevision(visibilityRevisionOf(command))",
} {
if !strings.Contains(script, expected) {
t.Fatalf("runtime bridge is missing revisioned visibility marker %q", expected)
}
}
if strings.Contains(script, "control.Hide(") {
t.Fatal("runtime hide command bypasses the frontend state flush")
}
}
func TestRuntimeRoutesParentCloseThroughGracefulFrontendEvent(t *testing.T) {
script := detachedRuntimeBridgeScript()
for _, expected := range []string{

View File

@@ -25,6 +25,11 @@ const (
// GracefulCloseRequestEventName is dispatched inside a detached WebView so
// React can flush state before the native child process exits.
GracefulCloseRequestEventName = "gonavi:native-detached-request-close"
// GracefulHideRequestEventName asks an already-mounted detached frontend to
// flush its state before parking the native window without exiting its
// process. The visibility revision prevents a late hide from winning over a
// newer focus request.
GracefulHideRequestEventName = "gonavi:native-detached-request-hide"
ExitReasonRequested = "requested"
ExitReasonWindowClosed = "window-closed"
@@ -78,6 +83,7 @@ type WindowInfo struct {
OpenedAt int64 `json:"openedAt"`
Ready bool `json:"ready"`
CloseSent bool `json:"closeSent"`
Hidden bool `json:"hidden,omitempty"`
}
// Bootstrap is fetched by the child after Wails has installed its native
@@ -91,10 +97,11 @@ type Bootstrap struct {
// OperationResult is returned by the Wails-bound Manager commands.
type OperationResult struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
ID string `json:"id,omitempty"`
Bounds *WindowBounds `json:"bounds,omitempty"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
ID string `json:"id,omitempty"`
Bounds *WindowBounds `json:"bounds,omitempty"`
VisibilityRevision uint64 `json:"visibilityRevision,omitempty"`
}
// HostStateRequest carries main-window state that an active detached child

View File

@@ -2,8 +2,11 @@ package nativewindow
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@@ -74,6 +77,152 @@ func TestDetachedChildShowsAfterReadyAndFocusesWithoutShowingAgain(t *testing.T)
}
}
func TestDetachedChildAcknowledgesFocusOnlyAfterDelayedPresentation(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
steps := make([]string, 0, 4)
control.showWindow = func(context.Context) { steps = append(steps, "show") }
control.focusWindow = func(context.Context) { steps = append(steps, "focus") }
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Path {
case CommandStatePath:
var acknowledgement commandStateRequest
if err := json.NewDecoder(request.Body).Decode(&acknowledgement); err != nil {
return nil, err
}
steps = append(steps, "ack-focus")
if acknowledgement.Action != "ack-focus" || acknowledgement.VisibilityRevision != 7 {
t.Fatalf("focus acknowledgement = %#v", acknowledgement)
}
case ActionPath:
steps = append(steps, "post-ready")
}
return successfulVisibilityResponse(), nil
})
if result := control.FocusRevision(7); !result.Success {
t.Fatalf("pre-ready FocusRevision result = %#v", result)
}
if len(steps) != 0 {
t.Fatalf("pre-ready steps = %#v, want none", steps)
}
control.markDOMReady(ctx)
if len(steps) != 0 {
t.Fatalf("DOM-ready steps = %#v, want none before frontend presentation", steps)
}
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if got := strings.Join(steps, ","); got != "show,focus,ack-focus,post-ready" {
t.Fatalf("delayed focus sequence = %q", got)
}
}
func TestDetachedChildFailedFocusAcknowledgementLeavesParentPendingForRetry(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true},
visibilityRevision: 7,
pendingFocusRevision: 7,
}
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: manager.token,
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
focuses := 0
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
failAcknowledgement := true
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if failAcknowledgement {
return nil, errors.New("temporary parent connection failure")
}
request.RemoteAddr = "127.0.0.1:51003"
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
return recorder.Result(), nil
})
if result := control.FocusRevision(7); !result.Success {
t.Fatalf("FocusRevision with failed acknowledgement = %#v", result)
}
manager.mu.RLock()
pendingAfterFailure := manager.windows["ai-chat"].pendingFocusRevision
manager.mu.RUnlock()
if pendingAfterFailure != 7 {
t.Fatalf("pending focus after failed acknowledgement = %d, want 7", pendingAfterFailure)
}
failAcknowledgement = false
if result := control.FocusRevision(7); !result.Success {
t.Fatalf("FocusRevision retry result = %#v", result)
}
manager.mu.RLock()
pendingAfterRetry := manager.windows["ai-chat"].pendingFocusRevision
manager.mu.RUnlock()
if pendingAfterRetry != 0 || focuses != 2 {
t.Fatalf("retry state = pending %d focuses %d, want 0/2", pendingAfterRetry, focuses)
}
}
func TestDetachedChildIgnoresLateHideAfterNewerFocus(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
hides := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.hideWindow = func(context.Context) { hides++ }
control.focusWindow = func(context.Context) { focuses++ }
control.markDOMReady(ctx)
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
t.Fatalf("ready Action result = %#v", result)
}
if result := control.Hide(1); !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("Hide result = %#v", result)
}
if hides != 1 {
t.Fatalf("hides after revision 1 = %d, want 1", hides)
}
if result := control.FocusRevision(2); !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("FocusRevision result = %#v", result)
}
if shows != 2 || focuses != 1 {
t.Fatalf("presentation after focus = show %d focus %d, want 2/1", shows, focuses)
}
lateHide := control.Hide(1)
if !lateHide.Success || lateHide.VisibilityRevision != 2 ||
!strings.Contains(lateHide.Message, "stale") {
t.Fatalf("late Hide result = %#v", lateHide)
}
if hides != 1 {
t.Fatalf("late hide reached native window: hides = %d, want 1", hides)
}
control.mu.RLock()
visible := control.visible
revision := control.visibilityRevision
control.mu.RUnlock()
if !visible || revision != 2 {
t.Fatalf("final visibility state = visible %v revision %d", visible, revision)
}
}
func TestDetachedChildPresentsBeforePaintReadyWithoutShowingTwice(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()