mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 01:03:51 +08:00
- 在主程序 Dock 菜单动态列出并聚焦已就绪的独立窗口 - 在 Wails 启动前固定子进程为 Accessory,消除临时 Dock 图标闪现 - 按多显示器可见区域修正越界窗口并回写实际坐标 - 将自定义 AI 面板快捷键同步到独立窗口并转交主窗口处理 - 补齐 Go、Objective-C、前端及 Wails 绑定回归测试
1206 lines
32 KiB
Go
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
|
|
}
|