mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
1603 lines
45 KiB
Go
1603 lines
45 KiB
Go
package nativewindow
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"strconv"
|
|
"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"
|
|
envParentPID = "GONAVI_DETACHED_PARENT_PID"
|
|
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
|
|
staleDetachedActionMessage = "stale detached action ignored"
|
|
)
|
|
|
|
type processExit struct {
|
|
err error
|
|
}
|
|
|
|
type windowEntry struct {
|
|
info WindowInfo
|
|
payload any
|
|
hostState HostStateRequest
|
|
ownerID string
|
|
process childProcess
|
|
exitReason string
|
|
closeGeneration uint64
|
|
visibilityRevision uint64
|
|
pendingFocusRevision 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 existing, exists := m.windows[request.ID]; exists {
|
|
if windowEntryIsTerminating(existing) {
|
|
m.mu.Unlock()
|
|
return closingWindowRetryFailure(request.ID)
|
|
}
|
|
// A parked child keeps its WebView and React tree alive. Refresh the
|
|
// bootstrap snapshot and remembered geometry before raising it so a
|
|
// subsequent frontend resume can hydrate from the newest host state.
|
|
existing.payload = request.Payload
|
|
existing.info.Title = request.Title
|
|
existing.info.X = request.X
|
|
existing.info.Y = request.Y
|
|
existing.info.Width = request.Width
|
|
existing.info.Height = request.Height
|
|
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.Lock()
|
|
entry, exists := m.windows[id]
|
|
if !exists {
|
|
m.mu.Unlock()
|
|
return operationFailure("native window was not found")
|
|
}
|
|
if windowEntryIsTerminating(entry) {
|
|
m.mu.Unlock()
|
|
return closingWindowRetryFailure(id)
|
|
}
|
|
wasHidden := entry.info.Hidden
|
|
// Every explicit focus is a separately acknowledged visibility intent.
|
|
// Advancing even while already visible prevents an acknowledgement for an
|
|
// older focus from clearing a newer request that arrived during an SSE gap.
|
|
entry.visibilityRevision++
|
|
entry.info.Hidden = false
|
|
entry.pendingFocusRevision = entry.visibilityRevision
|
|
visibilityRevision := entry.visibilityRevision
|
|
kind := entry.info.Kind
|
|
bounds := windowBoundsFromInfo(entry.info)
|
|
emitToChild := m.emitToChild
|
|
shared := m.shared
|
|
m.mu.Unlock()
|
|
command := childCommand{
|
|
ID: id,
|
|
Action: "focus",
|
|
Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision},
|
|
}
|
|
if emitToChild != nil {
|
|
emitToChild(id, CommandEventName, command)
|
|
} else if shared != nil {
|
|
shared.EmitTo(id, CommandEventName, command)
|
|
}
|
|
if wasHidden {
|
|
m.emitDetached(Event{
|
|
ID: id,
|
|
Kind: kind,
|
|
Action: "focus",
|
|
Payload: visibilityCommandPayload{
|
|
VisibilityRevision: visibilityRevision,
|
|
},
|
|
})
|
|
publishDetachedDockMenuSnapshot(m)
|
|
}
|
|
return OperationResult{
|
|
Success: true,
|
|
ID: id,
|
|
Bounds: bounds,
|
|
VisibilityRevision: visibilityRevision,
|
|
}
|
|
}
|
|
|
|
// Hide parks a detached child without terminating its process. Repeated hides
|
|
// reuse the same visibility revision, while the next Focus advances it so a
|
|
// delayed child-side hide cannot conceal a newly focused window.
|
|
func (m *Manager) Hide(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")
|
|
}
|
|
if entry.info.CloseSent {
|
|
m.mu.Unlock()
|
|
return operationFailure("native window is closing")
|
|
}
|
|
if !entry.info.Hidden {
|
|
entry.visibilityRevision++
|
|
entry.info.Hidden = true
|
|
}
|
|
entry.pendingFocusRevision = 0
|
|
visibilityRevision := entry.visibilityRevision
|
|
emitToChild := m.emitToChild
|
|
shared := m.shared
|
|
m.mu.Unlock()
|
|
|
|
command := childCommand{
|
|
ID: id,
|
|
Action: "hide",
|
|
Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision},
|
|
}
|
|
if emitToChild != nil {
|
|
emitToChild(id, CommandEventName, command)
|
|
} else if shared != nil {
|
|
shared.EmitTo(id, CommandEventName, command)
|
|
}
|
|
publishDetachedDockMenuSnapshot(m)
|
|
return OperationResult{
|
|
Success: true,
|
|
ID: id,
|
|
VisibilityRevision: visibilityRevision,
|
|
}
|
|
}
|
|
|
|
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.pendingFocusRevision = 0
|
|
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")
|
|
}
|
|
if m.closing || entry.exitReason == ExitReasonParentShutdown {
|
|
m.mu.Unlock()
|
|
return operationFailure("native window manager shutdown cannot be cancelled")
|
|
}
|
|
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, ExitReasonAttached, ExitReasonWindowClosed:
|
|
entry.exitReason = ""
|
|
}
|
|
}
|
|
|
|
func (m *Manager) cancelCloseActionLocked(entry *windowEntry, rollbackAction string) bool {
|
|
if m.closing || entry == nil || entry.exitReason == ExitReasonParentShutdown {
|
|
return false
|
|
}
|
|
switch rollbackAction {
|
|
case "attach":
|
|
if entry.info.CloseSent || (entry.exitReason != "" && entry.exitReason != ExitReasonAttached) {
|
|
return false
|
|
}
|
|
case "close":
|
|
validRequestedClose := entry.info.CloseSent && entry.exitReason == ExitReasonRequested
|
|
validWindowClose := !entry.info.CloseSent && entry.exitReason == ExitReasonWindowClosed
|
|
alreadyOpen := !entry.info.CloseSent && entry.exitReason == ""
|
|
if !validRequestedClose && !validWindowClose && !alreadyOpen {
|
|
return false
|
|
}
|
|
case "":
|
|
// Compatibility with children started before rollbackAction was added.
|
|
default:
|
|
return false
|
|
}
|
|
m.cancelCloseLocked(entry)
|
|
return true
|
|
}
|
|
|
|
// 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"`
|
|
}
|
|
|
|
type visibilityCommandPayload struct {
|
|
VisibilityRevision uint64 `json:"visibilityRevision"`
|
|
}
|
|
|
|
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,
|
|
envParentPID: strconv.Itoa(os.Getpid()),
|
|
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 windowEntryIsTerminating(entry *windowEntry) bool {
|
|
return entry != nil && (entry.info.CloseSent || entry.exitReason != "")
|
|
}
|
|
|
|
func closingWindowRetryFailure(id string) OperationResult {
|
|
return OperationResult{
|
|
Success: false,
|
|
ID: strings.TrimSpace(id),
|
|
Message: "native window is closing; retry after it exits",
|
|
}
|
|
}
|
|
|
|
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.pendingFocusRevision = 0
|
|
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,
|
|
ActionRevision: entry.actionRevision,
|
|
}
|
|
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.MethodPost {
|
|
m.handleCommandStateAck(w, r)
|
|
return
|
|
}
|
|
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
|
|
hidden := entry.info.Hidden
|
|
visibilityRevision := entry.visibilityRevision
|
|
pendingFocusRevision := entry.pendingFocusRevision
|
|
reason := entry.exitReason
|
|
m.mu.RUnlock()
|
|
if !closeSent && !hidden && pendingFocusRevision == 0 {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if hidden && !closeSent {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(childCommand{
|
|
ID: id,
|
|
Action: "hide",
|
|
Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision},
|
|
})
|
|
return
|
|
}
|
|
if !closeSent {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(childCommand{
|
|
ID: id,
|
|
Action: "focus",
|
|
Payload: visibilityCommandPayload{VisibilityRevision: pendingFocusRevision},
|
|
})
|
|
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,
|
|
})
|
|
}
|
|
|
|
type commandStateRequest struct {
|
|
Action string `json:"action"`
|
|
VisibilityRevision uint64 `json:"visibilityRevision"`
|
|
}
|
|
|
|
func (m *Manager) handleCommandStateAck(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
var request commandStateRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10))
|
|
if err := decoder.Decode(&request); err != nil {
|
|
http.Error(w, "invalid detached command acknowledgement", http.StatusBadRequest)
|
|
return
|
|
}
|
|
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
|
|
if request.Action != "ack-focus" || request.VisibilityRevision == 0 {
|
|
http.Error(w, "invalid detached command acknowledgement", http.StatusBadRequest)
|
|
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
|
|
}
|
|
message := ""
|
|
if entry.pendingFocusRevision == request.VisibilityRevision {
|
|
entry.pendingFocusRevision = 0
|
|
} else {
|
|
message = "stale focus acknowledgement ignored"
|
|
}
|
|
visibilityRevision := entry.visibilityRevision
|
|
m.mu.Unlock()
|
|
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(OperationResult{
|
|
Success: true,
|
|
ID: id,
|
|
Message: message,
|
|
VisibilityRevision: visibilityRevision,
|
|
})
|
|
}
|
|
|
|
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", "hide", "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)
|
|
requestedAISettingsVisibilityRevision := uint64(0)
|
|
m.mu.Lock()
|
|
entry, exists := m.windows[id]
|
|
if !exists {
|
|
m.mu.Unlock()
|
|
http.Error(w, "unknown detached window", http.StatusNotFound)
|
|
return
|
|
}
|
|
if request.Action == "open-ai-settings" {
|
|
requestedAISettingsVisibilityRevision = positiveVisibilityRevision(request.Payload)
|
|
if requestedAISettingsVisibilityRevision == 0 ||
|
|
requestedAISettingsVisibilityRevision != entry.visibilityRevision ||
|
|
!entry.info.Hidden {
|
|
visibilityRevision := entry.visibilityRevision
|
|
m.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(OperationResult{
|
|
Success: true,
|
|
Applied: operationApplied(false),
|
|
ID: id,
|
|
Message: "open AI settings ignored after a newer visibility action",
|
|
VisibilityRevision: visibilityRevision,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
if request.Action == "cancel-close" && (m.closing || entry.exitReason == ExitReasonParentShutdown) {
|
|
visibilityRevision := entry.visibilityRevision
|
|
m.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(OperationResult{
|
|
Success: true,
|
|
Applied: operationApplied(false),
|
|
ID: id,
|
|
Message: "detached close cancellation ignored while parent is closing",
|
|
VisibilityRevision: visibilityRevision,
|
|
})
|
|
return
|
|
}
|
|
if actionUsesRevision(request.Action) && revision > 0 {
|
|
if revision <= entry.actionRevision {
|
|
visibilityRevision := entry.visibilityRevision
|
|
m.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(OperationResult{
|
|
Success: true,
|
|
Applied: operationApplied(false),
|
|
ID: id,
|
|
Message: staleDetachedActionMessage,
|
|
VisibilityRevision: visibilityRevision,
|
|
})
|
|
return
|
|
}
|
|
entry.actionRevision = revision
|
|
}
|
|
eventAction := request.Action
|
|
visibilityRevision := requestedAISettingsVisibilityRevision
|
|
if request.Action == "ready" {
|
|
entry.info.Ready = true
|
|
entry.readyOnce.Do(func() {
|
|
if entry.ready != nil {
|
|
close(entry.ready)
|
|
}
|
|
})
|
|
} else if request.Action == "attach" {
|
|
if entry.exitReason == "" {
|
|
entry.exitReason = ExitReasonAttached
|
|
}
|
|
entry.pendingFocusRevision = 0
|
|
} else if request.Action == "close" && entry.exitReason == "" {
|
|
entry.exitReason = ExitReasonWindowClosed
|
|
entry.pendingFocusRevision = 0
|
|
} else if request.Action == "hide" {
|
|
requestedVisibilityRevision := positiveVisibilityRevision(request.Payload)
|
|
switch {
|
|
case requestedVisibilityRevision == 0:
|
|
if !entry.info.Hidden {
|
|
entry.visibilityRevision++
|
|
entry.info.Hidden = true
|
|
}
|
|
entry.pendingFocusRevision = 0
|
|
visibilityRevision = entry.visibilityRevision
|
|
case requestedVisibilityRevision < entry.visibilityRevision:
|
|
// Preserve the final child snapshot, but do not let an old hide
|
|
// transition close a window that the host has already focused again.
|
|
visibilityRevision = requestedVisibilityRevision
|
|
eventAction = "sync"
|
|
default:
|
|
entry.visibilityRevision = requestedVisibilityRevision
|
|
entry.info.Hidden = true
|
|
entry.pendingFocusRevision = 0
|
|
visibilityRevision = requestedVisibilityRevision
|
|
}
|
|
request.Payload = withVisibilityRevision(request.Payload, visibilityRevision)
|
|
} else if request.Action == "cancel-close" {
|
|
if !m.cancelCloseActionLocked(entry, rollbackActionFromPayload(request.Payload)) {
|
|
visibilityRevision = entry.visibilityRevision
|
|
m.mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
_ = json.NewEncoder(w).Encode(OperationResult{
|
|
Success: true,
|
|
Applied: operationApplied(false),
|
|
ID: id,
|
|
Message: "detached close cancellation no longer matches the active close",
|
|
VisibilityRevision: visibilityRevision,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
info := entry.info
|
|
ownerID := entry.ownerID
|
|
m.mu.Unlock()
|
|
if request.Action == "ready" || request.Action == "hide" || request.Action == "cancel-close" {
|
|
publishDetachedDockMenuSnapshot(m)
|
|
}
|
|
|
|
if request.Action != "ready" {
|
|
m.emitDetached(Event{
|
|
ID: info.ID,
|
|
Kind: info.Kind,
|
|
Action: eventAction,
|
|
Payload: withOwnerWindowID(request.Payload, ownerID),
|
|
})
|
|
}
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
applied := revisionedActionApplied(request.Action, revision)
|
|
if request.Action == "open-ai-settings" {
|
|
applied = operationApplied(true)
|
|
}
|
|
_ = json.NewEncoder(w).Encode(OperationResult{
|
|
Success: true,
|
|
Applied: applied,
|
|
ID: id,
|
|
VisibilityRevision: visibilityRevision,
|
|
})
|
|
}
|
|
|
|
func actionUsesRevision(action string) bool {
|
|
return action == "sync" || action == "attach" || action == "close" || action == "hide" || action == "cancel-close"
|
|
}
|
|
|
|
func operationApplied(value bool) *bool {
|
|
return &value
|
|
}
|
|
|
|
func revisionedActionApplied(action string, revision int64) *bool {
|
|
if !actionUsesRevision(action) || revision <= 0 {
|
|
return nil
|
|
}
|
|
return operationApplied(true)
|
|
}
|
|
|
|
func rollbackActionFromPayload(payload any) string {
|
|
record, ok := payload.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
rollbackAction, _ := record["rollbackAction"].(string)
|
|
return strings.ToLower(strings.TrimSpace(rollbackAction))
|
|
}
|
|
|
|
func positiveVisibilityRevision(payload any) uint64 {
|
|
switch typed := payload.(type) {
|
|
case visibilityCommandPayload:
|
|
return typed.VisibilityRevision
|
|
case *visibilityCommandPayload:
|
|
if typed != nil {
|
|
return typed.VisibilityRevision
|
|
}
|
|
}
|
|
record, ok := payload.(map[string]any)
|
|
if !ok {
|
|
return 0
|
|
}
|
|
return positiveUintRevision(record["visibilityRevision"])
|
|
}
|
|
|
|
func positiveUintRevision(value any) uint64 {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
if typed > 0 && typed <= 9_007_199_254_740_991 && math.Trunc(typed) == typed {
|
|
return uint64(typed)
|
|
}
|
|
case json.Number:
|
|
revision, err := typed.Int64()
|
|
if err == nil && revision > 0 {
|
|
return uint64(revision)
|
|
}
|
|
case uint64:
|
|
return typed
|
|
case uint:
|
|
return uint64(typed)
|
|
case int64:
|
|
if typed > 0 {
|
|
return uint64(typed)
|
|
}
|
|
case int:
|
|
if typed > 0 {
|
|
return uint64(typed)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func withVisibilityRevision(payload any, visibilityRevision uint64) any {
|
|
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["visibilityRevision"] = visibilityRevision
|
|
return result
|
|
}
|
|
|
|
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.canFocusWindow(request.ID, ownerID) {
|
|
result = operationFailure("native window is not owned by this window")
|
|
break
|
|
}
|
|
result = m.Focus(request.ID)
|
|
case "hide":
|
|
if !m.ownsWindow(request.ID, ownerID) {
|
|
result = operationFailure("native window is not owned by this window")
|
|
break
|
|
}
|
|
result = m.Hide(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) canFocusWindow(id string, ownerID string) bool {
|
|
id = strings.TrimSpace(id)
|
|
ownerID = strings.TrimSpace(ownerID)
|
|
if id == "" || ownerID == "" {
|
|
return false
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
entry, exists := m.windows[id]
|
|
return exists && (id == ownerID || entry.ownerID == 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
|
|
}
|