feat(native-window): 完善原生独立窗口与跨屏拖拽

- 支持全部工作台标签与 AI 对话在多显示器中原生独立展示
- 修复拖拽指针捕获,恢复关闭、右键菜单和跨窗口释放识别
- 拆分子窗启动入口并按需加载语言与工作台内容
- 内容与 Monaco 完成可见绘制后再提交迁移,消除白屏和加载闪烁
- 加固结果编辑、宿主同步及关闭、超时和焦点竞态
This commit is contained in:
Syngnat
2026-07-17 16:06:24 +08:00
parent a86f5c6078
commit 3f4b247faa
50 changed files with 7708 additions and 379 deletions

View File

@@ -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}
}

View 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)
}
}

View File

@@ -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) {

View 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
}

View 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:
}
}

View 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)
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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();
}

View File

@@ -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)
}
}
}

View File

@@ -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 {

View 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),
}
}