🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复

- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件
- 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡
- 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点
- 拒绝复用终态子进程,并恢复失败的附加与关闭操作
- 串行展示、隐藏和终态退出,消除关闭门闩并发竞态
- 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
This commit is contained in:
Syngnat
2026-07-22 18:45:55 +08:00
parent 75749d2465
commit c19fcdd183
19 changed files with 2256 additions and 153 deletions

View File

@@ -180,13 +180,17 @@ func (b *Bridge) control(request controlRequest) OperationResult {
// 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 {
return b.action(action, payload, true)
}
func (b *Bridge) action(action string, payload any, grantForeground bool) OperationResult {
normalizedAction := strings.ToLower(strings.TrimSpace(action))
if normalizedAction == "ready" {
if result := b.presentFrontendReady(); !result.Success {
return result
}
}
if normalizedAction == "open-ai-settings" && b.allowParentForeground != nil {
if grantForeground && 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.
@@ -201,10 +205,13 @@ func (b *Bridge) Action(action string, payload any) OperationResult {
if status != http.StatusOK {
return operationFailure(fmt.Sprintf("detached action failed with status %d", status))
}
if result.Success {
if result.Success && (result.Applied == nil || *result.Applied) {
b.mu.Lock()
if normalizedAction == "attach" || normalizedAction == "close" {
switch normalizedAction {
case "attach", "close":
b.terminal = normalizedAction
case "cancel-close":
b.terminal = ""
}
b.mu.Unlock()
}
@@ -520,6 +527,7 @@ func (b *Bridge) emitRuntimeEvent(name string, args ...any) {
// Wails window.
type Control struct {
mu sync.RWMutex
visibilityOpMu sync.Mutex
ctx context.Context
bridge *Bridge
closeGate closeGate
@@ -581,6 +589,7 @@ func (c *Control) markDOMReady(ctx context.Context) {
if c == nil {
return
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.ctx == nil {
c.ctx = ctx
@@ -588,22 +597,29 @@ func (c *Control) markDOMReady(ctx context.Context) {
c.domReady = true
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
}
func (c *Control) markFrontendReady() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.closeCommitted {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window close is already committed")
}
if !c.domReady || c.ctx == nil || c.showWindow == nil {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window DOM is not ready")
}
c.frontendReady = true
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true}
}
@@ -614,13 +630,21 @@ func (c *Control) Present() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.closeCommitted {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window close is already committed")
}
if !c.domReady || c.ctx == nil || c.showWindow == nil {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window DOM is not ready")
}
if c.visible {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return OperationResult{Success: true}
}
c.visible = true
@@ -633,7 +657,7 @@ func (c *Control) Present() OperationResult {
presentation.bridge = c.bridge
}
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true}
}
@@ -645,23 +669,37 @@ type childWindowPresentation struct {
visibilityRevision uint64
}
func (p childWindowPresentation) run() {
func (p childWindowPresentation) runNative() {
if p.show != nil {
p.show(p.ctx)
}
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)
}
}
}
func (p childWindowPresentation) acknowledgeFocus() {
if p.focus == nil || p.bridge == nil || p.visibilityRevision == 0 {
return
}
// 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)
}
// runVisibilityPresentationLocked keeps state selection and native effects in
// one visibility operation, then releases the lock before parent RPC.
func (c *Control) runVisibilityPresentationLocked(presentation childWindowPresentation) {
func() {
defer c.visibilityOpMu.Unlock()
presentation.runNative()
}()
presentation.acknowledgeFocus()
}
func (c *Control) takeInitialPresentationLocked() childWindowPresentation {
if c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
if c.closeCommitted || c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
return childWindowPresentation{}
}
c.visible = true
@@ -680,6 +718,8 @@ func (c *Control) Close() OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
defer c.visibilityOpMu.Unlock()
c.mu.Lock()
ctx := c.ctx
quit := c.quit
@@ -705,6 +745,8 @@ func (c *Control) Hide(visibilityRevision uint64) OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
defer c.visibilityOpMu.Unlock()
c.mu.Lock()
if visibilityRevision < c.visibilityRevision {
currentRevision := c.visibilityRevision
@@ -736,6 +778,53 @@ func (c *Control) Hide(visibilityRevision uint64) OperationResult {
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}
// HideForAISettings parks the child before asking the parent to render its
// settings modal. The request runs in Go after WindowHide, so WebView suspension
// cannot leave the modal behind the detached window.
func (c *Control) HideForAISettings(visibilityRevision uint64) OperationResult {
if c == nil || c.bridge == nil {
return operationFailure("native window control is unavailable")
}
bridge := c.bridge
if bridge.allowParentForeground != nil {
// Windows requires the currently foreground child to grant activation to
// its parent before the child is hidden.
_ = bridge.allowParentForeground()
}
hideResult := c.Hide(visibilityRevision)
if !hideResult.Success {
return hideResult
}
if hideResult.VisibilityRevision != visibilityRevision {
failure := operationFailure("open AI settings was superseded by a newer window focus")
failure.VisibilityRevision = hideResult.VisibilityRevision
return failure
}
actionResult := bridge.action("open-ai-settings", map[string]any{
"id": bridge.windowID,
"kind": bridge.kind,
"visibilityRevision": visibilityRevision,
}, false)
if actionResult.Success && (actionResult.Applied == nil || *actionResult.Applied) {
return OperationResult{
Success: true,
ID: bridge.windowID,
VisibilityRevision: visibilityRevision,
}
}
// Do not strand the user in a hidden child when the parent request fails.
restoreResult := bridge.FocusWindow(bridge.windowID)
if restoreResult.Success {
_ = c.FocusRevision(restoreResult.VisibilityRevision)
} else {
_ = c.FocusRevision(visibilityRevision)
}
failure := operationFailure(fmt.Sprintf("open AI settings failed: %s", actionResult.Message))
failure.VisibilityRevision = visibilityRevision
return failure
}
// 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.
@@ -799,34 +888,42 @@ func (c *Control) scheduleCloseFallback(fallbackCtx context.Context) {
c.closeFallbackGeneration++
generation := c.closeFallbackGeneration
c.closeFallback = time.AfterFunc(delay, func() {
c.mu.Lock()
if c.closeCommitted ||
c.closeFallbackGeneration != generation ||
c.closeGate.isAllowed() {
c.closeFallback = nil
c.mu.Unlock()
return
}
c.closeFallback = nil
c.closeCommitted = true
ctx := c.ctx
quit := c.quit
c.mu.Unlock()
c.closeGate.allow()
if c.bridge != nil {
c.bridge.notifyClosing()
}
if ctx == nil {
ctx = fallbackCtx
}
if ctx != nil && quit != nil {
quit(ctx)
}
c.runCloseFallback(generation, fallbackCtx)
})
c.mu.Unlock()
}
func (c *Control) runCloseFallback(generation uint64, fallbackCtx context.Context) {
c.visibilityOpMu.Lock()
defer c.visibilityOpMu.Unlock()
c.mu.Lock()
if c.closeFallbackGeneration != generation {
c.mu.Unlock()
return
}
if c.closeCommitted || c.closeGate.isAllowed() {
c.closeFallback = nil
c.mu.Unlock()
return
}
c.closeFallback = nil
c.closeCommitted = true
ctx := c.ctx
quit := c.quit
c.mu.Unlock()
c.closeGate.allow()
if c.bridge != nil {
c.bridge.notifyClosing()
}
if ctx == nil {
ctx = fallbackCtx
}
if ctx != nil && quit != nil {
quit(ctx)
}
}
func (c *Control) invalidateCloseFallbackLocked() {
c.closeFallbackGeneration++
if c.closeFallback != nil {
@@ -851,10 +948,17 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
if c == nil {
return operationFailure("native window control is unavailable")
}
c.visibilityOpMu.Lock()
c.mu.Lock()
if c.closeCommitted {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window close is already committed")
}
if visibilityRevision < c.visibilityRevision {
currentRevision := c.visibilityRevision
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return OperationResult{
Success: true,
Message: "stale native window focus ignored",
@@ -866,6 +970,7 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
focus := c.focusWindow
if ctx == nil || focus == nil {
c.mu.Unlock()
c.visibilityOpMu.Unlock()
return operationFailure("native window is not ready")
}
if !c.visible {
@@ -873,7 +978,7 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
c.focusPendingRevision = visibilityRevision
presentation := c.takeInitialPresentationLocked()
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}
c.focusPending = false
@@ -885,6 +990,6 @@ func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult {
visibilityRevision: visibilityRevision,
}
c.mu.Unlock()
presentation.run()
c.runVisibilityPresentationLocked(presentation)
return OperationResult{Success: true, VisibilityRevision: visibilityRevision}
}

View File

@@ -156,6 +156,96 @@ func TestControlCancelCloseInvalidatesFallbackAndAllowsRetry(t *testing.T) {
}
}
func TestBridgeCancelCloseResetsTerminalFallback(t *testing.T) {
requests := make(chan actionRequest, 3)
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
var request actionRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode fallback action: %v", err)
}
requests <- request
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"ai-chat"}`)),
Header: make(http.Header),
}, nil
})
control := newControl(bridge)
if result := bridge.Action("attach", map[string]any{"revision": 1}); !result.Success {
t.Fatalf("attach Action result = %#v", result)
}
if result := bridge.Action("cancel-close", map[string]any{"revision": 2}); !result.Success {
t.Fatalf("cancel-close Action result = %#v", result)
}
if result := control.CancelClose(); !result.Success {
t.Fatalf("CancelClose result = %#v", result)
}
bridge.notifyClosing()
for _, expectedAction := range []string{"attach", "cancel-close", "close"} {
select {
case request := <-requests:
if request.Action != expectedAction {
t.Fatalf("action = %q, want %q", request.Action, expectedAction)
}
case <-time.After(time.Second):
t.Fatalf("missing %q action after terminal rollback", expectedAction)
}
}
}
func TestBridgeIgnoredTerminalActionDoesNotSuppressCloseFallback(t *testing.T) {
requests := make(chan actionRequest, 2)
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
defer r.Body.Close()
var request actionRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Errorf("decode action: %v", err)
}
requests <- request
body := `{"success":true,"id":"ai-chat"}`
if request.Action == "attach" {
body = `{"success":true,"applied":false,"message":"stale detached action ignored","id":"ai-chat"}`
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}, nil
})
result := bridge.Action("attach", map[string]any{"revision": 1})
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("ignored attach result = %#v", result)
}
bridge.notifyClosing()
for _, expectedAction := range []string{"attach", "close"} {
select {
case request := <-requests:
if request.Action != expectedAction {
t.Fatalf("action = %q, want %q", request.Action, expectedAction)
}
case <-time.After(time.Second):
t.Fatalf("missing %q action after ignored terminal action", expectedAction)
}
}
}
func TestNotifyClosingStillSendsOneFallbackAction(t *testing.T) {
requests := make(chan actionRequest, 2)
bridge := newBridge(ChildOptions{

View File

@@ -1,13 +1,201 @@
package nativewindow
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestControlHidesBeforeOpeningAISettingsInParent(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
InitializeControl(control, context.Background())
steps := make([]string, 0, 3)
bridge.allowParentForeground = func() error {
steps = append(steps, "allow-parent-foreground")
return nil
}
control.hideWindow = func(context.Context) {
steps = append(steps, "hide-window")
}
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path != ActionPath {
t.Fatalf("unexpected request path %q", request.URL.Path)
}
steps = append(steps, "post-action")
return successfulForegroundActionResponse(), nil
})
result := control.HideForAISettings(7)
if !result.Success || result.VisibilityRevision != 7 {
t.Fatalf("HideForAISettings result = %#v", result)
}
if got := strings.Join(steps, ","); got != "allow-parent-foreground,hide-window,post-action" {
t.Fatalf("HideForAISettings sequence = %q", got)
}
}
func TestControlDoesNotOpenAISettingsAfterHideIsSupersededByFocus(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
InitializeControl(control, context.Background())
control.visibilityRevision = 8
control.visible = true
hides := 0
posts := 0
control.hideWindow = func(context.Context) { hides++ }
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
posts++
return successfulForegroundActionResponse(), nil
})
result := control.HideForAISettings(7)
if result.Success || !strings.Contains(result.Message, "superseded") {
t.Fatalf("stale HideForAISettings result = %#v", result)
}
if hides != 0 || posts != 0 {
t.Fatalf("stale settings action reached native/parent: hides=%d posts=%d", hides, posts)
}
}
func TestControlRestoresAIWindowWhenOpeningSettingsFails(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: "test-token",
ID: "ai-chat",
Kind: "ai-chat",
})
control := newControl(bridge)
ctx := context.Background()
InitializeControl(control, ctx)
steps := make([]string, 0, 8)
control.showWindow = func(context.Context) { steps = append(steps, "show-window") }
control.hideWindow = func(context.Context) { steps = append(steps, "hide-window") }
control.focusWindow = func(context.Context) { steps = append(steps, "focus-window") }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
steps = steps[:0]
bridge.allowParentForeground = func() error {
steps = append(steps, "allow-parent-foreground")
return nil
}
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
switch request.URL.Path {
case ActionPath:
steps = append(steps, "post-action")
return nil, errors.New("parent unavailable")
case ControlPath:
steps = append(steps, "focus-parent")
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"success":true,"id":"ai-chat","visibilityRevision":8}`,
)),
Header: make(http.Header),
}, nil
case CommandStatePath:
steps = append(steps, "ack-focus")
return successfulForegroundActionResponse(), nil
default:
t.Fatalf("unexpected request path %q", request.URL.Path)
return nil, nil
}
})
result := control.HideForAISettings(7)
if result.Success || !strings.Contains(result.Message, "parent unavailable") {
t.Fatalf("failed HideForAISettings result = %#v", result)
}
if got := strings.Join(steps, ","); got != "allow-parent-foreground,hide-window,post-action,focus-parent,show-window,focus-window,ack-focus" {
t.Fatalf("failed HideForAISettings recovery sequence = %q", got)
}
control.mu.RLock()
visible := control.visible
revision := control.visibilityRevision
control.mu.RUnlock()
if !visible || revision != 8 {
t.Fatalf("restored visibility = visible %v revision %d, want true/8", visible, revision)
}
}
func TestControlRestoresParentAndChildVisibilityThroughAuthenticatedSelfFocus(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{
ID: "ai-chat",
Kind: "ai-chat",
Title: "GoNavi AI",
Hidden: true,
},
visibilityRevision: 8,
}
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",
Token: manager.token,
ID: "ai-chat",
Kind: "ai-chat",
})
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
request.RemoteAddr = "127.0.0.1:51003"
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
return recorder.Result(), nil
})
control := newControl(bridge)
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
control.hideWindow = func(context.Context) {}
control.focusWindow = func(context.Context) {}
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
result := control.HideForAISettings(7)
if result.Success || !strings.Contains(result.Message, "ignored after a newer visibility action") {
t.Fatalf("superseded HideForAISettings result = %#v", result)
}
manager.mu.RLock()
managerEntry := manager.windows["ai-chat"]
managerHidden := managerEntry.info.Hidden
managerRevision := managerEntry.visibilityRevision
pendingFocusRevision := managerEntry.pendingFocusRevision
manager.mu.RUnlock()
if managerHidden || managerRevision != 9 || pendingFocusRevision != 0 {
t.Fatalf(
"restored manager visibility = hidden %v revision %d pending %d, want false/9/0",
managerHidden,
managerRevision,
pendingFocusRevision,
)
}
control.mu.RLock()
childVisible := control.visible
childRevision := control.visibilityRevision
control.mu.RUnlock()
if !childVisible || childRevision != 9 {
t.Fatalf("restored child visibility = visible %v revision %d, want true/9", childVisible, childRevision)
}
}
func TestBridgeAllowsParentForegroundImmediatelyBeforeOpeningAISettings(t *testing.T) {
bridge := newBridge(ChildOptions{
ParentURL: "http://127.0.0.1:43119",

View File

@@ -41,6 +41,7 @@ const (
const (
defaultGracefulCloseTimeout = 10 * time.Second
defaultOpenReadyTimeout = 10 * time.Second
staleDetachedActionMessage = "stale detached action ignored"
)
type processExit struct {
@@ -295,7 +296,7 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
return operationFailure("native window manager is not running")
}
if existing, exists := m.windows[request.ID]; exists {
if existing.info.CloseSent {
if windowEntryIsTerminating(existing) {
m.mu.Unlock()
return closingWindowRetryFailure(request.ID)
}
@@ -422,7 +423,7 @@ func (m *Manager) Focus(id string) OperationResult {
m.mu.Unlock()
return operationFailure("native window was not found")
}
if entry.info.CloseSent {
if windowEntryIsTerminating(entry) {
m.mu.Unlock()
return closingWindowRetryFailure(id)
}
@@ -434,6 +435,7 @@ func (m *Manager) Focus(id string) OperationResult {
entry.info.Hidden = false
entry.pendingFocusRevision = entry.visibilityRevision
visibilityRevision := entry.visibilityRevision
kind := entry.info.Kind
bounds := windowBoundsFromInfo(entry.info)
emitToChild := m.emitToChild
shared := m.shared
@@ -449,6 +451,14 @@ func (m *Manager) Focus(id string) OperationResult {
shared.EmitTo(id, CommandEventName, command)
}
if wasHidden {
m.emitDetached(Event{
ID: id,
Kind: kind,
Action: "focus",
Payload: visibilityCommandPayload{
VisibilityRevision: visibilityRevision,
},
})
publishDetachedDockMenuSnapshot(m)
}
return OperationResult{
@@ -591,6 +601,10 @@ func (m *Manager) CancelClose(id string) OperationResult {
m.mu.Unlock()
return operationFailure("native window was not found")
}
if m.closing || entry.exitReason == ExitReasonParentShutdown {
m.mu.Unlock()
return operationFailure("native window manager shutdown cannot be cancelled")
}
m.cancelCloseLocked(entry)
m.mu.Unlock()
publishDetachedDockMenuSnapshot(m)
@@ -601,11 +615,36 @@ func (m *Manager) cancelCloseLocked(entry *windowEntry) {
entry.closeGeneration++
entry.info.CloseSent = false
switch entry.exitReason {
case ExitReasonRequested, ExitReasonParentShutdown, ExitReasonAttached, ExitReasonWindowClosed:
case ExitReasonRequested, ExitReasonAttached, ExitReasonWindowClosed:
entry.exitReason = ""
}
}
func (m *Manager) cancelCloseActionLocked(entry *windowEntry, rollbackAction string) bool {
if m.closing || entry == nil || entry.exitReason == ExitReasonParentShutdown {
return false
}
switch rollbackAction {
case "attach":
if entry.info.CloseSent || (entry.exitReason != "" && entry.exitReason != ExitReasonAttached) {
return false
}
case "close":
validRequestedClose := entry.info.CloseSent && entry.exitReason == ExitReasonRequested
validWindowClose := !entry.info.CloseSent && entry.exitReason == ExitReasonWindowClosed
alreadyOpen := !entry.info.CloseSent && entry.exitReason == ""
if !validRequestedClose && !validWindowClose && !alreadyOpen {
return false
}
case "":
// Compatibility with children started before rollbackAction was added.
default:
return false
}
m.cancelCloseLocked(entry)
return true
}
// CloseAll requests graceful shutdown of every detached child.
func (m *Manager) CloseAll() OperationResult {
if m == nil {
@@ -831,6 +870,10 @@ func operationFailure(message string) OperationResult {
return OperationResult{Success: false, Message: message}
}
func windowEntryIsTerminating(entry *windowEntry) bool {
return entry != nil && (entry.info.CloseSent || entry.exitReason != "")
}
func closingWindowRetryFailure(id string) OperationResult {
return OperationResult{
Success: false,
@@ -993,7 +1036,13 @@ func (m *Manager) handleBootstrap(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown detached window", http.StatusNotFound)
return
}
bootstrap := Bootstrap{ID: entry.info.ID, Kind: entry.info.Kind, Title: entry.info.Title, Payload: entry.payload}
bootstrap := Bootstrap{
ID: entry.info.ID,
Kind: entry.info.Kind,
Title: entry.info.Title,
Payload: entry.payload,
ActionRevision: entry.actionRevision,
}
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
@@ -1146,6 +1195,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
revision := positiveActionRevision(request.Payload)
requestedAISettingsVisibilityRevision := uint64(0)
m.mu.Lock()
entry, exists := m.windows[id]
if !exists {
@@ -1153,6 +1203,37 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unknown detached window", http.StatusNotFound)
return
}
if request.Action == "open-ai-settings" {
requestedAISettingsVisibilityRevision = positiveVisibilityRevision(request.Payload)
if requestedAISettingsVisibilityRevision == 0 ||
requestedAISettingsVisibilityRevision != entry.visibilityRevision ||
!entry.info.Hidden {
visibilityRevision := entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "open AI settings ignored after a newer visibility action",
VisibilityRevision: visibilityRevision,
})
return
}
}
if request.Action == "cancel-close" && (m.closing || entry.exitReason == ExitReasonParentShutdown) {
visibilityRevision := entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "detached close cancellation ignored while parent is closing",
VisibilityRevision: visibilityRevision,
})
return
}
if actionUsesRevision(request.Action) && revision > 0 {
if revision <= entry.actionRevision {
visibilityRevision := entry.visibilityRevision
@@ -1160,8 +1241,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "stale detached action ignored",
Message: staleDetachedActionMessage,
VisibilityRevision: visibilityRevision,
})
return
@@ -1169,7 +1251,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
entry.actionRevision = revision
}
eventAction := request.Action
visibilityRevision := uint64(0)
visibilityRevision := requestedAISettingsVisibilityRevision
if request.Action == "ready" {
entry.info.Ready = true
entry.readyOnce.Do(func() {
@@ -1178,7 +1260,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
}
})
} else if request.Action == "attach" {
entry.exitReason = ExitReasonAttached
if entry.exitReason == "" {
entry.exitReason = ExitReasonAttached
}
entry.pendingFocusRevision = 0
} else if request.Action == "close" && entry.exitReason == "" {
entry.exitReason = ExitReasonWindowClosed
@@ -1206,7 +1290,19 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
}
request.Payload = withVisibilityRevision(request.Payload, visibilityRevision)
} else if request.Action == "cancel-close" {
m.cancelCloseLocked(entry)
if !m.cancelCloseActionLocked(entry, rollbackActionFromPayload(request.Payload)) {
visibilityRevision = entry.visibilityRevision
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: operationApplied(false),
ID: id,
Message: "detached close cancellation no longer matches the active close",
VisibilityRevision: visibilityRevision,
})
return
}
}
info := entry.info
ownerID := entry.ownerID
@@ -1224,15 +1320,40 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
applied := revisionedActionApplied(request.Action, revision)
if request.Action == "open-ai-settings" {
applied = operationApplied(true)
}
_ = json.NewEncoder(w).Encode(OperationResult{
Success: true,
Applied: applied,
ID: id,
VisibilityRevision: visibilityRevision,
})
}
func actionUsesRevision(action string) bool {
return action == "sync" || action == "attach" || action == "close" || action == "hide"
return action == "sync" || action == "attach" || action == "close" || action == "hide" || action == "cancel-close"
}
func operationApplied(value bool) *bool {
return &value
}
func revisionedActionApplied(action string, revision int64) *bool {
if !actionUsesRevision(action) || revision <= 0 {
return nil
}
return operationApplied(true)
}
func rollbackActionFromPayload(payload any) string {
record, ok := payload.(map[string]any)
if !ok {
return ""
}
rollbackAction, _ := record["rollbackAction"].(string)
return strings.ToLower(strings.TrimSpace(rollbackAction))
}
func positiveVisibilityRevision(payload any) uint64 {
@@ -1355,7 +1476,7 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
})
}
case "focus":
if !m.ownsWindow(request.ID, ownerID) {
if !m.canFocusWindow(request.ID, ownerID) {
result = operationFailure("native window is not owned by this window")
break
}
@@ -1429,6 +1550,18 @@ func (m *Manager) ownsWindow(id string, ownerID string) bool {
return exists && entry.ownerID == strings.TrimSpace(ownerID)
}
func (m *Manager) canFocusWindow(id string, ownerID string) bool {
id = strings.TrimSpace(id)
ownerID = strings.TrimSpace(ownerID)
if id == "" || ownerID == "" {
return false
}
m.mu.RLock()
defer m.mu.RUnlock()
entry, exists := m.windows[id]
return exists && (id == ownerID || entry.ownerID == ownerID)
}
func (m *Manager) closeOwned(ownerID string) OperationResult {
ownerID = strings.TrimSpace(ownerID)
if ownerID == "" {

View File

@@ -486,6 +486,13 @@ func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T)
},
}
commands := make(chan childCommand, 3)
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)
}
}
manager.emitToChild = func(targetID string, name string, args ...any) {
if targetID != "ai-chat" || name != CommandEventName {
t.Fatalf("unexpected target event %q %q", targetID, name)
@@ -522,6 +529,11 @@ func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T)
focusCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 2 {
t.Fatalf("focus command = %#v", focusCommand)
}
focusEvent := receiveEvent(t, events)
if focusEvent.ID != "ai-chat" || focusEvent.Kind != "ai-chat" || focusEvent.Action != "focus" ||
positiveVisibilityRevision(focusEvent.Payload) != 2 {
t.Fatalf("focus lifecycle event = %#v", focusEvent)
}
manager.mu.RLock()
hidden := manager.windows["ai-chat"].info.Hidden
manager.mu.RUnlock()
@@ -762,6 +774,10 @@ func TestStaleHideActionCannotOverrideNewerFocus(t *testing.T) {
if result := manager.Focus("ai-chat"); !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("Focus result = %#v", result)
}
focusEvent := receiveEvent(t, events)
if focusEvent.Action != "focus" || positiveVisibilityRevision(focusEvent.Payload) != 2 {
t.Fatalf("Focus event = %#v", focusEvent)
}
body := strings.NewReader(
`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":2,"visibilityRevision":1}}`,
)
@@ -824,9 +840,10 @@ func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) {
func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["window-1"] = &windowEntry{
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
payload: map[string]any{"value": "shared"},
ready: make(chan struct{}),
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
payload: map[string]any{"value": "shared"},
actionRevision: 17,
ready: make(chan struct{}),
}
handler := manager.authenticatedHandler()
@@ -862,7 +879,9 @@ func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing
if validRecorder.Code != http.StatusOK {
t.Fatalf("valid bootstrap status = %d body=%s", validRecorder.Code, validRecorder.Body.String())
}
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) || !strings.Contains(validRecorder.Body.String(), `"value":"shared"`) {
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) ||
!strings.Contains(validRecorder.Body.String(), `"value":"shared"`) ||
!strings.Contains(validRecorder.Body.String(), `"actionRevision":17`) {
t.Fatalf("unexpected bootstrap body: %s", validRecorder.Body.String())
}
select {
@@ -888,8 +907,9 @@ func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing
func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
ready: make(chan struct{}),
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI", Hidden: true},
ready: make(chan struct{}),
visibilityRevision: 7,
}
events := make(chan Event, 1)
manager.runtimeCtx = context.Background()
@@ -899,7 +919,7 @@ func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
}
}
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat"}}`)
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat","visibilityRevision":7}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
@@ -918,6 +938,50 @@ func TestOpenAISettingsActionIsForwardedWithoutClosingTheChild(t *testing.T) {
}
}
func TestOpenAISettingsActionIsIgnoredAfterANewerFocus(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Hidden: true},
ready: make(chan struct{}),
visibilityRevision: 7,
}
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)
}
}
focusResult := manager.Focus("ai-chat")
if !focusResult.Success || focusResult.VisibilityRevision != 8 {
t.Fatalf("Focus result = %#v", focusResult)
}
focusEvent := receiveEvent(t, events)
if focusEvent.Action != "focus" || positiveVisibilityRevision(focusEvent.Payload) != 8 {
t.Fatalf("Focus event = %#v", focusEvent)
}
body := strings.NewReader(`{"action":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat","visibilityRevision":7}}`)
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 open-ai-settings 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 stale open-ai-settings result: %v", err)
}
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("stale open-ai-settings result = %#v", result)
}
select {
case event := <-events:
t.Fatalf("stale open-ai-settings emitted event: %#v", event)
default:
}
}
func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
manager := newHTTPTestManager(t)
manager.started = true
@@ -1240,6 +1304,90 @@ func TestCancelCloseActionClearsPendingStateAndNotifiesMainWindow(t *testing.T)
}
}
func TestCancelCloseActionCannotRollbackNewerTerminalState(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["workbench:query-1"] = &windowEntry{
info: WindowInfo{ID: "workbench:query-1", Kind: "workbench", CloseSent: true},
exitReason: ExitReasonRequested,
actionRevision: 12,
closeGeneration: 4,
}
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)
}
}
body := strings.NewReader(`{"action":"cancel-close","payload":{"id":"workbench:query-1","revision":11,"rollbackAction":"attach"}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "workbench:query-1", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("stale cancel-close 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 stale cancel-close result: %v", err)
}
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("stale cancel-close result = %#v, want ignored success", result)
}
manager.mu.RLock()
entry := manager.windows["workbench:query-1"]
closeSent := entry.info.CloseSent
exitReason := entry.exitReason
revision := entry.actionRevision
closeGeneration := entry.closeGeneration
manager.mu.RUnlock()
if !closeSent || exitReason != ExitReasonRequested || revision != 12 || closeGeneration != 4 {
t.Fatalf(
"stale cancel changed state: closeSent=%v reason=%q revision=%d generation=%d",
closeSent,
exitReason,
revision,
closeGeneration,
)
}
select {
case event := <-events:
t.Fatalf("stale cancel emitted event: %#v", event)
case <-time.After(25 * time.Millisecond):
}
}
func TestCancelCloseActionCannotCancelParentShutdown(t *testing.T) {
manager := newHTTPTestManager(t)
manager.closing = true
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", CloseSent: true},
exitReason: ExitReasonParentShutdown,
actionRevision: 8,
closeGeneration: 5,
}
body := strings.NewReader(`{"action":"cancel-close","payload":{"id":"ai-chat","revision":9,"rollbackAction":"close"}}`)
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("shutdown cancel-close 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 shutdown cancel-close result: %v", err)
}
if !result.Success || result.Applied == nil || *result.Applied {
t.Fatalf("shutdown cancel-close result = %#v, want ignored success", result)
}
entry := manager.windows["ai-chat"]
if !entry.info.CloseSent || entry.exitReason != ExitReasonParentShutdown ||
entry.actionRevision != 8 || entry.closeGeneration != 5 {
t.Fatalf("shutdown cancellation changed entry: %#v", entry)
}
}
func TestHostEventActionIsForwardedWithoutChangingTerminalState(t *testing.T) {
manager := newHTTPTestManager(t)
manager.windows["ai-chat"] = &windowEntry{
@@ -1407,6 +1555,37 @@ func TestActionRevisionPreventsStaleTerminalTransition(t *testing.T) {
}
}
func TestManagerDoesNotReuseChildAfterTerminalActionIsAccepted(t *testing.T) {
for _, action := range []string{"attach", "close"} {
t.Run(action, func(t *testing.T) {
manager := newHTTPTestManager(t)
manager.started = true
manager.endpoint = "http://127.0.0.1:43119"
manager.windows["ai-chat"] = &windowEntry{
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
}
body := strings.NewReader(fmt.Sprintf(
`{"action":%q,"payload":{"revision":1}}`,
action,
))
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
recorder := httptest.NewRecorder()
manager.authenticatedHandler().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("%s action status = %d body=%s", action, recorder.Code, recorder.Body.String())
}
if result := manager.Focus("ai-chat"); result.Success || !strings.Contains(result.Message, "retry") {
t.Errorf("Focus after %s result = %#v, want retry failure", action, result)
}
if result := manager.Open(OpenRequest{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"}); result.Success || !strings.Contains(result.Message, "retry") {
t.Errorf("Open after %s result = %#v, want retry failure", action, result)
}
})
}
}
func TestManagerShutdownAllowsGracefulChildExitBeforeKilling(t *testing.T) {
manager := newHTTPTestManager(t)
manager.shutdownGracePeriod = 250 * time.Millisecond

View File

@@ -89,10 +89,11 @@ type WindowInfo struct {
// Bootstrap is fetched by the child after Wails has installed its native
// runtime and bindings.
type Bootstrap struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Payload any `json:"payload,omitempty"`
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Payload any `json:"payload,omitempty"`
ActionRevision int64 `json:"actionRevision,omitempty"`
}
// OperationResult is returned by the Wails-bound Manager commands.
@@ -102,6 +103,9 @@ type OperationResult struct {
ID string `json:"id,omitempty"`
Bounds *WindowBounds `json:"bounds,omitempty"`
VisibilityRevision uint64 `json:"visibilityRevision,omitempty"`
// Applied is set only for revisioned child actions. A nil value preserves
// compatibility with ordinary manager/control results and older parents.
Applied *bool `json:"applied,omitempty"`
}
// HostStateRequest carries main-window state that an active detached child

View File

@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestDetachedChildQueuesFocusUntilFrontendReadyHandshake(t *testing.T) {
@@ -223,6 +224,339 @@ func TestDetachedChildIgnoresLateHideAfterNewerFocus(t *testing.T) {
}
}
func TestDetachedChildSerializesNativeHideBeforeNewerFocus(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
steps := make(chan string, 8)
hideStarted := make(chan struct{})
releaseHide := make(chan struct{})
control.showWindow = func(context.Context) { steps <- "show" }
control.hideWindow = func(context.Context) {
steps <- "hide-start"
close(hideStarted)
<-releaseHide
steps <- "hide-end"
}
control.focusWindow = func(context.Context) { steps <- "focus" }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
if step := <-steps; step != "show" {
t.Fatalf("initial presentation step = %q, want show", step)
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case <-hideStarted:
case <-time.After(time.Second):
t.Fatal("native hide did not start")
}
focusCallStarted := make(chan struct{})
focusDone := make(chan OperationResult, 1)
go func() {
close(focusCallStarted)
focusDone <- control.FocusRevision(2)
}()
<-focusCallStarted
select {
case result := <-focusDone:
close(releaseHide)
<-hideDone
t.Fatalf("newer focus completed before the older native hide: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseHide)
if result := <-hideDone; !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("Hide result = %#v", result)
}
if result := <-focusDone; !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("FocusRevision result = %#v", result)
}
sequence := make([]string, 0, 4)
for len(sequence) < 4 {
select {
case step := <-steps:
sequence = append(sequence, step)
case <-time.After(time.Second):
t.Fatalf("native visibility sequence stopped at %#v", sequence)
}
}
if got := strings.Join(sequence, ","); got != "hide-start,hide-end,show,focus" {
t.Fatalf("native visibility sequence = %q", got)
}
}
func TestDetachedChildDoesNotBlockHideWhileAcknowledgingFocus(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.showWindow = func(context.Context) {}
control.focusWindow = func(context.Context) {}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
control.markDOMReady(ctx)
if result := control.markFrontendReady(); !result.Success {
t.Fatalf("markFrontendReady result = %#v", result)
}
acknowledgementStarted := make(chan struct{})
releaseAcknowledgement := make(chan struct{})
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.Path == CommandStatePath {
close(acknowledgementStarted)
<-releaseAcknowledgement
}
return successfulVisibilityResponse(), nil
})
focusDone := make(chan OperationResult, 1)
go func() {
focusDone <- control.FocusRevision(1)
}()
select {
case <-acknowledgementStarted:
case <-time.After(time.Second):
t.Fatal("focus acknowledgement did not start")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(2)
}()
select {
case result := <-hideDone:
if !result.Success || result.VisibilityRevision != 2 {
t.Fatalf("Hide result = %#v", result)
}
case <-time.After(25 * time.Millisecond):
close(releaseAcknowledgement)
<-focusDone
<-hideDone
t.Fatal("native hide waited for the focus acknowledgement network request")
}
select {
case <-hideCalled:
case <-time.After(time.Second):
t.Fatal("native hide callback was not called")
}
close(releaseAcknowledgement)
if result := <-focusDone; !result.Success || result.VisibilityRevision != 1 {
t.Fatalf("FocusRevision result = %#v", result)
}
}
func TestDetachedChildClosePreventsConcurrentHideFromCancellingExit(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
quitStarted := make(chan struct{})
releaseQuit := make(chan struct{})
control.quit = func(context.Context) {
close(quitStarted)
<-releaseQuit
}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
closeDone := make(chan OperationResult, 1)
go func() {
closeDone <- control.Close()
}()
select {
case <-quitStarted:
case <-time.After(time.Second):
t.Fatal("native close did not reach quit")
}
if !control.closeGate.isAllowed() {
t.Fatal("close gate was not opened before quit")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case result := <-hideDone:
close(releaseQuit)
<-closeDone
t.Fatalf("hide completed while native close was in progress: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseQuit)
if result := <-closeDone; !result.Success {
t.Fatalf("Close result = %#v", result)
}
if result := <-hideDone; result.Success || !strings.Contains(result.Message, "already committed") {
t.Fatalf("Hide result after committed close = %#v", result)
}
if !control.closeGate.isAllowed() {
t.Fatal("concurrent hide cancelled the committed close gate")
}
select {
case <-hideCalled:
t.Fatal("concurrent hide reached the native window after close committed")
default:
}
}
func TestDetachedChildCloseFallbackPreventsConcurrentHideFromCancellingExit(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.closeFallbackDelay = time.Millisecond
quitStarted := make(chan struct{})
releaseQuit := make(chan struct{})
control.quit = func(context.Context) {
close(quitStarted)
<-releaseQuit
}
hideCalled := make(chan struct{}, 1)
control.hideWindow = func(context.Context) { hideCalled <- struct{}{} }
control.scheduleCloseFallback(ctx)
select {
case <-quitStarted:
case <-time.After(time.Second):
t.Fatal("native close fallback did not reach quit")
}
if !control.closeGate.isAllowed() {
t.Fatal("close fallback did not open the gate before quit")
}
hideDone := make(chan OperationResult, 1)
go func() {
hideDone <- control.Hide(1)
}()
select {
case result := <-hideDone:
close(releaseQuit)
t.Fatalf("hide completed while native close fallback was in progress: %#v", result)
case <-time.After(25 * time.Millisecond):
}
close(releaseQuit)
if result := <-hideDone; result.Success || !strings.Contains(result.Message, "already committed") {
t.Fatalf("Hide result after fallback committed close = %#v", result)
}
if !control.closeGate.isAllowed() {
t.Fatal("concurrent hide cancelled the fallback close gate")
}
select {
case <-hideCalled:
t.Fatal("concurrent hide reached the native window after fallback close committed")
default:
}
}
func TestDetachedChildStaleCloseFallbackKeepsNewerTimer(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
control.closeFallbackDelay = time.Hour
quits := 0
control.quit = func(context.Context) { quits++ }
control.mu.Lock()
control.closeFallbackGeneration = 1
control.closeFallback = time.AfterFunc(time.Hour, func() {})
control.mu.Unlock()
control.visibilityOpMu.Lock()
staleFallbackDone := make(chan struct{})
go func() {
control.runCloseFallback(1, ctx)
close(staleFallbackDone)
}()
if result := control.CancelClose(); !result.Success {
control.visibilityOpMu.Unlock()
t.Fatalf("CancelClose result = %#v", result)
}
control.scheduleCloseFallback(ctx)
control.mu.RLock()
newGeneration := control.closeFallbackGeneration
newFallback := control.closeFallback
control.mu.RUnlock()
if newGeneration != 3 || newFallback == nil {
control.visibilityOpMu.Unlock()
t.Fatalf("new fallback state = generation %d timer %p, want 3/non-nil", newGeneration, newFallback)
}
control.visibilityOpMu.Unlock()
select {
case <-staleFallbackDone:
case <-time.After(time.Second):
t.Fatal("stale close fallback did not complete")
}
control.mu.RLock()
retainedFallback := control.closeFallback
retainedGeneration := control.closeFallbackGeneration
closeCommitted := control.closeCommitted
control.mu.RUnlock()
if retainedFallback != newFallback || retainedGeneration != newGeneration {
t.Fatalf(
"fallback after stale callback = generation %d timer %p, want %d/%p",
retainedGeneration,
retainedFallback,
newGeneration,
newFallback,
)
}
if closeCommitted || control.closeGate.isAllowed() || quits != 0 {
t.Fatalf(
"stale fallback exit state = committed %v gate %v quits %d, want false/false/0",
closeCommitted,
control.closeGate.isAllowed(),
quits,
)
}
if result := control.CancelClose(); !result.Success {
t.Fatalf("final CancelClose result = %#v", result)
}
}
func TestDetachedChildDoesNotPresentOrFocusAfterCloseCommitted(t *testing.T) {
_, control := newVisibilityTestChild()
ctx := context.Background()
InitializeControl(control, ctx)
shows := 0
focuses := 0
control.showWindow = func(context.Context) { shows++ }
control.focusWindow = func(context.Context) { focuses++ }
control.quit = func(context.Context) {}
control.markDOMReady(ctx)
if result := control.Close(); !result.Success {
t.Fatalf("Close result = %#v", result)
}
if result := control.markFrontendReady(); result.Success {
t.Fatalf("markFrontendReady after close = %#v, want failure", result)
}
if result := control.Present(); result.Success {
t.Fatalf("Present after close = %#v, want failure", result)
}
if result := control.FocusRevision(1); result.Success {
t.Fatalf("FocusRevision after close = %#v, want failure", result)
}
if shows != 0 || focuses != 0 {
t.Fatalf("post-close presentation = show %d focus %d, want 0/0", shows, focuses)
}
}
func TestDetachedChildPresentsBeforePaintReadyWithoutShowingTwice(t *testing.T) {
bridge, control := newVisibilityTestChild()
ctx := context.Background()