Files
MyGoNavi/internal/nativewindow/manager.go
Syngnat 5c3535c85d feat(native-window): 支持 macOS Dock 管理独立窗口
- 在主程序 Dock 菜单动态列出并聚焦已就绪的独立窗口
- 在 Wails 启动前固定子进程为 Accessory,消除临时 Dock 图标闪现
- 按多显示器可见区域修正越界窗口并回写实际坐标
- 将自定义 AI 面板快捷键同步到独立窗口并转交主窗口处理
- 补齐 Go、Objective-C、前端及 Wails 绑定回归测试
2026-07-21 18:52:54 +08:00

1206 lines
32 KiB
Go

package nativewindow
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"math"
"net"
"net/http"
"os"
"reflect"
"strings"
"sync"
"time"
aiservice "GoNavi-Wails/internal/ai/service"
appcore "GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/uievents"
"GoNavi-Wails/internal/webserver"
"github.com/google/uuid"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
const (
envParentURL = "GONAVI_DETACHED_PARENT_URL"
envToken = "GONAVI_DETACHED_TOKEN"
envWindowID = "GONAVI_DETACHED_WINDOW_ID"
envKind = "GONAVI_DETACHED_KIND"
envTitle = "GONAVI_DETACHED_TITLE"
envX = "GONAVI_DETACHED_X"
envY = "GONAVI_DETACHED_Y"
envWidth = "GONAVI_DETACHED_WIDTH"
envHeight = "GONAVI_DETACHED_HEIGHT"
)
const (
defaultGracefulCloseTimeout = 10 * time.Second
defaultOpenReadyTimeout = 10 * time.Second
)
type processExit struct {
err error
}
type windowEntry struct {
info WindowInfo
payload any
hostState HostStateRequest
ownerID string
process childProcess
exitReason string
closeGeneration uint64
actionRevision int64
ready chan struct{}
done chan processExit
readyOnce sync.Once
doneOnce sync.Once
acknowledged bool
}
// Manager owns the loopback bridge and the registry of detached Wails child
// processes. It is intended to be bound to the main Wails window.
type Manager struct {
mu sync.RWMutex
shared *webserver.SharedRuntime
token string
endpoint string
listener net.Listener
httpServer *http.Server
runtimeCtx context.Context
started bool
closing bool
windows map[string]*windowEntry
starter processStarter
executable string
resolveBounds func(WindowBounds) WindowBounds
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
// desktop backend instances. InitializeLifecycle starts its random loopback
// listener after Wails provides the runtime context.
func NewManager(assetFS fs.FS, app *appcore.App, ai *aiservice.Service) (*Manager, error) {
token, err := newBridgeToken()
if err != nil {
return nil, fmt.Errorf("create detached-window token failed: %w", err)
}
shared, err := webserver.NewSharedRuntime(assetFS, app, ai, webserver.SharedRuntimeOptions{
RuntimeBridgePath: RuntimePath,
RuntimeBridgeScript: detachedRuntimeBridgeScript(),
})
if err != nil {
return nil, err
}
executable, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve executable failed: %w", err)
}
manager := &Manager{
shared: shared,
token: token,
windows: make(map[string]*windowEntry),
starter: execProcessStarter{},
executable: executable,
resolveBounds: normalizeDetachedWindowBounds,
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,
}
installDetachedDockMenu()
return manager, nil
}
func newBridgeToken() (string, error) {
payload := make([]byte, 32)
if _, err := rand.Read(payload); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
// InitializeLifecycle starts the loopback bridge and attaches the main Wails
// runtime. Call it before initialising App and AI lifecycle contexts.
func InitializeLifecycle(manager *Manager, ctx context.Context) error {
if manager == nil {
return fmt.Errorf("native window manager is unavailable")
}
return manager.initialize(ctx)
}
// WithLifecycleContext makes App/AI events fan out to both the main Wails
// window and every detached child. It does not rerun either backend lifecycle.
func WithLifecycleContext(manager *Manager, ctx context.Context) context.Context {
if manager == nil {
return ctx
}
return uievents.WithEmitter(ctx, managerEventEmitter{manager: manager})
}
type managerEventEmitter struct {
manager *Manager
}
func (e managerEventEmitter) Emit(name string, args ...any) {
e.manager.emit(name, args...)
}
// ShutdownLifecycle closes child processes and the private loopback server.
func ShutdownLifecycle(manager *Manager) {
if manager != nil {
manager.shutdown()
}
}
func (m *Manager) initialize(ctx context.Context) error {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return fmt.Errorf("start detached-window bridge failed: %w", err)
}
m.mu.Lock()
if m.started {
m.mu.Unlock()
_ = listener.Close()
return nil
}
m.runtimeCtx = ctx
m.listener = listener
m.endpoint = "http://" + listener.Addr().String()
m.started = true
httpServer := &http.Server{
Handler: m.authenticatedHandler(),
ReadHeaderTimeout: 10 * time.Second,
}
m.httpServer = httpServer
m.mu.Unlock()
registerDetachedDockMenuManager(m)
go func() {
_ = httpServer.Serve(listener)
}()
return nil
}
// emit retains normal main-window delivery and also copies backend events to
// the child-side Go SSE clients.
func (m *Manager) emit(name string, args ...any) {
if m == nil || strings.TrimSpace(name) == "" {
return
}
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 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 {
return m.open(request, "")
}
func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
if m == nil {
return operationFailure("native window manager is unavailable")
}
request = normalizeOpenRequest(request)
if m.resolveBounds != nil {
bounds := m.resolveBounds(WindowBounds{
X: request.X, Y: request.Y, Width: request.Width, Height: request.Height,
})
request.X = bounds.X
request.Y = bounds.Y
request.Width = bounds.Width
request.Height = bounds.Height
}
request.ID = strings.TrimSpace(request.ID)
if request.ID == "" {
request.ID = uuid.NewString()
}
if err := validateOpenRequest(request); err != nil {
return operationFailure(err.Error())
}
m.mu.Lock()
if !m.started || m.closing || m.endpoint == "" {
m.mu.Unlock()
return operationFailure("native window manager is not running")
}
if _, exists := m.windows[request.ID]; exists {
m.mu.Unlock()
result := m.Focus(request.ID)
result.ID = request.ID
return result
}
entry := &windowEntry{
info: WindowInfo{
ID: request.ID,
Kind: request.Kind,
Title: request.Title,
X: request.X,
Y: request.Y,
Width: request.Width,
Height: request.Height,
OpenedAt: time.Now().UnixMilli(),
},
payload: request.Payload,
ownerID: strings.TrimSpace(ownerID),
ready: make(chan struct{}),
done: make(chan processExit, 1),
}
m.windows[request.ID] = entry
spec := m.processSpecLocked(request)
starter := m.starter
m.mu.Unlock()
process, err := starter.Start(spec)
if err != nil {
m.mu.Lock()
delete(m.windows, request.ID)
m.mu.Unlock()
return operationFailure(fmt.Sprintf("open native window failed: %v", err))
}
m.mu.Lock()
current, exists := m.windows[request.ID]
if !exists {
m.mu.Unlock()
_ = process.Kill()
return operationFailure("native window was closed while starting")
}
current.process = process
current.info.PID = process.PID()
m.mu.Unlock()
// A very fast child can acknowledge readiness before Start returns. Republish
// after recording its PID so the Dock menu can identify the frontmost child.
publishDetachedDockMenuSnapshot(m)
go m.watchProcess(request.ID, process)
timeout := m.openTimeout
if timeout <= 0 {
timeout = defaultOpenReadyTimeout
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-entry.ready:
m.mu.Lock()
current, active := m.windows[request.ID]
if active && current == entry {
entry.acknowledged = true
}
m.mu.Unlock()
if !active {
return operationFailure("native window exited before it became ready")
}
return OperationResult{Success: true, ID: request.ID, Bounds: windowBoundsFromRequest(request)}
case exit := <-entry.done:
message := "native window exited before it became ready"
if exit.err != nil {
message = fmt.Sprintf("%s: %v", message, exit.err)
}
return operationFailure(message)
case <-timer.C:
m.mu.Lock()
current, active := m.windows[request.ID]
if active && current == entry && entry.info.Ready {
entry.acknowledged = true
m.mu.Unlock()
return OperationResult{Success: true, ID: request.ID, Bounds: windowBoundsFromRequest(request)}
}
registered := active && current == entry
if registered {
delete(m.windows, request.ID)
}
m.mu.Unlock()
if registered {
_ = process.Kill()
}
return operationFailure("native window did not become ready in time")
}
}
// OpenResult is a convenience binding for the separately detachable result
// surface.
func (m *Manager) OpenResult(request OpenRequest) OperationResult {
if strings.TrimSpace(request.Kind) == "" {
request.Kind = "query-result"
}
return m.Open(request)
}
// Focus restores and raises an existing native child window.
func (m *Manager) Focus(id string) OperationResult {
if m == nil {
return operationFailure("native window manager is unavailable")
}
id = strings.TrimSpace(id)
m.mu.RLock()
entry, exists := m.windows[id]
var bounds *WindowBounds
if exists {
bounds = windowBoundsFromInfo(entry.info)
}
emitToChild := m.emitToChild
shared := m.shared
m.mu.RUnlock()
if !exists {
return operationFailure("native window was not found")
}
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, Bounds: bounds}
}
func windowBoundsFromRequest(request OpenRequest) *WindowBounds {
return &WindowBounds{X: request.X, Y: request.Y, Width: request.Width, Height: request.Height}
}
func windowBoundsFromInfo(info WindowInfo) *WindowBounds {
return &WindowBounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}
}
// Close requests a graceful child shutdown and force-kills it if the WebView is
// no longer responsive.
func (m *Manager) Close(id string) OperationResult {
if m == nil {
return operationFailure("native window manager is unavailable")
}
return m.requestClose(strings.TrimSpace(id), ExitReasonRequested)
}
func (m *Manager) requestClose(id string, reason string) OperationResult {
m.mu.Lock()
entry, exists := m.windows[id]
if !exists {
m.mu.Unlock()
return operationFailure("native window was not found")
}
if entry.info.CloseSent {
m.mu.Unlock()
return 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()
publishDetachedDockMenuSnapshot(m)
// 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 {
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(delay, func() {
m.mu.Lock()
defer m.mu.Unlock()
current, active := m.windows[id]
if active &&
current.process == process &&
current.info.CloseSent &&
current.closeGeneration == closeGeneration {
_ = process.Kill()
}
})
}
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()
publishDetachedDockMenuSnapshot(m)
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 {
return operationFailure("native window manager is unavailable")
}
m.mu.RLock()
ids := make([]string, 0, len(m.windows))
for id := range m.windows {
ids = append(ids, id)
}
m.mu.RUnlock()
for _, id := range ids {
m.requestClose(id, ExitReasonRequested)
}
return OperationResult{Success: true}
}
// List returns a snapshot of the current native child registry.
func (m *Manager) List() []WindowInfo {
if m == nil {
return nil
}
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]WindowInfo, 0, len(m.windows))
for _, entry := range m.windows {
result = append(result, entry.info)
}
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 := filterDetachedChildEnvironment(os.Environ())
values := map[string]string{
envParentURL: m.endpoint,
envToken: m.token,
envWindowID: request.ID,
envKind: request.Kind,
envTitle: request.Title,
envX: fmt.Sprintf("%d", request.X),
envY: fmt.Sprintf("%d", request.Y),
envWidth: fmt.Sprintf("%d", request.Width),
envHeight: fmt.Sprintf("%d", request.Height),
}
for name, value := range values {
env = setEnvironmentValue(env, name, value)
}
return processSpec{Executable: m.executable, Env: env}
}
func setEnvironmentValue(environment []string, name string, value string) []string {
prefix := name + "="
filtered := environment[:0]
for _, item := range environment {
if !strings.HasPrefix(item, prefix) {
filtered = append(filtered, item)
}
}
return append(filtered, prefix+value)
}
func (m *Manager) watchProcess(id string, process childProcess) {
err := process.Wait()
m.mu.Lock()
entry, exists := m.windows[id]
if !exists || entry.process != process {
m.mu.Unlock()
return
}
delete(m.windows, id)
reason := entry.exitReason
if reason == "" {
if err != nil {
reason = ExitReasonProcessError
} else {
reason = ExitReasonWindowClosed
}
}
info := entry.info
ownerID := entry.ownerID
acknowledged := entry.acknowledged
entry.doneOnce.Do(func() {
if entry.done != nil {
entry.done <- processExit{err: err}
close(entry.done)
}
})
m.mu.Unlock()
publishDetachedDockMenuSnapshot(m)
if !acknowledged {
return
}
payload := map[string]any{
"reason": reason,
"exited": true,
}
if err != nil && reason == ExitReasonProcessError {
payload["error"] = err.Error()
}
m.emitDetached(Event{
ID: info.ID,
Kind: info.Kind,
Action: "close",
Payload: withOwnerWindowID(payload, ownerID),
})
}
func (m *Manager) emitDetached(event 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 {
return OperationResult{Success: false, Message: message}
}
func validateOpenRequest(request OpenRequest) error {
if len(request.ID) > 256 || strings.ContainsAny(request.ID, "\r\n\x00") {
return fmt.Errorf("native window id is invalid")
}
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" && request.Kind != "ai-chat" {
return fmt.Errorf("native window kind is unsupported")
}
return nil
}
func (m *Manager) shutdown() {
unregisterDetachedDockMenuManager(m)
m.mu.Lock()
if m.closing {
m.mu.Unlock()
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 {
if entry.process != nil {
processes = append(processes, entry.process)
}
}
m.mu.RUnlock()
for _, process := range processes {
_ = process.Kill()
}
if httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
_ = 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 {
mux := http.NewServeMux()
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) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
token := r.Header.Get(HeaderToken)
if subtle.ConstantTimeCompare([]byte(token), []byte(m.token)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
id := strings.TrimSpace(r.Header.Get(HeaderWindowID))
m.mu.RLock()
_, exists := m.windows[id]
m.mu.RUnlock()
if id == "" || !exists {
http.Error(w, "unknown detached window", http.StatusForbidden)
return
}
mux.ServeHTTP(w, r)
})
}
func isLoopbackRemote(remoteAddr string) bool {
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddr))
if err != nil {
return false
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func (m *Manager) handleBootstrap(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.Lock()
entry, exists := m.windows[id]
if !exists {
m.mu.Unlock()
http.Error(w, "unknown detached window", http.StatusNotFound)
return
}
bootstrap := Bootstrap{ID: entry.info.ID, Kind: entry.info.Kind, Title: entry.info.Title, Payload: entry.payload}
m.mu.Unlock()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = 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)
return
}
defer r.Body.Close()
var request actionRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDetachedJSONBytes))
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid detached action", http.StatusBadRequest)
return
}
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
switch request.Action {
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 {
m.mu.Unlock()
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() {
if entry.ready != nil {
close(entry.ready)
}
})
} else if request.Action == "attach" {
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
m.mu.Unlock()
if request.Action == "ready" || request.Action == "cancel-close" {
publishDetachedDockMenuSnapshot(m)
}
if request.Action != "ready" {
m.emitDetached(Event{
ID: info.ID,
Kind: info.Kind,
Action: request.Action,
Payload: withOwnerWindowID(request.Payload, ownerID),
})
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = 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)
return
}
defer r.Body.Close()
var request controlRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDetachedJSONBytes))
if err := decoder.Decode(&request); err != nil {
http.Error(w, "invalid detached control request", http.StatusBadRequest)
return
}
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
ownerID := strings.TrimSpace(r.Header.Get(HeaderWindowID))
var result OperationResult
switch request.Action {
case "open":
if targetID := strings.TrimSpace(request.Request.ID); targetID != "" && !m.canOpenOwned(targetID, ownerID) {
result = operationFailure("native window id belongs to another owner")
break
}
result = m.open(request.Request, ownerID)
if result.Success {
m.emitDetached(Event{
ID: result.ID,
Kind: normalizeOpenRequest(request.Request).Kind,
Action: "opened",
Payload: withOwnerWindowID(
openEventPayload(request.Request.Payload, result.Bounds),
ownerID,
),
})
}
case "focus":
if !m.ownsWindow(request.ID, ownerID) {
result = operationFailure("native window is not owned by this window")
break
}
result = m.Focus(request.ID)
case "close":
if !m.ownsWindow(request.ID, ownerID) {
result = operationFailure("native window is not owned by this window")
break
}
result = m.Close(request.ID)
case "close-owned":
result = m.closeOwned(ownerID)
default:
http.Error(w, "unsupported detached control action", http.StatusBadRequest)
return
}
status := http.StatusOK
if !result.Success {
status = http.StatusBadRequest
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(result)
}
func openEventPayload(payload any, bounds *WindowBounds) any {
source, ok := payload.(map[string]any)
if !ok {
return nil
}
result := make(map[string]any, 2)
for _, key := range []string{"tab", "resultWindow"} {
if value, exists := source[key]; exists {
if key == "resultWindow" && bounds != nil {
if resultWindow, ok := value.(map[string]any); ok {
corrected := make(map[string]any, len(resultWindow)+4)
for field, fieldValue := range resultWindow {
corrected[field] = fieldValue
}
corrected["x"] = bounds.X
corrected["y"] = bounds.Y
corrected["width"] = bounds.Width
corrected["height"] = bounds.Height
value = corrected
}
}
result[key] = value
}
}
return result
}
func (m *Manager) canOpenOwned(id string, ownerID string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
entry, exists := m.windows[strings.TrimSpace(id)]
return !exists || entry.ownerID == strings.TrimSpace(ownerID)
}
func (m *Manager) ownsWindow(id string, ownerID string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
entry, exists := m.windows[strings.TrimSpace(id)]
return exists && entry.ownerID == strings.TrimSpace(ownerID)
}
func (m *Manager) closeOwned(ownerID string) OperationResult {
ownerID = strings.TrimSpace(ownerID)
if ownerID == "" {
return operationFailure("native owner window is required")
}
m.mu.RLock()
ids := make([]string, 0)
for id, entry := range m.windows {
if entry.ownerID == ownerID {
ids = append(ids, id)
}
}
m.mu.RUnlock()
for _, id := range ids {
m.requestClose(id, ExitReasonRequested)
}
return OperationResult{Success: true}
}
func withOwnerWindowID(payload any, ownerID string) any {
ownerID = strings.TrimSpace(ownerID)
if ownerID == "" {
return payload
}
result := make(map[string]any)
if source, ok := payload.(map[string]any); ok {
for key, value := range source {
result[key] = value
}
} else if payload != nil {
result["value"] = payload
}
result["ownerWindowId"] = ownerID
return result
}