mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-21 16:34:21 +08:00
✨ feat(native-window): 完善原生独立窗口与跨屏拖拽
- 支持全部工作台标签与 AI 对话在多显示器中原生独立展示 - 修复拖拽指针捕获,恢复关闭、右键菜单和跨窗口释放识别 - 拆分子窗启动入口并按需加载语言与工作台内容 - 内容与 Monaco 完成可见绘制后再提交迁移,消除白屏和加载闪烁 - 加固结果编辑、宿主同步及关闭、超时和焦点竞态
This commit is contained in:
@@ -43,11 +43,14 @@ type Bridge struct {
|
||||
kind string
|
||||
client *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
terminal string
|
||||
closeOnce sync.Once
|
||||
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)
|
||||
}
|
||||
|
||||
func newBridge(options ChildOptions) *Bridge {
|
||||
@@ -65,6 +68,9 @@ func newBridge(options ChildOptions) *Bridge {
|
||||
windowID: options.ID,
|
||||
kind: options.Kind,
|
||||
client: &http.Client{Transport: transport},
|
||||
emitToWails: func(ctx context.Context, name string, args ...any) {
|
||||
wailsRuntime.EventsEmit(ctx, name, args...)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +174,13 @@ func (b *Bridge) control(request controlRequest) OperationResult {
|
||||
// Action acknowledges child readiness or forwards sync, 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" {
|
||||
if result := b.presentFrontendReady(); !result.Success {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
var result OperationResult
|
||||
status, err := b.doJSON(context.Background(), http.MethodPost, ActionPath, actionRequest{Action: action, Payload: payload}, &result)
|
||||
if err != nil {
|
||||
@@ -176,15 +189,58 @@ func (b *Bridge) Action(action string, payload any) OperationResult {
|
||||
if status != http.StatusOK {
|
||||
return operationFailure(fmt.Sprintf("detached action failed with status %d", status))
|
||||
}
|
||||
normalizedAction := strings.ToLower(strings.TrimSpace(action))
|
||||
if result.Success && (normalizedAction == "attach" || normalizedAction == "close") {
|
||||
if result.Success {
|
||||
b.mu.Lock()
|
||||
b.terminal = normalizedAction
|
||||
if normalizedAction == "attach" || normalizedAction == "close" {
|
||||
b.terminal = normalizedAction
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *Bridge) setReadyHandler(handler func() OperationResult) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.onReady = handler
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) presentFrontendReady() OperationResult {
|
||||
if b == nil {
|
||||
return operationFailure("detached bridge is unavailable")
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.ready {
|
||||
b.mu.Unlock()
|
||||
return OperationResult{Success: true, ID: b.windowID}
|
||||
}
|
||||
onReady := b.onReady
|
||||
b.mu.Unlock()
|
||||
if onReady == nil {
|
||||
return operationFailure("native window ready handler is unavailable")
|
||||
}
|
||||
result := onReady()
|
||||
if !result.Success {
|
||||
return result
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.ready = true
|
||||
b.mu.Unlock()
|
||||
return OperationResult{Success: true, ID: b.windowID}
|
||||
}
|
||||
|
||||
func (b *Bridge) frontendReady() bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.ready
|
||||
}
|
||||
|
||||
func (b *Bridge) notifyClosing() {
|
||||
if b == nil {
|
||||
return
|
||||
@@ -304,6 +360,15 @@ func (b *Bridge) consumeEventStream(ctx context.Context) error {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
||||
return fmt.Errorf("detached event stream failed with status %d", response.StatusCode)
|
||||
}
|
||||
if err := b.replayPendingCommand(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// The SSE subscription exists before this GET starts, so a concurrent host
|
||||
// update is either present in the retained response, queued on SSE, or both.
|
||||
// Frontend revision checks make the possible duplicate harmless.
|
||||
if err := b.replayHostState(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(response.Body)
|
||||
scanner.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
@@ -312,44 +377,147 @@ func (b *Bridge) consumeEventStream(ctx context.Context) error {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
if data.Len() > 0 {
|
||||
b.dispatchEvent(data.String())
|
||||
if err := b.dispatchEvent(ctx, data.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
data.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
if data.Len() > 0 {
|
||||
data.WriteByte('\n')
|
||||
chunk := strings.TrimPrefix(line, "data:")
|
||||
if strings.HasPrefix(chunk, " ") {
|
||||
chunk = chunk[1:]
|
||||
}
|
||||
data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
if int64(data.Len()+len(chunk)) > maxDetachedSSEEventBytes {
|
||||
return fmt.Errorf("detached event exceeds the maximum payload size")
|
||||
}
|
||||
data.WriteString(chunk)
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (b *Bridge) dispatchEvent(payload string) {
|
||||
func (b *Bridge) replayPendingCommand(ctx context.Context) error {
|
||||
var command childCommand
|
||||
status, err := b.doJSON(ctx, http.MethodGet, CommandStatePath, nil, &command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
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" {
|
||||
return fmt.Errorf("detached command-state replay is invalid")
|
||||
}
|
||||
b.emitRuntimeEvent(CommandEventName, command)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) replayHostState(ctx context.Context) error {
|
||||
var snapshot HostStateRequest
|
||||
status, err := b.doJSON(ctx, http.MethodGet, HostStatePath, nil, &snapshot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("detached host-state replay failed with status %d", status)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.ID) != b.windowID || snapshot.Revision <= 0 || snapshot.StoreState == nil {
|
||||
return fmt.Errorf("detached host-state replay is invalid")
|
||||
}
|
||||
b.emitRuntimeEvent(CommandEventName, childCommand{
|
||||
ID: snapshot.ID,
|
||||
Action: "sync-host-state",
|
||||
Payload: hostStatePayload{
|
||||
Revision: snapshot.Revision,
|
||||
StoreState: snapshot.StoreState,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) dispatchEvent(ctx context.Context, payload string) error {
|
||||
var event bridgeEvent
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil || strings.TrimSpace(event.Name) == "" {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if b.isHostStateInvalidation(event) {
|
||||
return b.replayHostState(ctx)
|
||||
}
|
||||
b.emitRuntimeEvent(event.Name, event.Args...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) isHostStateInvalidation(event bridgeEvent) bool {
|
||||
if b == nil || event.Name != CommandEventName || len(event.Args) != 1 {
|
||||
return false
|
||||
}
|
||||
command, ok := event.Args[0].(map[string]any)
|
||||
if !ok || strings.TrimSpace(fmt.Sprint(command["id"])) != b.windowID || command["action"] != "sync-host-state" {
|
||||
return false
|
||||
}
|
||||
payload, ok := command["payload"].(map[string]any)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
_, carriesStoreState := payload["storeState"]
|
||||
return !carriesStoreState
|
||||
}
|
||||
|
||||
func (b *Bridge) emitRuntimeEvent(name string, args ...any) {
|
||||
b.mu.Lock()
|
||||
ctx := b.ctx
|
||||
emitToWails := b.emitToWails
|
||||
b.mu.Unlock()
|
||||
if ctx != nil {
|
||||
wailsRuntime.EventsEmit(ctx, event.Name, event.Args...)
|
||||
if ctx != nil && emitToWails != nil {
|
||||
emitToWails(ctx, name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Control is bound only in a child and always targets that process's native
|
||||
// Wails window.
|
||||
type Control struct {
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
bridge *Bridge
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
bridge *Bridge
|
||||
closeGate closeGate
|
||||
closeFallback *time.Timer
|
||||
closeFallbackGeneration uint64
|
||||
closeFallbackDelay time.Duration
|
||||
closeCommitted bool
|
||||
domReady bool
|
||||
frontendReady bool
|
||||
focusPending bool
|
||||
visible bool
|
||||
emitCommand func(context.Context, childCommand)
|
||||
showWindow func(context.Context)
|
||||
focusWindow func(context.Context)
|
||||
quit func(context.Context)
|
||||
}
|
||||
|
||||
func newControl(bridge *Bridge) *Control {
|
||||
return &Control{bridge: bridge}
|
||||
return &Control{
|
||||
bridge: bridge,
|
||||
closeFallbackDelay: defaultGracefulCloseTimeout,
|
||||
emitCommand: func(ctx context.Context, command childCommand) {
|
||||
wailsRuntime.EventsEmit(ctx, CommandEventName, command)
|
||||
},
|
||||
showWindow: func(ctx context.Context) {
|
||||
wailsRuntime.WindowShow(ctx)
|
||||
},
|
||||
focusWindow: func(ctx context.Context) {
|
||||
wailsRuntime.WindowUnminimise(ctx)
|
||||
wailsRuntime.Show(ctx)
|
||||
},
|
||||
quit: wailsRuntime.Quit,
|
||||
}
|
||||
}
|
||||
|
||||
func InitializeControl(control *Control, ctx context.Context) {
|
||||
@@ -361,35 +529,234 @@ func InitializeControl(control *Control, ctx context.Context) {
|
||||
control.mu.Unlock()
|
||||
}
|
||||
|
||||
// markDOMReady records that the WebView exists without exposing its still-empty
|
||||
// surface. The frontend ready handshake releases the first presentation only
|
||||
// after its own post-paint barrier.
|
||||
func (c *Control) markDOMReady(ctx context.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.ctx == nil {
|
||||
c.ctx = ctx
|
||||
}
|
||||
c.domReady = true
|
||||
presentation := c.takeInitialPresentationLocked()
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
}
|
||||
|
||||
func (c *Control) markFrontendReady() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.mu.Lock()
|
||||
if !c.domReady || c.ctx == nil || c.showWindow == nil {
|
||||
c.mu.Unlock()
|
||||
return operationFailure("native window DOM is not ready")
|
||||
}
|
||||
c.frontendReady = true
|
||||
presentation := c.takeInitialPresentationLocked()
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
// Present exposes the already-mounted child without acknowledging readiness to
|
||||
// the parent. The visible WebView can then cross a real paint frame before the
|
||||
// frontend sends its final ready action.
|
||||
func (c *Control) Present() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.mu.Lock()
|
||||
if !c.domReady || c.ctx == nil || c.showWindow == nil {
|
||||
c.mu.Unlock()
|
||||
return operationFailure("native window DOM is not ready")
|
||||
}
|
||||
if c.visible {
|
||||
c.mu.Unlock()
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
c.visible = true
|
||||
presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow}
|
||||
if c.focusPending && c.focusWindow != nil {
|
||||
c.focusPending = false
|
||||
presentation.focus = c.focusWindow
|
||||
}
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
type childWindowPresentation struct {
|
||||
ctx context.Context
|
||||
show func(context.Context)
|
||||
focus func(context.Context)
|
||||
}
|
||||
|
||||
func (p childWindowPresentation) run() {
|
||||
if p.show != nil {
|
||||
p.show(p.ctx)
|
||||
}
|
||||
if p.focus != nil {
|
||||
p.focus(p.ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Control) takeInitialPresentationLocked() childWindowPresentation {
|
||||
if c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
|
||||
return childWindowPresentation{}
|
||||
}
|
||||
c.visible = true
|
||||
presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow}
|
||||
if c.focusPending && c.focusWindow != nil {
|
||||
c.focusPending = false
|
||||
presentation.focus = c.focusWindow
|
||||
}
|
||||
return presentation
|
||||
}
|
||||
|
||||
func (c *Control) Close() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.mu.Lock()
|
||||
ctx := c.ctx
|
||||
quit := c.quit
|
||||
if ctx == nil || quit == nil {
|
||||
c.mu.Unlock()
|
||||
return operationFailure("native window is not ready")
|
||||
}
|
||||
c.closeCommitted = true
|
||||
c.invalidateCloseFallbackLocked()
|
||||
c.mu.Unlock()
|
||||
c.closeGate.allow()
|
||||
if c.bridge != nil {
|
||||
c.bridge.notifyClosing()
|
||||
}
|
||||
c.mu.RLock()
|
||||
ctx := c.ctx
|
||||
c.mu.RUnlock()
|
||||
if ctx == nil {
|
||||
return operationFailure("native window is not ready")
|
||||
}
|
||||
wailsRuntime.Quit(ctx)
|
||||
quit(ctx)
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Control) CancelClose() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.closeCommitted {
|
||||
c.mu.Unlock()
|
||||
return operationFailure("native window close is already committed")
|
||||
}
|
||||
c.invalidateCloseFallbackLocked()
|
||||
c.mu.Unlock()
|
||||
c.closeGate.cancel()
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
// handleBeforeClose gives a mounted frontend one opportunity to submit its
|
||||
// terminal payload. A child that has not acknowledged ready has no state worth
|
||||
// gating, so its existing notifyClosing fallback remains in force.
|
||||
func (c *Control) handleBeforeClose(ctx context.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
if c.bridge == nil || !c.bridge.frontendReady() {
|
||||
if c.bridge != nil {
|
||||
c.bridge.notifyClosing()
|
||||
}
|
||||
return false
|
||||
}
|
||||
veto, requestFrontendClose := c.closeGate.intercept()
|
||||
if requestFrontendClose {
|
||||
c.mu.RLock()
|
||||
emitCommand := c.emitCommand
|
||||
c.mu.RUnlock()
|
||||
if emitCommand != nil {
|
||||
emitCommand(ctx, childCommand{
|
||||
ID: c.bridge.WindowID(),
|
||||
Action: "close",
|
||||
Reason: ExitReasonWindowClosed,
|
||||
})
|
||||
}
|
||||
}
|
||||
if veto {
|
||||
c.scheduleCloseFallback(ctx)
|
||||
}
|
||||
return veto
|
||||
}
|
||||
|
||||
func (c *Control) scheduleCloseFallback(fallbackCtx context.Context) {
|
||||
c.mu.Lock()
|
||||
if c.closeCommitted || c.closeFallback != nil {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delay := c.closeFallbackDelay
|
||||
if delay <= 0 {
|
||||
delay = defaultGracefulCloseTimeout
|
||||
}
|
||||
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.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Control) invalidateCloseFallbackLocked() {
|
||||
c.closeFallbackGeneration++
|
||||
if c.closeFallback != nil {
|
||||
c.closeFallback.Stop()
|
||||
c.closeFallback = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Control) Focus() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.mu.RLock()
|
||||
c.mu.Lock()
|
||||
ctx := c.ctx
|
||||
c.mu.RUnlock()
|
||||
if ctx == nil {
|
||||
focus := c.focusWindow
|
||||
if ctx == nil || focus == nil {
|
||||
c.mu.Unlock()
|
||||
return operationFailure("native window is not ready")
|
||||
}
|
||||
wailsRuntime.WindowUnminimise(ctx)
|
||||
wailsRuntime.WindowShow(ctx)
|
||||
wailsRuntime.Show(ctx)
|
||||
if !c.visible {
|
||||
c.focusPending = true
|
||||
presentation := c.takeInitialPresentationLocked()
|
||||
c.mu.Unlock()
|
||||
presentation.run()
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
focus(ctx)
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
127
internal/nativewindow/bridge_sse_test.go
Normal file
127
internal/nativewindow/bridge_sse_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBridgeReassemblesFragmentedLargeSSEEventWithoutChangingPayload(t *testing.T) {
|
||||
const chunkSize = 256 << 10
|
||||
content := strings.Repeat(" value with spaces ", (5<<20)/19)
|
||||
payload, err := json.Marshal(bridgeEvent{
|
||||
Name: "ai:stream:session-1",
|
||||
Args: []any{map[string]any{"content": content}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal large event: %v", err)
|
||||
}
|
||||
var stream strings.Builder
|
||||
stream.WriteString(": connected\n\n")
|
||||
for offset := 0; offset < len(payload); offset += chunkSize {
|
||||
end := offset + chunkSize
|
||||
if end > len(payload) {
|
||||
end = len(payload)
|
||||
}
|
||||
stream.WriteString("data: ")
|
||||
stream.Write(payload[offset:end])
|
||||
stream.WriteByte('\n')
|
||||
}
|
||||
stream.WriteByte('\n')
|
||||
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
status := http.StatusNoContent
|
||||
body := ""
|
||||
if request.URL.Path == EventsPath {
|
||||
status = http.StatusOK
|
||||
body = stream.String()
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
|
||||
var received string
|
||||
bridge.mu.Lock()
|
||||
bridge.ctx = context.Background()
|
||||
bridge.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name != "ai:stream:session-1" || len(args) != 1 {
|
||||
t.Fatalf("unexpected event %q %#v", name, args)
|
||||
}
|
||||
chunk, ok := args[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected event payload: %#v", args[0])
|
||||
}
|
||||
received, _ = chunk["content"].(string)
|
||||
}
|
||||
bridge.mu.Unlock()
|
||||
|
||||
if err := bridge.consumeEventStream(context.Background()); err != nil {
|
||||
t.Fatalf("consume fragmented stream: %v", err)
|
||||
}
|
||||
if received != content {
|
||||
t.Fatalf("round-trip content length = %d, want %d", len(received), len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeReplaysPendingCloseWhenEventStreamReconnects(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "workbench:query-1",
|
||||
Kind: "workbench",
|
||||
})
|
||||
commandPayload, err := json.Marshal(childCommand{
|
||||
ID: "workbench:query-1",
|
||||
Action: "close",
|
||||
Reason: ExitReasonRequested,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal close command: %v", err)
|
||||
}
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
status := http.StatusNoContent
|
||||
body := ""
|
||||
switch request.URL.Path {
|
||||
case EventsPath:
|
||||
status = http.StatusOK
|
||||
body = ": connected\n\n"
|
||||
case CommandStatePath:
|
||||
status = http.StatusOK
|
||||
body = string(commandPayload)
|
||||
}
|
||||
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.ID != "workbench:query-1" || received.Action != "close" || received.Reason != ExitReasonRequested {
|
||||
t.Fatalf("replayed command = %#v", received)
|
||||
}
|
||||
}
|
||||
@@ -133,12 +133,14 @@ func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
|
||||
|
||||
bridge := newBridge(childOptions)
|
||||
control := newControl(bridge)
|
||||
bridge.setReadyHandler(control.markFrontendReady)
|
||||
minWidth, minHeight := detachedWindowMinimumSize(childOptions.Kind)
|
||||
err = wails.Run(&options.App{
|
||||
Title: childOptions.Title,
|
||||
Width: childOptions.Width,
|
||||
Height: childOptions.Height,
|
||||
MinWidth: 420,
|
||||
MinHeight: 280,
|
||||
MinWidth: minWidth,
|
||||
MinHeight: minHeight,
|
||||
StartHidden: true,
|
||||
Frameless: true,
|
||||
AssetServer: &assetserver.Options{Handler: proxy},
|
||||
@@ -171,11 +173,10 @@ func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
|
||||
childOptions.Height,
|
||||
)
|
||||
wailsRuntime.WindowSetTitle(ctx, childOptions.Title)
|
||||
wailsRuntime.WindowShow(ctx)
|
||||
control.markDOMReady(ctx)
|
||||
},
|
||||
OnBeforeClose: func(context.Context) bool {
|
||||
bridge.notifyClosing()
|
||||
return false
|
||||
OnBeforeClose: func(ctx context.Context) bool {
|
||||
return control.handleBeforeClose(ctx)
|
||||
},
|
||||
OnShutdown: func(context.Context) {
|
||||
bridge.notifyClosing()
|
||||
@@ -186,6 +187,13 @@ func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func detachedWindowMinimumSize(kind string) (width int, height int) {
|
||||
if strings.TrimSpace(kind) == "ai-chat" {
|
||||
return 360, 420
|
||||
}
|
||||
return 480, 320
|
||||
}
|
||||
|
||||
type discardWriter struct{}
|
||||
|
||||
func (discardWriter) Write(payload []byte) (int, error) {
|
||||
|
||||
39
internal/nativewindow/close_gate.go
Normal file
39
internal/nativewindow/close_gate.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package nativewindow
|
||||
|
||||
import "sync"
|
||||
|
||||
// closeGate lets the first native close request ask the frontend for a final
|
||||
// sync. Control.Close opens the gate after that terminal action succeeds.
|
||||
type closeGate struct {
|
||||
mu sync.Mutex
|
||||
allowed bool
|
||||
}
|
||||
|
||||
func (g *closeGate) intercept() (veto bool, requestFrontendClose bool) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.allowed {
|
||||
return false, false
|
||||
}
|
||||
// Emit on every native close attempt. The React terminal state deduplicates
|
||||
// concurrent requests, while a user can retry after a failed final flush.
|
||||
return true, true
|
||||
}
|
||||
|
||||
func (g *closeGate) allow() {
|
||||
g.mu.Lock()
|
||||
g.allowed = true
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *closeGate) cancel() {
|
||||
g.mu.Lock()
|
||||
g.allowed = false
|
||||
g.mu.Unlock()
|
||||
}
|
||||
|
||||
func (g *closeGate) isAllowed() bool {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.allowed
|
||||
}
|
||||
172
internal/nativewindow/close_gate_test.go
Normal file
172
internal/nativewindow/close_gate_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
func TestCloseGateVetoesAndRequestsFrontendUntilExitIsAllowed(t *testing.T) {
|
||||
var gate closeGate
|
||||
|
||||
if veto, first := gate.intercept(); !veto || !first {
|
||||
t.Fatalf("first intercept = veto %v first %v, want true true", veto, first)
|
||||
}
|
||||
if veto, request := gate.intercept(); !veto || !request {
|
||||
t.Fatalf("second intercept = veto %v request %v, want true true", veto, request)
|
||||
}
|
||||
|
||||
gate.allow()
|
||||
if veto, first := gate.intercept(); veto || first {
|
||||
t.Fatalf("allowed intercept = veto %v first %v, want false false", veto, first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlBeforeCloseRequestsOneGracefulFrontendClose(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{ID: "ai-chat", Kind: "ai-chat"})
|
||||
bridge.mu.Lock()
|
||||
bridge.ready = true
|
||||
bridge.mu.Unlock()
|
||||
control := newControl(bridge)
|
||||
commands := make([]childCommand, 0, 1)
|
||||
control.emitCommand = func(_ context.Context, command childCommand) {
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
if veto := control.handleBeforeClose(context.Background()); !veto {
|
||||
t.Fatal("first native close was not vetoed")
|
||||
}
|
||||
if veto := control.handleBeforeClose(context.Background()); !veto {
|
||||
t.Fatal("repeated native close was not vetoed while final sync is pending")
|
||||
}
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("graceful close commands = %d, want 2", len(commands))
|
||||
}
|
||||
for _, command := range commands {
|
||||
if command.ID != "ai-chat" || command.Action != "close" || command.Reason != ExitReasonWindowClosed {
|
||||
t.Fatalf("unexpected graceful close command: %#v", command)
|
||||
}
|
||||
}
|
||||
|
||||
control.closeGate.allow()
|
||||
if veto := control.handleBeforeClose(context.Background()); veto {
|
||||
t.Fatal("frontend-approved native close was still vetoed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlBeforeCloseForcesExitWhenFrontendDoesNotRespond(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{ID: "ai-chat", Kind: "ai-chat"})
|
||||
bridge.mu.Lock()
|
||||
bridge.ready = true
|
||||
bridge.mu.Unlock()
|
||||
control := newControl(bridge)
|
||||
control.closeFallbackDelay = 10 * time.Millisecond
|
||||
control.emitCommand = func(context.Context, childCommand) {}
|
||||
quit := make(chan struct{}, 1)
|
||||
control.quit = func(context.Context) {
|
||||
quit <- struct{}{}
|
||||
}
|
||||
InitializeControl(control, context.Background())
|
||||
|
||||
if veto := control.handleBeforeClose(context.Background()); !veto {
|
||||
t.Fatal("native close was not initially vetoed")
|
||||
}
|
||||
select {
|
||||
case <-quit:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("native close fallback did not force exit")
|
||||
}
|
||||
if veto, _ := control.closeGate.intercept(); veto {
|
||||
t.Fatal("close gate remained locked after fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlCancelCloseInvalidatesFallbackAndAllowsRetry(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{ID: "ai-chat", Kind: "ai-chat"})
|
||||
bridge.mu.Lock()
|
||||
bridge.ready = true
|
||||
bridge.mu.Unlock()
|
||||
control := newControl(bridge)
|
||||
control.closeFallbackDelay = 20 * time.Millisecond
|
||||
control.emitCommand = func(context.Context, childCommand) {}
|
||||
quit := make(chan struct{}, 2)
|
||||
control.quit = func(context.Context) {
|
||||
quit <- struct{}{}
|
||||
}
|
||||
InitializeControl(control, context.Background())
|
||||
|
||||
if veto := control.handleBeforeClose(context.Background()); !veto {
|
||||
t.Fatal("native close was not initially vetoed")
|
||||
}
|
||||
if result := control.CancelClose(); !result.Success {
|
||||
t.Fatalf("CancelClose result = %#v", result)
|
||||
}
|
||||
select {
|
||||
case <-quit:
|
||||
t.Fatal("cancelled close fallback still forced exit")
|
||||
case <-time.After(60 * time.Millisecond):
|
||||
}
|
||||
|
||||
if veto := control.handleBeforeClose(context.Background()); !veto {
|
||||
t.Fatal("native close retry was not vetoed")
|
||||
}
|
||||
select {
|
||||
case <-quit:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("close retry did not schedule a new fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyClosingStillSendsOneFallbackAction(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 fallback action: %v", err)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Body: io.NopCloser(strings.NewReader(`{"success":false}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
requests <- request
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"ai-chat"}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})
|
||||
bridge.notifyClosing()
|
||||
bridge.notifyClosing()
|
||||
|
||||
request := <-requests
|
||||
if request.Action != "close" {
|
||||
t.Fatalf("fallback action = %q, want close", request.Action)
|
||||
}
|
||||
payload, ok := request.Payload.(map[string]any)
|
||||
if !ok || payload["id"] != "ai-chat" || payload["kind"] != "ai-chat" {
|
||||
t.Fatalf("unexpected fallback payload: %#v", request.Payload)
|
||||
}
|
||||
select {
|
||||
case duplicate := <-requests:
|
||||
t.Fatalf("notifyClosing sent duplicate fallback: %#v", duplicate)
|
||||
default:
|
||||
}
|
||||
}
|
||||
181
internal/nativewindow/host_state_test.go
Normal file
181
internal/nativewindow/host_state_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBridgeReplaysLatestHostStateAfterEveryEventStreamConnection(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
revision := int64(3)
|
||||
eventConnections := 0
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch request.URL.Path {
|
||||
case EventsPath:
|
||||
eventConnections++
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(": connected\n\n")),
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
}, nil
|
||||
case CommandStatePath:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
case HostStatePath:
|
||||
payload, err := json.Marshal(HostStateRequest{
|
||||
ID: "ai-chat",
|
||||
Revision: revision,
|
||||
StoreState: map[string]any{"activeTabId": fmt.Sprintf("query-%d", revision)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected bridge request path: %s", request.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
commands := make([]childCommand, 0, 2)
|
||||
bridge.mu.Lock()
|
||||
bridge.ctx = context.Background()
|
||||
bridge.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name != CommandEventName || len(args) != 1 {
|
||||
t.Fatalf("unexpected replay event: %q %#v", name, args)
|
||||
}
|
||||
commands = append(commands, args[0].(childCommand))
|
||||
}
|
||||
bridge.mu.Unlock()
|
||||
|
||||
if err := bridge.consumeEventStream(context.Background()); err != nil {
|
||||
t.Fatalf("first event stream: %v", err)
|
||||
}
|
||||
revision = 4
|
||||
if err := bridge.consumeEventStream(context.Background()); err != nil {
|
||||
t.Fatalf("reconnected event stream: %v", err)
|
||||
}
|
||||
|
||||
if eventConnections != 2 {
|
||||
t.Fatalf("event stream connections = %d, want 2", eventConnections)
|
||||
}
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("replayed commands = %d, want 2", len(commands))
|
||||
}
|
||||
for index, wantRevision := range []int64{3, 4} {
|
||||
command := commands[index]
|
||||
if command.ID != "ai-chat" || command.Action != "sync-host-state" {
|
||||
t.Fatalf("replay %d command = %#v", index, command)
|
||||
}
|
||||
payload, ok := command.Payload.(hostStatePayload)
|
||||
if !ok || payload.Revision != wantRevision {
|
||||
t.Fatalf("replay %d payload = %#v, want revision %d", index, command.Payload, wantRevision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRefreshesRetainedHostStateForLightweightInvalidation(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
})
|
||||
invalidation, err := json.Marshal(bridgeEvent{
|
||||
Name: CommandEventName,
|
||||
Args: []any{map[string]any{
|
||||
"id": "ai-chat",
|
||||
"action": "sync-host-state",
|
||||
"payload": map[string]any{
|
||||
"revision": 4,
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal invalidation: %v", err)
|
||||
}
|
||||
|
||||
hostStateRequests := 0
|
||||
bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
switch request.URL.Path {
|
||||
case EventsPath:
|
||||
body := fmt.Sprintf(": connected\n\ndata: %s\n\n", invalidation)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
}, nil
|
||||
case CommandStatePath:
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusNoContent,
|
||||
Body: io.NopCloser(strings.NewReader("")),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
case HostStatePath:
|
||||
hostStateRequests++
|
||||
revision := int64(3)
|
||||
if hostStateRequests > 1 {
|
||||
revision = 4
|
||||
}
|
||||
payload, marshalErr := json.Marshal(HostStateRequest{
|
||||
ID: "ai-chat",
|
||||
Revision: revision,
|
||||
StoreState: map[string]any{"revision": revision},
|
||||
})
|
||||
if marshalErr != nil {
|
||||
return nil, marshalErr
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(string(payload))),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected bridge request path: %s", request.URL.Path)
|
||||
return nil, nil
|
||||
}
|
||||
})
|
||||
|
||||
commands := make([]childCommand, 0, 2)
|
||||
bridge.mu.Lock()
|
||||
bridge.ctx = context.Background()
|
||||
bridge.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == CommandEventName && len(args) == 1 {
|
||||
commands = append(commands, args[0].(childCommand))
|
||||
}
|
||||
}
|
||||
bridge.mu.Unlock()
|
||||
|
||||
if err := bridge.consumeEventStream(context.Background()); err != nil {
|
||||
t.Fatalf("consume invalidation stream: %v", err)
|
||||
}
|
||||
if hostStateRequests != 2 {
|
||||
t.Fatalf("host-state requests = %d, want initial replay plus invalidation refresh", hostStateRequests)
|
||||
}
|
||||
if len(commands) != 2 {
|
||||
t.Fatalf("host-state commands = %d, want 2", len(commands))
|
||||
}
|
||||
for index, revision := range []int64{3, 4} {
|
||||
payload, ok := commands[index].Payload.(hostStatePayload)
|
||||
if !ok || payload.Revision != revision {
|
||||
t.Fatalf("command %d payload = %#v, want revision %d", index, commands[index].Payload, revision)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -37,8 +39,8 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
forceCloseDelay = 3 * time.Second
|
||||
defaultOpenReadyTimeout = 10 * time.Second
|
||||
defaultGracefulCloseTimeout = 10 * time.Second
|
||||
defaultOpenReadyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type processExit struct {
|
||||
@@ -46,16 +48,19 @@ type processExit struct {
|
||||
}
|
||||
|
||||
type windowEntry struct {
|
||||
info WindowInfo
|
||||
payload any
|
||||
ownerID string
|
||||
process childProcess
|
||||
exitReason string
|
||||
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
|
||||
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
|
||||
@@ -73,10 +78,15 @@ type Manager struct {
|
||||
closing bool
|
||||
windows map[string]*windowEntry
|
||||
|
||||
starter processStarter
|
||||
executable string
|
||||
openTimeout time.Duration
|
||||
emitToWails func(context.Context, string, ...any)
|
||||
starter processStarter
|
||||
executable string
|
||||
openTimeout time.Duration
|
||||
closeFallbackDelay time.Duration
|
||||
shutdownGracePeriod time.Duration
|
||||
emitToWails func(context.Context, string, ...any)
|
||||
emitToChildren func(string, ...any)
|
||||
emitToChild func(string, string, ...any)
|
||||
emitToChildBestEffort func(string, string, ...any)
|
||||
}
|
||||
|
||||
// NewManager prepares a detached-window manager around the already-created
|
||||
@@ -99,15 +109,20 @@ func NewManager(assetFS fs.FS, app *appcore.App, ai *aiservice.Service) (*Manage
|
||||
return nil, fmt.Errorf("resolve executable failed: %w", err)
|
||||
}
|
||||
return &Manager{
|
||||
shared: shared,
|
||||
token: token,
|
||||
windows: make(map[string]*windowEntry),
|
||||
starter: execProcessStarter{},
|
||||
executable: executable,
|
||||
openTimeout: defaultOpenReadyTimeout,
|
||||
shared: shared,
|
||||
token: token,
|
||||
windows: make(map[string]*windowEntry),
|
||||
starter: execProcessStarter{},
|
||||
executable: executable,
|
||||
openTimeout: defaultOpenReadyTimeout,
|
||||
closeFallbackDelay: defaultGracefulCloseTimeout,
|
||||
shutdownGracePeriod: defaultGracefulCloseTimeout,
|
||||
emitToWails: func(ctx context.Context, name string, args ...any) {
|
||||
wailsRuntime.EventsEmit(ctx, name, args...)
|
||||
},
|
||||
emitToChildren: shared.Emit,
|
||||
emitToChild: shared.EmitTo,
|
||||
emitToChildBestEffort: shared.EmitToBestEffort,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -190,16 +205,55 @@ func (m *Manager) emit(name string, args ...any) {
|
||||
m.mu.RLock()
|
||||
ctx := m.runtimeCtx
|
||||
emitToWails := m.emitToWails
|
||||
emitToChildren := m.emitToChildren
|
||||
emitToChild := m.emitToChild
|
||||
emitToChildBestEffort := m.emitToChildBestEffort
|
||||
shared := m.shared
|
||||
m.mu.RUnlock()
|
||||
if ctx != nil && emitToWails != nil {
|
||||
emitToWails(ctx, name, args...)
|
||||
}
|
||||
if shared != nil {
|
||||
if strings.HasPrefix(name, "ai:stream:") {
|
||||
if aiStreamEventRequiresReliableDelivery(args) {
|
||||
if emitToChild != nil {
|
||||
emitToChild("ai-chat", name, args...)
|
||||
} else if shared != nil {
|
||||
shared.EmitTo("ai-chat", name, args...)
|
||||
}
|
||||
} else if emitToChildBestEffort != nil {
|
||||
emitToChildBestEffort("ai-chat", name, args...)
|
||||
} else if shared != nil {
|
||||
shared.EmitToBestEffort("ai-chat", name, args...)
|
||||
}
|
||||
return
|
||||
}
|
||||
if emitToChildren != nil {
|
||||
emitToChildren(name, args...)
|
||||
} else if shared != nil {
|
||||
shared.Emit(name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func aiStreamEventRequiresReliableDelivery(args []any) bool {
|
||||
if len(args) != 1 {
|
||||
return true
|
||||
}
|
||||
payload, ok := args[0].(map[string]any)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if done, _ := payload["done"].(bool); done {
|
||||
return true
|
||||
}
|
||||
if errorText, _ := payload["error"].(string); strings.TrimSpace(errorText) != "" {
|
||||
return true
|
||||
}
|
||||
toolCalls := reflect.ValueOf(payload["tool_calls"])
|
||||
return toolCalls.IsValid() &&
|
||||
(toolCalls.Kind() == reflect.Array || toolCalls.Kind() == reflect.Slice) &&
|
||||
toolCalls.Len() > 0
|
||||
}
|
||||
|
||||
// Open launches one native Wails child process. Existing IDs are focused
|
||||
// instead of duplicated.
|
||||
func (m *Manager) Open(request OpenRequest) OperationResult {
|
||||
@@ -298,11 +352,17 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
|
||||
case <-timer.C:
|
||||
m.mu.Lock()
|
||||
current, active := m.windows[request.ID]
|
||||
if active && current == entry {
|
||||
if active && current == entry && entry.info.Ready {
|
||||
entry.acknowledged = true
|
||||
m.mu.Unlock()
|
||||
return OperationResult{Success: true, ID: request.ID}
|
||||
}
|
||||
registered := active && current == entry
|
||||
if registered {
|
||||
delete(m.windows, request.ID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if active {
|
||||
if registered {
|
||||
_ = process.Kill()
|
||||
}
|
||||
return operationFailure("native window did not become ready in time")
|
||||
@@ -326,12 +386,18 @@ func (m *Manager) Focus(id string) OperationResult {
|
||||
id = strings.TrimSpace(id)
|
||||
m.mu.RLock()
|
||||
_, exists := m.windows[id]
|
||||
emitToChild := m.emitToChild
|
||||
shared := m.shared
|
||||
m.mu.RUnlock()
|
||||
if !exists {
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
shared.Emit(CommandEventName, childCommand{ID: id, Action: "focus"})
|
||||
command := childCommand{ID: id, Action: "focus"}
|
||||
if emitToChild != nil {
|
||||
emitToChild(id, CommandEventName, command)
|
||||
} else if shared != nil {
|
||||
shared.EmitTo(id, CommandEventName, command)
|
||||
}
|
||||
return OperationResult{Success: true, ID: id}
|
||||
}
|
||||
|
||||
@@ -351,27 +417,46 @@ func (m *Manager) requestClose(id string, reason string) OperationResult {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
if entry.info.CloseSent {
|
||||
m.mu.Unlock()
|
||||
return OperationResult{Success: true, ID: id}
|
||||
}
|
||||
childAlreadyClosing := entry.exitReason == ExitReasonAttached || entry.exitReason == ExitReasonWindowClosed
|
||||
if entry.exitReason == "" {
|
||||
entry.exitReason = reason
|
||||
}
|
||||
entry.info.CloseSent = true
|
||||
entry.closeGeneration++
|
||||
closeGeneration := entry.closeGeneration
|
||||
process := entry.process
|
||||
shared := m.shared
|
||||
emitToChild := m.emitToChild
|
||||
delay := m.closeFallbackDelay
|
||||
if delay <= 0 {
|
||||
delay = defaultGracefulCloseTimeout
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// attach/close actions are emitted before their HTTP response is written.
|
||||
// Do not race that response with a second close command: the child will quit
|
||||
// itself after receiving the successful terminal-action response.
|
||||
if !childAlreadyClosing {
|
||||
shared.Emit(CommandEventName, childCommand{ID: id, Action: "close"})
|
||||
command := childCommand{ID: id, Action: "close", Reason: reason}
|
||||
if emitToChild != nil {
|
||||
emitToChild(id, CommandEventName, command)
|
||||
} else if shared != nil {
|
||||
shared.EmitTo(id, CommandEventName, command)
|
||||
}
|
||||
}
|
||||
if process != nil {
|
||||
time.AfterFunc(forceCloseDelay, func() {
|
||||
m.mu.RLock()
|
||||
time.AfterFunc(delay, func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
current, active := m.windows[id]
|
||||
m.mu.RUnlock()
|
||||
if active && current.process == process {
|
||||
if active &&
|
||||
current.process == process &&
|
||||
current.info.CloseSent &&
|
||||
current.closeGeneration == closeGeneration {
|
||||
_ = process.Kill()
|
||||
}
|
||||
})
|
||||
@@ -379,6 +464,33 @@ func (m *Manager) requestClose(id string, reason string) OperationResult {
|
||||
return OperationResult{Success: true, ID: id}
|
||||
}
|
||||
|
||||
// CancelClose clears a pending graceful-close request. Incrementing the close
|
||||
// generation makes an already queued force-kill callback harmless.
|
||||
func (m *Manager) CancelClose(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")
|
||||
}
|
||||
m.cancelCloseLocked(entry)
|
||||
m.mu.Unlock()
|
||||
return OperationResult{Success: true, ID: id}
|
||||
}
|
||||
|
||||
func (m *Manager) cancelCloseLocked(entry *windowEntry) {
|
||||
entry.closeGeneration++
|
||||
entry.info.CloseSent = false
|
||||
switch entry.exitReason {
|
||||
case ExitReasonRequested, ExitReasonParentShutdown, ExitReasonAttached, ExitReasonWindowClosed:
|
||||
entry.exitReason = ""
|
||||
}
|
||||
}
|
||||
|
||||
// CloseAll requests graceful shutdown of every detached child.
|
||||
func (m *Manager) CloseAll() OperationResult {
|
||||
if m == nil {
|
||||
@@ -410,8 +522,78 @@ func (m *Manager) List() []WindowInfo {
|
||||
return result
|
||||
}
|
||||
|
||||
// SyncHostState retains and invalidates the newest main-window snapshot for one
|
||||
// active child. It deliberately bypasses the main Wails event bus so host state
|
||||
// cannot echo back into the window that produced it.
|
||||
func (m *Manager) SyncHostState(request HostStateRequest) OperationResult {
|
||||
if m == nil {
|
||||
return operationFailure("native window manager is unavailable")
|
||||
}
|
||||
request.ID = strings.TrimSpace(request.ID)
|
||||
if request.ID == "" {
|
||||
return operationFailure("native host-state window id is required")
|
||||
}
|
||||
if request.Revision <= 0 {
|
||||
return operationFailure("native host-state revision must be positive")
|
||||
}
|
||||
storeState, err := cloneHostStoreState(request.StoreState)
|
||||
if err != nil {
|
||||
return operationFailure(err.Error())
|
||||
}
|
||||
request.StoreState = storeState
|
||||
|
||||
m.mu.Lock()
|
||||
entry, exists := m.windows[request.ID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
if request.Revision <= entry.hostState.Revision {
|
||||
m.mu.Unlock()
|
||||
return OperationResult{Success: true, ID: request.ID, Message: "stale host state ignored"}
|
||||
}
|
||||
entry.hostState = request
|
||||
emitToChild := m.emitToChild
|
||||
shared := m.shared
|
||||
m.mu.Unlock()
|
||||
|
||||
command := childCommand{
|
||||
ID: request.ID,
|
||||
Action: "sync-host-state",
|
||||
Payload: hostStateInvalidationPayload{Revision: request.Revision},
|
||||
}
|
||||
if emitToChild != nil {
|
||||
emitToChild(request.ID, CommandEventName, command)
|
||||
} else if shared != nil {
|
||||
shared.EmitTo(request.ID, CommandEventName, command)
|
||||
}
|
||||
return OperationResult{Success: true, ID: request.ID}
|
||||
}
|
||||
|
||||
type hostStateInvalidationPayload struct {
|
||||
Revision int64 `json:"revision"`
|
||||
}
|
||||
|
||||
func cloneHostStoreState(storeState map[string]any) (map[string]any, error) {
|
||||
if storeState == nil {
|
||||
return nil, fmt.Errorf("native host-state storeState is required")
|
||||
}
|
||||
payload, err := json.Marshal(storeState)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode native host state failed: %w", err)
|
||||
}
|
||||
if int64(len(payload)) > maxDetachedJSONBytes {
|
||||
return nil, fmt.Errorf("native host state exceeds the maximum payload size")
|
||||
}
|
||||
cloned := make(map[string]any)
|
||||
if err := json.Unmarshal(payload, &cloned); err != nil {
|
||||
return nil, fmt.Errorf("clone native host state failed: %w", err)
|
||||
}
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
func (m *Manager) processSpecLocked(request OpenRequest) processSpec {
|
||||
env := append([]string(nil), os.Environ()...)
|
||||
env := filterDetachedChildEnvironment(os.Environ())
|
||||
values := map[string]string{
|
||||
envParentURL: m.endpoint,
|
||||
envToken: m.token,
|
||||
@@ -487,7 +669,42 @@ func (m *Manager) watchProcess(id string, process childProcess) {
|
||||
}
|
||||
|
||||
func (m *Manager) emitDetached(event Event) {
|
||||
m.emit(MainEventName, event)
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
ctx := m.runtimeCtx
|
||||
emitToWails := m.emitToWails
|
||||
emitToChild := m.emitToChild
|
||||
shared := m.shared
|
||||
m.mu.RUnlock()
|
||||
if ctx != nil && emitToWails != nil {
|
||||
emitToWails(ctx, MainEventName, event)
|
||||
}
|
||||
// Only child-owned window lifecycle must cross back into a child process.
|
||||
// Top-level child sync can contain large result/history snapshots and must
|
||||
// never be broadcast to every detached SSE subscriber.
|
||||
ownerID := ownerWindowIDFromPayload(event.Payload)
|
||||
if ownerID == "" {
|
||||
return
|
||||
}
|
||||
if emitToChild != nil {
|
||||
emitToChild(ownerID, MainEventName, event)
|
||||
} else if shared != nil {
|
||||
shared.EmitTo(ownerID, MainEventName, event)
|
||||
}
|
||||
}
|
||||
|
||||
func ownerWindowIDFromPayload(payload any) string {
|
||||
record, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
ownerID, ok := record["ownerWindowId"].(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(ownerID)
|
||||
}
|
||||
|
||||
func operationFailure(message string) OperationResult {
|
||||
@@ -501,7 +718,7 @@ func validateOpenRequest(request OpenRequest) error {
|
||||
if strings.ContainsRune(request.Kind, '\x00') || strings.ContainsRune(request.Title, '\x00') {
|
||||
return fmt.Errorf("native window kind or title is invalid")
|
||||
}
|
||||
if request.Kind != "workbench" && request.Kind != "query-result" {
|
||||
if request.Kind != "workbench" && request.Kind != "query-result" && request.Kind != "ai-chat" {
|
||||
return fmt.Errorf("native window kind is unsupported")
|
||||
}
|
||||
return nil
|
||||
@@ -514,16 +731,40 @@ func (m *Manager) shutdown() {
|
||||
return
|
||||
}
|
||||
m.closing = true
|
||||
ids := make([]string, 0, len(m.windows))
|
||||
for id, entry := range m.windows {
|
||||
ids = append(ids, id)
|
||||
entry.exitReason = ExitReasonParentShutdown
|
||||
entry.info.CloseSent = true
|
||||
entry.closeGeneration++
|
||||
}
|
||||
httpServer := m.httpServer
|
||||
shared := m.shared
|
||||
emitToChild := m.emitToChild
|
||||
gracePeriod := m.shutdownGracePeriod
|
||||
if gracePeriod <= 0 {
|
||||
gracePeriod = defaultGracefulCloseTimeout
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, id := range ids {
|
||||
command := childCommand{ID: id, Action: "close", Reason: ExitReasonParentShutdown}
|
||||
if emitToChild != nil {
|
||||
emitToChild(id, CommandEventName, command)
|
||||
} else if shared != nil {
|
||||
shared.EmitTo(id, CommandEventName, command)
|
||||
}
|
||||
}
|
||||
m.waitForChildren(gracePeriod)
|
||||
|
||||
m.mu.RLock()
|
||||
processes := make([]childProcess, 0, len(m.windows))
|
||||
for _, entry := range m.windows {
|
||||
entry.exitReason = ExitReasonParentShutdown
|
||||
if entry.process != nil {
|
||||
processes = append(processes, entry.process)
|
||||
}
|
||||
}
|
||||
httpServer := m.httpServer
|
||||
m.mu.Unlock()
|
||||
|
||||
m.mu.RUnlock()
|
||||
for _, process := range processes {
|
||||
_ = process.Kill()
|
||||
}
|
||||
@@ -532,6 +773,42 @@ func (m *Manager) shutdown() {
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
cancel()
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.started = false
|
||||
m.httpServer = nil
|
||||
m.listener = nil
|
||||
m.endpoint = ""
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) waitForChildren(timeout time.Duration) {
|
||||
if timeout <= 0 {
|
||||
return
|
||||
}
|
||||
pollInterval := 10 * time.Millisecond
|
||||
if timeout < pollInterval {
|
||||
pollInterval = timeout / 4
|
||||
if pollInterval <= 0 {
|
||||
pollInterval = time.Millisecond
|
||||
}
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
m.mu.RLock()
|
||||
remaining := len(m.windows)
|
||||
m.mu.RUnlock()
|
||||
if remaining == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-timer.C:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) authenticatedHandler() http.Handler {
|
||||
@@ -539,6 +816,8 @@ func (m *Manager) authenticatedHandler() http.Handler {
|
||||
mux.HandleFunc(BootstrapPath, m.handleBootstrap)
|
||||
mux.HandleFunc(ActionPath, m.handleAction)
|
||||
mux.HandleFunc(ControlPath, m.handleControl)
|
||||
mux.HandleFunc(HostStatePath, m.handleHostState)
|
||||
mux.HandleFunc(CommandStatePath, m.handleCommandState)
|
||||
mux.Handle("/", m.shared.Handler())
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isLoopbackRemote(r.RemoteAddr) {
|
||||
@@ -591,6 +870,60 @@ func (m *Manager) handleBootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(bootstrap)
|
||||
}
|
||||
|
||||
func (m *Manager) handleHostState(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
|
||||
m.mu.RLock()
|
||||
entry, exists := m.windows[id]
|
||||
if !exists {
|
||||
m.mu.RUnlock()
|
||||
http.Error(w, "unknown detached window", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
hostState := entry.hostState
|
||||
m.mu.RUnlock()
|
||||
if hostState.Revision <= 0 {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(hostState)
|
||||
}
|
||||
|
||||
func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
|
||||
m.mu.RLock()
|
||||
entry, exists := m.windows[id]
|
||||
if !exists {
|
||||
m.mu.RUnlock()
|
||||
http.Error(w, "unknown detached window", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
closeSent := entry.info.CloseSent
|
||||
reason := entry.exitReason
|
||||
m.mu.RUnlock()
|
||||
if !closeSent {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(reason) == "" {
|
||||
reason = ExitReasonRequested
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(childCommand{
|
||||
ID: id,
|
||||
Action: "close",
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -605,13 +938,14 @@ 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":
|
||||
case "ready", "sync", "attach", "close", "cancel-close", "host-event", "open-ai-settings":
|
||||
default:
|
||||
http.Error(w, "unsupported detached action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
|
||||
revision := positiveActionRevision(request.Payload)
|
||||
m.mu.Lock()
|
||||
entry, exists := m.windows[id]
|
||||
if !exists {
|
||||
@@ -619,6 +953,19 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unknown detached window", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if actionUsesRevision(request.Action) && revision > 0 {
|
||||
if revision <= entry.actionRevision {
|
||||
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",
|
||||
})
|
||||
return
|
||||
}
|
||||
entry.actionRevision = revision
|
||||
}
|
||||
if request.Action == "ready" {
|
||||
entry.info.Ready = true
|
||||
entry.readyOnce.Do(func() {
|
||||
@@ -630,6 +977,8 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
entry.exitReason = ExitReasonAttached
|
||||
} else if request.Action == "close" && entry.exitReason == "" {
|
||||
entry.exitReason = ExitReasonWindowClosed
|
||||
} else if request.Action == "cancel-close" {
|
||||
m.cancelCloseLocked(entry)
|
||||
}
|
||||
info := entry.info
|
||||
ownerID := entry.ownerID
|
||||
@@ -647,6 +996,38 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{Success: true, ID: id})
|
||||
}
|
||||
|
||||
func actionUsesRevision(action string) bool {
|
||||
return action == "sync" || action == "attach" || action == "close"
|
||||
}
|
||||
|
||||
func positiveActionRevision(payload any) int64 {
|
||||
record, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch value := record["revision"].(type) {
|
||||
case float64:
|
||||
if value <= 0 || value > 9_007_199_254_740_991 || math.Trunc(value) != value {
|
||||
return 0
|
||||
}
|
||||
return int64(value)
|
||||
case json.Number:
|
||||
revision, err := value.Int64()
|
||||
if err == nil && revision > 0 {
|
||||
return revision
|
||||
}
|
||||
case int64:
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
case int:
|
||||
if value > 0 {
|
||||
return int64(value)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -2,7 +2,9 @@ package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -45,6 +47,35 @@ type fakeChildProcess struct {
|
||||
killOnce sync.Once
|
||||
}
|
||||
|
||||
type readyBeforeReturnStarter struct {
|
||||
manager *Manager
|
||||
mu sync.Mutex
|
||||
nextPID int
|
||||
started []*fakeChildProcess
|
||||
}
|
||||
|
||||
func (s *readyBeforeReturnStarter) Start(spec processSpec) (childProcess, error) {
|
||||
s.mu.Lock()
|
||||
s.nextPID++
|
||||
process := &fakeChildProcess{
|
||||
pid: s.nextPID,
|
||||
done: make(chan error, 1),
|
||||
killed: make(chan struct{}),
|
||||
}
|
||||
s.started = append(s.started, process)
|
||||
s.mu.Unlock()
|
||||
|
||||
id := environmentValue(spec.Env, envWindowID)
|
||||
s.manager.mu.Lock()
|
||||
entry := s.manager.windows[id]
|
||||
if entry != nil {
|
||||
entry.info.Ready = true
|
||||
entry.readyOnce.Do(func() { close(entry.ready) })
|
||||
}
|
||||
s.manager.mu.Unlock()
|
||||
return process, nil
|
||||
}
|
||||
|
||||
func (p *fakeChildProcess) PID() int { return p.pid }
|
||||
func (p *fakeChildProcess) Wait() error {
|
||||
return <-p.done
|
||||
@@ -85,6 +116,52 @@ func TestParseChildOptionsPreservesNegativeVirtualDesktopCoordinates(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedWindowMinimumSizeMatchesFrontendPresets(t *testing.T) {
|
||||
if width, height := detachedWindowMinimumSize("ai-chat"); width != 360 || height != 420 {
|
||||
t.Fatalf("AI minimum size = %dx%d, want 360x420", width, height)
|
||||
}
|
||||
if width, height := detachedWindowMinimumSize("workbench"); width != 480 || height != 320 {
|
||||
t.Fatalf("workbench minimum size = %dx%d, want 480x320", width, height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGracefulCloseTimeoutLeavesTerminalGuardHeadroom(t *testing.T) {
|
||||
const terminalGuardTimeout = 3 * time.Second
|
||||
if defaultGracefulCloseTimeout != 10*time.Second {
|
||||
t.Fatalf("default graceful close timeout = %s, want 10s", defaultGracefulCloseTimeout)
|
||||
}
|
||||
if defaultGracefulCloseTimeout <= terminalGuardTimeout {
|
||||
t.Fatalf(
|
||||
"default graceful close timeout %s must exceed terminal guard timeout %s",
|
||||
defaultGracefulCloseTimeout,
|
||||
terminalGuardTimeout,
|
||||
)
|
||||
}
|
||||
|
||||
manager := newHTTPTestManager(t)
|
||||
if manager.closeFallbackDelay != defaultGracefulCloseTimeout {
|
||||
t.Fatalf("manager close fallback = %s, want %s", manager.closeFallbackDelay, defaultGracefulCloseTimeout)
|
||||
}
|
||||
if manager.shutdownGracePeriod != defaultGracefulCloseTimeout {
|
||||
t.Fatalf("manager shutdown grace = %s, want %s", manager.shutdownGracePeriod, defaultGracefulCloseTimeout)
|
||||
}
|
||||
control := newControl(nil)
|
||||
if control.closeFallbackDelay != defaultGracefulCloseTimeout {
|
||||
t.Fatalf("child close fallback = %s, want %s", control.closeFallbackDelay, defaultGracefulCloseTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOpenRequestSupportsEveryDetachedWindowKind(t *testing.T) {
|
||||
for _, kind := range []string{"workbench", "query-result", "ai-chat"} {
|
||||
if err := validateOpenRequest(OpenRequest{ID: "window-1", Kind: kind}); err != nil {
|
||||
t.Fatalf("validateOpenRequest(%q) returned error: %v", kind, err)
|
||||
}
|
||||
}
|
||||
if err := validateOpenRequest(OpenRequest{ID: "window-1", Kind: "unsupported"}); err == nil {
|
||||
t.Fatal("validateOpenRequest accepted an unsupported kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOpenGeneratesUniqueIDsAndRegistersMultipleWindows(t *testing.T) {
|
||||
starter := &fakeProcessStarter{nextPID: 100}
|
||||
manager := &Manager{
|
||||
@@ -135,6 +212,219 @@ func TestManagerOpenGeneratesUniqueIDsAndRegistersMultipleWindows(t *testing.T)
|
||||
waitForRegistrySize(t, manager, 0)
|
||||
}
|
||||
|
||||
func TestManagerOpenAcceptsReadyAtTimeoutBoundary(t *testing.T) {
|
||||
manager := &Manager{
|
||||
token: "test-token",
|
||||
endpoint: "http://127.0.0.1:43119",
|
||||
started: true,
|
||||
windows: make(map[string]*windowEntry),
|
||||
executable: "/tmp/GoNavi",
|
||||
openTimeout: time.Nanosecond,
|
||||
}
|
||||
starter := &readyBeforeReturnStarter{manager: manager, nextPID: 400}
|
||||
manager.starter = starter
|
||||
|
||||
for attempt := 0; attempt < 64; attempt++ {
|
||||
id := fmt.Sprintf("ready-at-timeout-%d", attempt)
|
||||
result := manager.Open(OpenRequest{ID: id, Kind: "workbench"})
|
||||
if !result.Success {
|
||||
t.Fatalf("Open attempt %d rejected an already-ready child: %#v", attempt, result)
|
||||
}
|
||||
|
||||
starter.mu.Lock()
|
||||
process := starter.started[len(starter.started)-1]
|
||||
starter.mu.Unlock()
|
||||
process.finish(nil)
|
||||
waitForRegistrySize(t, manager, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerSyncHostStateRetainsNewestRevisionAndOnlyEmitsToChildren(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Title: "GoNavi AI"},
|
||||
}
|
||||
type targetedCommand struct {
|
||||
targetID string
|
||||
command childCommand
|
||||
}
|
||||
commands := make(chan targetedCommand, 2)
|
||||
manager.emitToChild = func(targetID string, name string, args ...any) {
|
||||
if name != CommandEventName || len(args) != 1 {
|
||||
t.Fatalf("unexpected child event: %q %#v", name, args)
|
||||
}
|
||||
commands <- targetedCommand{targetID: targetID, command: args[0].(childCommand)}
|
||||
}
|
||||
mainEvents := 0
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(context.Context, string, ...any) {
|
||||
mainEvents++
|
||||
}
|
||||
|
||||
newestState := map[string]any{
|
||||
"activeTabId": "query-new",
|
||||
"activeContext": map[string]any{
|
||||
"connectionId": "conn-new",
|
||||
"dbName": "analytics",
|
||||
},
|
||||
}
|
||||
result := manager.SyncHostState(HostStateRequest{
|
||||
ID: "ai-chat",
|
||||
Revision: 2,
|
||||
StoreState: newestState,
|
||||
})
|
||||
if !result.Success {
|
||||
t.Fatalf("SyncHostState newest result = %#v", result)
|
||||
}
|
||||
newestState["activeTabId"] = "mutated-after-sync"
|
||||
|
||||
stale := manager.SyncHostState(HostStateRequest{
|
||||
ID: "ai-chat",
|
||||
Revision: 1,
|
||||
StoreState: map[string]any{
|
||||
"activeTabId": "query-stale",
|
||||
},
|
||||
})
|
||||
if !stale.Success || !strings.Contains(stale.Message, "stale") {
|
||||
t.Fatalf("SyncHostState stale result = %#v", stale)
|
||||
}
|
||||
|
||||
targeted := <-commands
|
||||
if targeted.targetID != "ai-chat" {
|
||||
t.Fatalf("host-state target = %q, want ai-chat", targeted.targetID)
|
||||
}
|
||||
command := targeted.command
|
||||
if command.ID != "ai-chat" || command.Action != "sync-host-state" {
|
||||
t.Fatalf("unexpected host-state command: %#v", command)
|
||||
}
|
||||
payload, ok := command.Payload.(hostStateInvalidationPayload)
|
||||
if !ok || payload.Revision != 2 {
|
||||
t.Fatalf("unexpected host-state payload: %#v", command.Payload)
|
||||
}
|
||||
select {
|
||||
case duplicate := <-commands:
|
||||
t.Fatalf("stale revision was emitted: %#v", duplicate)
|
||||
default:
|
||||
}
|
||||
if mainEvents != 0 {
|
||||
t.Fatalf("host state echoed to main Wails window %d times", mainEvents)
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
retained := manager.windows["ai-chat"].hostState
|
||||
manager.mu.RUnlock()
|
||||
if retained.Revision != 2 || retained.StoreState["activeTabId"] != "query-new" {
|
||||
t.Fatalf("retained host state = %#v", retained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRoutesCommandsAndAIStreamsToOnlyTheirTargetWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["workbench:query-a"] = &windowEntry{
|
||||
info: WindowInfo{ID: "workbench:query-a", Kind: "workbench"},
|
||||
}
|
||||
manager.runtimeCtx = context.Background()
|
||||
|
||||
type emittedEvent struct {
|
||||
targetID string
|
||||
name string
|
||||
args []any
|
||||
}
|
||||
reliable := make(chan emittedEvent, 2)
|
||||
bestEffort := make(chan emittedEvent, 2)
|
||||
broadcast := make(chan emittedEvent, 2)
|
||||
mainEvents := make(chan emittedEvent, 2)
|
||||
manager.emitToChild = func(targetID string, name string, args ...any) {
|
||||
reliable <- emittedEvent{targetID: targetID, name: name, args: args}
|
||||
}
|
||||
manager.emitToChildBestEffort = func(targetID string, name string, args ...any) {
|
||||
bestEffort <- emittedEvent{targetID: targetID, name: name, args: args}
|
||||
}
|
||||
manager.emitToChildren = func(name string, args ...any) {
|
||||
broadcast <- emittedEvent{name: name, args: args}
|
||||
}
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
mainEvents <- emittedEvent{name: name, args: args}
|
||||
}
|
||||
|
||||
if result := manager.Focus("workbench:query-a"); !result.Success {
|
||||
t.Fatalf("Focus result = %#v", result)
|
||||
}
|
||||
focus := <-reliable
|
||||
if focus.targetID != "workbench:query-a" || focus.name != CommandEventName {
|
||||
t.Fatalf("focus event = %#v", focus)
|
||||
}
|
||||
if command := focus.args[0].(childCommand); command.Action != "focus" {
|
||||
t.Fatalf("focus command = %#v", command)
|
||||
}
|
||||
|
||||
if result := manager.Close("workbench:query-a"); !result.Success {
|
||||
t.Fatalf("Close result = %#v", result)
|
||||
}
|
||||
closeEvent := <-reliable
|
||||
if closeEvent.targetID != "workbench:query-a" || closeEvent.name != CommandEventName {
|
||||
t.Fatalf("close event = %#v", closeEvent)
|
||||
}
|
||||
if command := closeEvent.args[0].(childCommand); command.Action != "close" {
|
||||
t.Fatalf("close command = %#v", command)
|
||||
}
|
||||
|
||||
manager.emit("ai:stream:session-1", map[string]any{"content": "chunk"})
|
||||
stream := <-bestEffort
|
||||
if stream.targetID != "ai-chat" || stream.name != "ai:stream:session-1" {
|
||||
t.Fatalf("AI stream event = %#v", stream)
|
||||
}
|
||||
if main := <-mainEvents; main.name != "ai:stream:session-1" {
|
||||
t.Fatalf("main AI stream event = %#v", main)
|
||||
}
|
||||
select {
|
||||
case leaked := <-broadcast:
|
||||
t.Fatalf("AI stream was broadcast to every child: %#v", leaked)
|
||||
default:
|
||||
}
|
||||
|
||||
manager.emit("sqlfile:progress", map[string]any{"current": 1})
|
||||
if normal := <-broadcast; normal.name != "sqlfile:progress" {
|
||||
t.Fatalf("normal backend event = %#v", normal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
||||
hostState: HostStateRequest{
|
||||
ID: "ai-chat",
|
||||
Revision: 7,
|
||||
StoreState: map[string]any{"activeTabId": "query-7"},
|
||||
},
|
||||
}
|
||||
|
||||
request := authenticatedRequest(manager, http.MethodGet, HostStatePath, "ai-chat", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("host-state status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var snapshot HostStateRequest
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&snapshot); err != nil {
|
||||
t.Fatalf("decode host-state response: %v", err)
|
||||
}
|
||||
if snapshot.ID != "ai-chat" || snapshot.Revision != 7 || snapshot.StoreState["activeTabId"] != "query-7" {
|
||||
t.Fatalf("unexpected host-state response: %#v", snapshot)
|
||||
}
|
||||
|
||||
manager.windows["workbench:query-empty"] = &windowEntry{
|
||||
info: WindowInfo{ID: "workbench:query-empty", Kind: "workbench"},
|
||||
}
|
||||
emptyRequest := authenticatedRequest(manager, http.MethodGet, HostStatePath, "workbench:query-empty", nil)
|
||||
emptyRecorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(emptyRecorder, emptyRequest)
|
||||
if emptyRecorder.Code != http.StatusNoContent {
|
||||
t.Fatalf("empty host-state status = %d, want 204", emptyRecorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["window-1"] = &windowEntry{
|
||||
@@ -199,6 +489,39 @@ 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{}),
|
||||
}
|
||||
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":"open-ai-settings","payload":{"id":"ai-chat","kind":"ai-chat"}}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("open-ai-settings status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
event := receiveEvent(t, events)
|
||||
if event.ID != "ai-chat" || event.Kind != "ai-chat" || event.Action != "open-ai-settings" {
|
||||
t.Fatalf("unexpected open-ai-settings event: %#v", event)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
exitReason := manager.windows["ai-chat"].exitReason
|
||||
manager.mu.RUnlock()
|
||||
if exitReason != "" {
|
||||
t.Fatalf("open-ai-settings marked child terminal: %q", exitReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.started = true
|
||||
@@ -361,6 +684,391 @@ func TestRequestedCloseReasonSurvivesForcedProcessExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCancelCloseInvalidatesForcedKill(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.closeFallbackDelay = 20 * time.Millisecond
|
||||
process := &fakeChildProcess{pid: 44, done: make(chan error, 1), killed: make(chan struct{})}
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
||||
process: process,
|
||||
}
|
||||
|
||||
if result := manager.Close("ai-chat"); !result.Success {
|
||||
t.Fatalf("Close result = %#v", result)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
entry := manager.windows["ai-chat"]
|
||||
closeSent := entry.info.CloseSent
|
||||
exitReason := entry.exitReason
|
||||
manager.mu.RUnlock()
|
||||
if !closeSent || exitReason != ExitReasonRequested {
|
||||
t.Fatalf("pending close state = closeSent %v reason %q", closeSent, exitReason)
|
||||
}
|
||||
|
||||
if result := manager.CancelClose("ai-chat"); !result.Success {
|
||||
t.Fatalf("CancelClose result = %#v", result)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
closeSent = entry.info.CloseSent
|
||||
exitReason = entry.exitReason
|
||||
manager.mu.RUnlock()
|
||||
if closeSent || exitReason != "" {
|
||||
t.Fatalf("cancelled close state = closeSent %v reason %q", closeSent, exitReason)
|
||||
}
|
||||
select {
|
||||
case <-process.killed:
|
||||
t.Fatal("cancelled parent close still killed the child")
|
||||
case <-time.After(60 * time.Millisecond):
|
||||
}
|
||||
if result := manager.Close("ai-chat"); !result.Success {
|
||||
t.Fatalf("close retry result = %#v", result)
|
||||
}
|
||||
select {
|
||||
case <-process.killed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("close retry did not schedule a fresh force-kill fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerCancelCloseRollsBackAcceptedTerminalReason(t *testing.T) {
|
||||
for _, terminalReason := range []string{ExitReasonAttached, ExitReasonWindowClosed} {
|
||||
t.Run(terminalReason, func(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
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)
|
||||
}
|
||||
}
|
||||
process := &fakeChildProcess{
|
||||
pid: 45,
|
||||
done: make(chan error, 1),
|
||||
killed: make(chan struct{}),
|
||||
}
|
||||
manager.windows["workbench:query-1"] = &windowEntry{
|
||||
info: WindowInfo{
|
||||
ID: "workbench:query-1",
|
||||
Kind: "workbench",
|
||||
CloseSent: true,
|
||||
},
|
||||
process: process,
|
||||
exitReason: terminalReason,
|
||||
acknowledged: true,
|
||||
closeGeneration: 1,
|
||||
}
|
||||
|
||||
if result := manager.CancelClose("workbench:query-1"); !result.Success {
|
||||
t.Fatalf("CancelClose result = %#v", result)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
entry := manager.windows["workbench:query-1"]
|
||||
closeSent := entry.info.CloseSent
|
||||
exitReason := entry.exitReason
|
||||
manager.mu.RUnlock()
|
||||
if closeSent || exitReason != "" {
|
||||
t.Fatalf("cancelled terminal state = closeSent %v reason %q", closeSent, exitReason)
|
||||
}
|
||||
|
||||
go manager.watchProcess("workbench:query-1", process)
|
||||
process.finish(errors.New("exit status 9"))
|
||||
event := receiveEvent(t, events)
|
||||
payload := event.Payload.(map[string]any)
|
||||
if payload["reason"] != ExitReasonProcessError {
|
||||
t.Fatalf("exit reason after rollback = %#v, want %q", payload["reason"], ExitReasonProcessError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelCloseActionClearsPendingStateAndNotifiesMainWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", CloseSent: true},
|
||||
exitReason: ExitReasonRequested,
|
||||
}
|
||||
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":"ai-chat","revision":9}}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("cancel-close status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
manager.mu.RLock()
|
||||
entry := manager.windows["ai-chat"]
|
||||
closeSent := entry.info.CloseSent
|
||||
exitReason := entry.exitReason
|
||||
manager.mu.RUnlock()
|
||||
if closeSent || exitReason != "" {
|
||||
t.Fatalf("cancel-close state = closeSent %v reason %q", closeSent, exitReason)
|
||||
}
|
||||
event := receiveEvent(t, events)
|
||||
if event.Action != "cancel-close" || event.ID != "ai-chat" {
|
||||
t.Fatalf("unexpected cancel-close event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostEventActionIsForwardedWithoutChangingTerminalState(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
||||
}
|
||||
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":"host-event","payload":{"name":"gonavi:insert-sql","detail":{"sql":"select 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("host-event status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
event := receiveEvent(t, events)
|
||||
if event.ID != "ai-chat" || event.Action != "host-event" {
|
||||
t.Fatalf("unexpected host-event: %#v", event)
|
||||
}
|
||||
manager.mu.RLock()
|
||||
exitReason := manager.windows["ai-chat"].exitReason
|
||||
manager.mu.RUnlock()
|
||||
if exitReason != "" {
|
||||
t.Fatalf("host-event marked child terminal: %q", exitReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedEventsOnlyBroadcastChildOwnedLifecycle(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.runtimeCtx = context.Background()
|
||||
mainEvents := make(chan Event, 2)
|
||||
type targetedEvent struct {
|
||||
targetID string
|
||||
event Event
|
||||
}
|
||||
childEvents := make(chan targetedEvent, 2)
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
mainEvents <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
manager.emitToChild = func(targetID string, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
childEvents <- targetedEvent{targetID: targetID, event: args[0].(Event)}
|
||||
}
|
||||
}
|
||||
|
||||
manager.emitDetached(Event{
|
||||
ID: "ai-chat",
|
||||
Kind: "ai-chat",
|
||||
Action: "sync",
|
||||
Payload: map[string]any{"storeState": map[string]any{"history": "large"}},
|
||||
})
|
||||
_ = receiveEvent(t, mainEvents)
|
||||
select {
|
||||
case event := <-childEvents:
|
||||
t.Fatalf("top-level sync leaked to children: %#v", event)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
|
||||
manager.emitDetached(Event{
|
||||
ID: "query-result:query-1:r1",
|
||||
Kind: "query-result",
|
||||
Action: "opened",
|
||||
Payload: map[string]any{
|
||||
"ownerWindowId": "workbench:query-1",
|
||||
},
|
||||
})
|
||||
_ = receiveEvent(t, mainEvents)
|
||||
owned := <-childEvents
|
||||
if owned.targetID != "workbench:query-1" || owned.event.ID != "query-result:query-1:r1" {
|
||||
t.Fatalf("unexpected child-owned event: %#v", owned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRevisionRejectsOutOfOrderSyncWithoutEmitting(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["window-1"] = &windowEntry{
|
||||
info: WindowInfo{ID: "window-1", Kind: "workbench"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
postAction := func(body string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", strings.NewReader(body))
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
if recorder := postAction(`{"action":"sync","payload":{"revision":7,"storeState":{"value":"new"}}}`); recorder.Code != http.StatusOK {
|
||||
t.Fatalf("new sync status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if recorder := postAction(`{"action":"sync","payload":{"revision":6,"storeState":{"value":"old"}}}`); recorder.Code != http.StatusOK {
|
||||
t.Fatalf("stale sync status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
event := receiveEvent(t, events)
|
||||
payload := event.Payload.(map[string]any)
|
||||
if event.Action != "sync" || payload["revision"] != float64(7) {
|
||||
t.Fatalf("unexpected newest sync event: %#v", event)
|
||||
}
|
||||
select {
|
||||
case stale := <-events:
|
||||
t.Fatalf("stale sync emitted an event: %#v", stale)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
manager.mu.RLock()
|
||||
revision := manager.windows["window-1"].actionRevision
|
||||
manager.mu.RUnlock()
|
||||
if revision != 7 {
|
||||
t.Fatalf("action revision = %d, want 7", revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRevisionPreventsStaleTerminalTransition(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["window-1"] = &windowEntry{
|
||||
info: WindowInfo{ID: "window-1", Kind: "workbench"},
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
postAction := func(body string) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", strings.NewReader(body))
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("action status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
postAction(`{"action":"attach","payload":{"revision":12}}`)
|
||||
postAction(`{"action":"close","payload":{"revision":11}}`)
|
||||
|
||||
event := receiveEvent(t, events)
|
||||
if event.Action != "attach" {
|
||||
t.Fatalf("terminal event = %#v, want attach", event)
|
||||
}
|
||||
select {
|
||||
case stale := <-events:
|
||||
t.Fatalf("stale terminal action emitted an event: %#v", stale)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
manager.mu.RLock()
|
||||
entry := manager.windows["window-1"]
|
||||
exitReason := entry.exitReason
|
||||
revision := entry.actionRevision
|
||||
manager.mu.RUnlock()
|
||||
if exitReason != ExitReasonAttached || revision != 12 {
|
||||
t.Fatalf("terminal state = reason %q revision %d", exitReason, revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerShutdownAllowsGracefulChildExitBeforeKilling(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.shutdownGracePeriod = 250 * time.Millisecond
|
||||
process := &fakeChildProcess{pid: 45, done: make(chan error, 1), killed: make(chan struct{})}
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
||||
process: process,
|
||||
acknowledged: true,
|
||||
}
|
||||
commands := make(chan childCommand, 1)
|
||||
manager.emitToChild = func(targetID string, name string, args ...any) {
|
||||
if targetID != "ai-chat" {
|
||||
t.Fatalf("shutdown target = %q, want ai-chat", targetID)
|
||||
}
|
||||
if name == CommandEventName && len(args) == 1 {
|
||||
commands <- args[0].(childCommand)
|
||||
}
|
||||
}
|
||||
go manager.watchProcess("ai-chat", process)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
manager.shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case command := <-commands:
|
||||
if command.ID != "ai-chat" || command.Action != "close" || command.Reason != ExitReasonParentShutdown {
|
||||
t.Fatalf("unexpected shutdown command: %#v", command)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown did not request graceful child close")
|
||||
}
|
||||
select {
|
||||
case <-process.killed:
|
||||
t.Fatal("shutdown killed child before its grace period")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
process.finish(nil)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("shutdown did not finish after child exited")
|
||||
}
|
||||
select {
|
||||
case <-process.killed:
|
||||
t.Fatal("cooperative child was killed during shutdown")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerShutdownKillsChildAfterGracePeriod(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.shutdownGracePeriod = 20 * time.Millisecond
|
||||
process := &fakeChildProcess{pid: 46, done: make(chan error, 1), killed: make(chan struct{})}
|
||||
manager.windows["ai-chat"] = &windowEntry{
|
||||
info: WindowInfo{ID: "ai-chat", Kind: "ai-chat"},
|
||||
process: process,
|
||||
}
|
||||
commands := make(chan childCommand, 1)
|
||||
manager.emitToChild = func(targetID string, name string, args ...any) {
|
||||
if targetID != "ai-chat" {
|
||||
t.Fatalf("shutdown target = %q, want ai-chat", targetID)
|
||||
}
|
||||
if name == CommandEventName && len(args) == 1 {
|
||||
commands <- args[0].(childCommand)
|
||||
}
|
||||
}
|
||||
go manager.watchProcess("ai-chat", process)
|
||||
|
||||
manager.shutdown()
|
||||
select {
|
||||
case command := <-commands:
|
||||
if command.Reason != ExitReasonParentShutdown {
|
||||
t.Fatalf("shutdown reason = %q", command.Reason)
|
||||
}
|
||||
default:
|
||||
t.Fatal("shutdown did not broadcast graceful close")
|
||||
}
|
||||
select {
|
||||
case <-process.killed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("unresponsive child was not killed after grace period")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOpenFailureBeforeBootstrapKeepsWindowUnacknowledged(t *testing.T) {
|
||||
starter := &fakeProcessStarter{nextPID: 200}
|
||||
manager := &Manager{
|
||||
@@ -462,6 +1170,30 @@ func environmentValue(environment []string, name string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestDetachedChildProcessUsesPositionalModeAndFiltersWailsDevEnvironment(t *testing.T) {
|
||||
if argument := detachedWindowProcessArgument(); argument != "detached-window" || strings.HasPrefix(argument, "-") {
|
||||
t.Fatalf("detached child argument = %q, want positional detached-window", argument)
|
||||
}
|
||||
environment := filterDetachedChildEnvironment([]string{
|
||||
"PATH=/usr/bin",
|
||||
"assetdir=frontend/dist",
|
||||
"devserver=127.0.0.1:34115",
|
||||
"frontenddevserverurl=http://127.0.0.1:5173",
|
||||
"loglevel=debug",
|
||||
})
|
||||
for _, name := range []string{"assetdir", "devserver", "frontenddevserverurl"} {
|
||||
if value := environmentValue(environment, name); value != "" {
|
||||
t.Fatalf("filtered environment retained %s=%q", name, value)
|
||||
}
|
||||
}
|
||||
if value := environmentValue(environment, "PATH"); value != "/usr/bin" {
|
||||
t.Fatalf("PATH = %q, want /usr/bin", value)
|
||||
}
|
||||
if value := environmentValue(environment, "loglevel"); value != "debug" {
|
||||
t.Fatalf("loglevel = %q, want debug", value)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForRegistrySize(t *testing.T, manager *Manager, size int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
|
||||
@@ -3,8 +3,15 @@ package nativewindow
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var detachedChildFilteredEnvironment = map[string]struct{}{
|
||||
"assetdir": {},
|
||||
"devserver": {},
|
||||
"frontenddevserverurl": {},
|
||||
}
|
||||
|
||||
type processSpec struct {
|
||||
Executable string
|
||||
Env []string
|
||||
@@ -23,7 +30,7 @@ type processStarter interface {
|
||||
type execProcessStarter struct{}
|
||||
|
||||
func (execProcessStarter) Start(spec processSpec) (childProcess, error) {
|
||||
command := exec.Command(spec.Executable, DetachedWindowArgument)
|
||||
command := exec.Command(spec.Executable, detachedWindowProcessArgument())
|
||||
command.Env = spec.Env
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
@@ -33,6 +40,22 @@ func (execProcessStarter) Start(spec processSpec) (childProcess, error) {
|
||||
return &execChildProcess{command: command}, nil
|
||||
}
|
||||
|
||||
func detachedWindowProcessArgument() string {
|
||||
return strings.TrimLeft(DetachedWindowArgument, "-")
|
||||
}
|
||||
|
||||
func filterDetachedChildEnvironment(environment []string) []string {
|
||||
filtered := make([]string, 0, len(environment))
|
||||
for _, item := range environment {
|
||||
name, _, _ := strings.Cut(item, "=")
|
||||
if _, blocked := detachedChildFilteredEnvironment[strings.ToLower(strings.TrimSpace(name))]; blocked {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
type execChildProcess struct {
|
||||
command *exec.Cmd
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ func detachedRuntimeBridgeScript() string {
|
||||
}
|
||||
return detached.bootstrapPromise;
|
||||
},
|
||||
present: function () {
|
||||
if (!control || typeof control.Present !== 'function') {
|
||||
return Promise.reject(new Error('native window present control is unavailable'));
|
||||
}
|
||||
return control.Present();
|
||||
},
|
||||
action: function (action, payload) {
|
||||
return bridge.Action(String(action || ''), payload || {});
|
||||
}
|
||||
@@ -92,14 +98,19 @@ func detachedRuntimeBridgeScript() string {
|
||||
var detachedWindowIDPromise = Promise.resolve(bridge.WindowID()).then(function (windowID) {
|
||||
return String(windowID || '');
|
||||
});
|
||||
var requestGracefulClose = function (reason) {
|
||||
window.dispatchEvent(new CustomEvent('` + GracefulCloseRequestEventName + `', {
|
||||
detail: { reason: String(reason || '') }
|
||||
}));
|
||||
};
|
||||
|
||||
var runtime = window.runtime || {};
|
||||
if (typeof runtime.EventsOnMultiple === 'function') {
|
||||
runtime.EventsOnMultiple('` + CommandEventName + `', function (command) {
|
||||
detachedWindowIDPromise.then(function (windowID) {
|
||||
if (!command || String(command.id || '') !== windowID) return;
|
||||
if (command.action === 'close' && control && typeof control.Close === 'function') {
|
||||
control.Close();
|
||||
if (command.action === 'close') {
|
||||
requestGracefulClose(command.reason);
|
||||
} else if (command.action === 'focus' && control && typeof control.Focus === 'function') {
|
||||
control.Focus();
|
||||
}
|
||||
|
||||
@@ -35,3 +35,32 @@ func TestRuntimeExposesParentWindowManagerInsideDetachedChildren(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRoutesParentCloseThroughGracefulFrontendEvent(t *testing.T) {
|
||||
script := detachedRuntimeBridgeScript()
|
||||
for _, expected := range []string{
|
||||
GracefulCloseRequestEventName,
|
||||
"requestGracefulClose(command.reason)",
|
||||
"window.dispatchEvent(new CustomEvent",
|
||||
} {
|
||||
if !strings.Contains(script, expected) {
|
||||
t.Fatalf("runtime bridge is missing graceful close marker %q", expected)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "control.Close();") {
|
||||
t.Fatal("parent close command still quits before the frontend can flush state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExposesTwoPhaseChildPresentation(t *testing.T) {
|
||||
script := detachedRuntimeBridgeScript()
|
||||
for _, expected := range []string{
|
||||
"present: function ()",
|
||||
"control.Present()",
|
||||
"native window present control is unavailable",
|
||||
} {
|
||||
if !strings.Contains(script, expected) {
|
||||
t.Fatalf("runtime bridge is missing presentation marker %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,12 +14,17 @@ const (
|
||||
BootstrapPath = "/__gonavi/detached/bootstrap"
|
||||
ActionPath = "/__gonavi/detached/action"
|
||||
ControlPath = "/__gonavi/detached/control"
|
||||
HostStatePath = "/__gonavi/detached/host-state"
|
||||
CommandStatePath = "/__gonavi/detached/command-state"
|
||||
RuntimePath = "/__gonavi/detached-runtime.js"
|
||||
InvokePath = "/__gonavi/api/invoke"
|
||||
EventsPath = "/__gonavi/events"
|
||||
|
||||
MainEventName = "gonavi:native-detached-event"
|
||||
CommandEventName = "gonavi:native-detached-command"
|
||||
// GracefulCloseRequestEventName is dispatched inside a detached WebView so
|
||||
// React can flush state before the native child process exits.
|
||||
GracefulCloseRequestEventName = "gonavi:native-detached-request-close"
|
||||
|
||||
ExitReasonRequested = "requested"
|
||||
ExitReasonWindowClosed = "window-closed"
|
||||
@@ -34,6 +39,7 @@ const (
|
||||
// Detached query results can be substantially larger than ordinary RPC
|
||||
// payloads. Keep one shared ceiling for child actions and parent responses.
|
||||
maxDetachedJSONBytes int64 = 512 << 20
|
||||
maxDetachedSSEEventBytes = maxDetachedJSONBytes + (1 << 20)
|
||||
)
|
||||
|
||||
// OpenRequest describes one independently movable native window. X and Y use
|
||||
@@ -80,6 +86,19 @@ type OperationResult struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// HostStateRequest carries main-window state that an active detached child
|
||||
// needs to follow. Revision is strictly monotonic per child window ID.
|
||||
type HostStateRequest struct {
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
StoreState map[string]any `json:"storeState"`
|
||||
}
|
||||
|
||||
type hostStatePayload struct {
|
||||
Revision int64 `json:"revision"`
|
||||
StoreState map[string]any `json:"storeState"`
|
||||
}
|
||||
|
||||
// Event is emitted to the main Wails window. Action mirrors the detached HTTP
|
||||
// action protocol so the frontend can use one reducer for child messages and
|
||||
// process-exit notifications.
|
||||
@@ -102,8 +121,10 @@ type controlRequest struct {
|
||||
}
|
||||
|
||||
type childCommand struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
func normalizeOpenRequest(request OpenRequest) OpenRequest {
|
||||
|
||||
222
internal/nativewindow/visibility_gate_test.go
Normal file
222
internal/nativewindow/visibility_gate_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetachedChildQueuesFocusUntilFrontendReadyHandshake(t *testing.T) {
|
||||
bridge, 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.markDOMReady(ctx)
|
||||
if result := control.Focus(); !result.Success {
|
||||
t.Fatalf("pre-ready Focus result = %#v", result)
|
||||
}
|
||||
if shows != 0 || focuses != 0 {
|
||||
t.Fatalf("pre-ready presentation = show %d focus %d, want 0/0", shows, focuses)
|
||||
}
|
||||
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
|
||||
t.Fatalf("ready Action result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 1 {
|
||||
t.Fatalf("ready presentation = show %d focus %d, want 1/1", shows, focuses)
|
||||
}
|
||||
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
|
||||
t.Fatalf("repeated ready Action result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 1 {
|
||||
t.Fatalf("repeated ready presentation = show %d focus %d, want 1/1", shows, focuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedChildShowsAfterReadyAndFocusesWithoutShowingAgain(t *testing.T) {
|
||||
bridge, 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.markDOMReady(ctx)
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
|
||||
t.Fatalf("ready Action result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 0 {
|
||||
t.Fatalf("ready presentation = show %d focus %d, want 1/0", shows, focuses)
|
||||
}
|
||||
|
||||
if result := control.Focus(); !result.Success {
|
||||
t.Fatalf("post-ready Focus result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 1 {
|
||||
t.Fatalf("post-ready presentation = show %d focus %d, want 1/1", shows, focuses)
|
||||
}
|
||||
|
||||
if result := control.Focus(); !result.Success {
|
||||
t.Fatalf("later Focus result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 2 {
|
||||
t.Fatalf("later presentation = show %d focus %d, want 1/2", shows, focuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedChildPresentsBeforePaintReadyWithoutShowingTwice(t *testing.T) {
|
||||
bridge, control := newVisibilityTestChild()
|
||||
ctx := context.Background()
|
||||
InitializeControl(control, ctx)
|
||||
control.markDOMReady(ctx)
|
||||
|
||||
shows := 0
|
||||
control.showWindow = func(context.Context) { shows++ }
|
||||
if result := control.Present(); !result.Success {
|
||||
t.Fatalf("Present result = %#v", result)
|
||||
}
|
||||
if shows != 1 {
|
||||
t.Fatalf("present shows = %d, want 1", shows)
|
||||
}
|
||||
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
|
||||
t.Fatalf("ready Action result = %#v", result)
|
||||
}
|
||||
if shows != 1 {
|
||||
t.Fatalf("post-ready shows = %d, want 1", shows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedChildPresentConsumesQueuedFocus(t *testing.T) {
|
||||
bridge, 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.markDOMReady(ctx)
|
||||
|
||||
if result := control.Focus(); !result.Success {
|
||||
t.Fatalf("pre-present Focus result = %#v", result)
|
||||
}
|
||||
if shows != 0 || focuses != 0 {
|
||||
t.Fatalf("queued focus presentation = show %d focus %d, want 0/0", shows, focuses)
|
||||
}
|
||||
if result := control.Present(); !result.Success {
|
||||
t.Fatalf("Present result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 1 {
|
||||
t.Fatalf("presented queued focus = show %d focus %d, want 1/1", shows, focuses)
|
||||
}
|
||||
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
|
||||
t.Fatalf("ready Action result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 1 {
|
||||
t.Fatalf("post-ready presentation = show %d focus %d, want 1/1", shows, focuses)
|
||||
}
|
||||
if result := control.Focus(); !result.Success {
|
||||
t.Fatalf("post-ready Focus result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 2 {
|
||||
t.Fatalf("repeated focus presentation = show %d focus %d, want 1/2", shows, focuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedChildWaitsForDOMReadyEvenAfterFrontendHandshake(t *testing.T) {
|
||||
bridge, control := newVisibilityTestChild()
|
||||
ctx := context.Background()
|
||||
InitializeControl(control, ctx)
|
||||
|
||||
shows := 0
|
||||
focuses := 0
|
||||
posts := 0
|
||||
control.showWindow = func(context.Context) { shows++ }
|
||||
control.focusWindow = func(context.Context) { focuses++ }
|
||||
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
posts++
|
||||
return successfulVisibilityResponse(), nil
|
||||
})
|
||||
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); result.Success {
|
||||
t.Fatalf("pre-DOM ready Action result = %#v, want failure", result)
|
||||
}
|
||||
if result := control.Focus(); !result.Success {
|
||||
t.Fatalf("pre-DOM Focus result = %#v", result)
|
||||
}
|
||||
if shows != 0 || focuses != 0 || posts != 0 {
|
||||
t.Fatalf("pre-DOM state = show %d focus %d post %d, want 0/0/0", shows, focuses, posts)
|
||||
}
|
||||
|
||||
control.markDOMReady(ctx)
|
||||
if shows != 0 || focuses != 0 || posts != 0 {
|
||||
t.Fatalf("DOM-ready state before retry = show %d focus %d post %d, want 0/0/0", shows, focuses, posts)
|
||||
}
|
||||
if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success {
|
||||
t.Fatalf("retried ready Action result = %#v", result)
|
||||
}
|
||||
if shows != 1 || focuses != 1 {
|
||||
t.Fatalf("DOM-ready presentation = show %d focus %d, want 1/1", shows, focuses)
|
||||
}
|
||||
if posts != 1 {
|
||||
t.Fatalf("parent ready posts = %d, want 1", posts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedChildShowsBeforePostingParentReady(t *testing.T) {
|
||||
bridge, control := newVisibilityTestChild()
|
||||
ctx := context.Background()
|
||||
InitializeControl(control, ctx)
|
||||
control.markDOMReady(ctx)
|
||||
|
||||
steps := make([]string, 0, 2)
|
||||
control.showWindow = func(context.Context) {
|
||||
steps = append(steps, "show")
|
||||
}
|
||||
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
steps = append(steps, "post-parent-ready")
|
||||
return successfulVisibilityResponse(), nil
|
||||
})
|
||||
|
||||
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,post-parent-ready" {
|
||||
t.Fatalf("ready sequence = %q, want show,post-parent-ready", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newVisibilityTestChild() (*Bridge, *Control) {
|
||||
bridge := newBridge(ChildOptions{
|
||||
ParentURL: "http://127.0.0.1:43119",
|
||||
Token: "test-token",
|
||||
ID: "window-1",
|
||||
Kind: "workbench",
|
||||
})
|
||||
bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return successfulVisibilityResponse(), nil
|
||||
})
|
||||
control := newControl(bridge)
|
||||
bridge.setReadyHandler(control.markFrontendReady)
|
||||
return bridge, control
|
||||
}
|
||||
|
||||
func successfulVisibilityResponse() *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"window-1"}`)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user