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 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) } return &Manager{ 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 } 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() 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) 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() 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} 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} } 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() _, exists := m.windows[id] 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} } // 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() // 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() 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() 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() { 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" { 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), 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) 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 { 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 }