mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
⚡️ perf(native-window): 优化 AI 独立窗口启动与交互
- 关闭时隐藏保活,重开复用现有进程并通过可见性版本消除竞态 - 拆分 AI 与工作台资源,使用启动状态白名单减少序列化开销 - 修复设置中心层级、Windows 前台切换及独立窗口关闭交互 - 补齐状态同步、断线重放与窗口生命周期回归测试
This commit is contained in:
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
7
internal/nativewindow/foreground_permission_other.go
Normal file
7
internal/nativewindow/foreground_permission_other.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package nativewindow
|
||||
|
||||
func grantParentForegroundAccess() error {
|
||||
return nil
|
||||
}
|
||||
82
internal/nativewindow/foreground_permission_test.go
Normal file
82
internal/nativewindow/foreground_permission_test.go
Normal 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),
|
||||
}
|
||||
}
|
||||
28
internal/nativewindow/foreground_permission_windows.go
Normal file
28
internal/nativewindow/foreground_permission_windows.go
Normal 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
|
||||
}
|
||||
40
internal/nativewindow/foreground_permission_windows_test.go
Normal file
40
internal/nativewindow/foreground_permission_windows_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user