mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 16:53:35 +08:00
- 支持全部工作台标签与 AI 对话在多显示器中原生独立展示 - 修复拖拽指针捕获,恢复关闭、右键菜单和跨窗口释放识别 - 拆分子窗启动入口并按需加载语言与工作台内容 - 内容与 Monaco 完成可见绘制后再提交迁移,消除白屏和加载闪烁 - 加固结果编辑、宿主同步及关闭、超时和焦点竞态
763 lines
19 KiB
Go
763 lines
19 KiB
Go
package nativewindow
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
type invokeRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
Receiver string `json:"receiver"`
|
|
Method string `json:"method"`
|
|
Args []any `json:"args"`
|
|
}
|
|
|
|
type invokeResponse struct {
|
|
Result any `json:"result,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
type bridgeEvent struct {
|
|
Name string `json:"name"`
|
|
Args []any `json:"args,omitempty"`
|
|
}
|
|
|
|
// Bridge is bound only inside a detached child. It performs parent RPC and SSE
|
|
// over Go's HTTP stack so long-lived event streams never pass through Wails v2's
|
|
// Windows AssetServer response buffering.
|
|
type Bridge struct {
|
|
parentURL string
|
|
token string
|
|
windowID string
|
|
kind string
|
|
client *http.Client
|
|
|
|
mu sync.Mutex
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
ready bool
|
|
onReady func() OperationResult
|
|
terminal string
|
|
closeOnce sync.Once
|
|
emitToWails func(context.Context, string, ...any)
|
|
}
|
|
|
|
func newBridge(options ChildOptions) *Bridge {
|
|
transport := &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 5 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}).DialContext,
|
|
ForceAttemptHTTP2: false,
|
|
}
|
|
return &Bridge{
|
|
parentURL: strings.TrimRight(options.ParentURL, "/"),
|
|
token: options.Token,
|
|
windowID: options.ID,
|
|
kind: options.Kind,
|
|
client: &http.Client{Transport: transport},
|
|
emitToWails: func(ctx context.Context, name string, args ...any) {
|
|
wailsRuntime.EventsEmit(ctx, name, args...)
|
|
},
|
|
}
|
|
}
|
|
|
|
func InitializeBridge(bridge *Bridge, ctx context.Context) {
|
|
if bridge == nil {
|
|
return
|
|
}
|
|
bridge.mu.Lock()
|
|
if bridge.cancel != nil {
|
|
bridge.mu.Unlock()
|
|
return
|
|
}
|
|
streamCtx, cancel := context.WithCancel(ctx)
|
|
bridge.ctx = ctx
|
|
bridge.cancel = cancel
|
|
bridge.mu.Unlock()
|
|
go bridge.consumeEvents(streamCtx)
|
|
}
|
|
|
|
// Invoke calls the shared parent App or AI service.
|
|
func (b *Bridge) Invoke(namespace string, receiver string, method string, args []any) (any, error) {
|
|
request := invokeRequest{
|
|
Namespace: namespace,
|
|
Receiver: receiver,
|
|
Method: method,
|
|
Args: args,
|
|
}
|
|
var response invokeResponse
|
|
status, err := b.doJSON(context.Background(), http.MethodPost, InvokePath, request, &response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusOK || response.Error != "" {
|
|
if response.Error != "" {
|
|
return nil, fmt.Errorf("%s", response.Error)
|
|
}
|
|
return nil, fmt.Errorf("parent invoke failed with status %d", status)
|
|
}
|
|
return response.Result, nil
|
|
}
|
|
|
|
// Bootstrap returns the tab snapshot stored in the main process registry.
|
|
func (b *Bridge) Bootstrap() (Bootstrap, error) {
|
|
var result Bootstrap
|
|
status, err := b.doJSON(context.Background(), http.MethodGet, BootstrapPath, nil, &result)
|
|
if err != nil {
|
|
return Bootstrap{}, err
|
|
}
|
|
if status != http.StatusOK {
|
|
return Bootstrap{}, fmt.Errorf("detached bootstrap failed with status %d", status)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// WindowID returns the lightweight process identity used to route parent
|
|
// focus/close commands. Command handling must not reload a potentially large
|
|
// bootstrap payload just to compare IDs.
|
|
func (b *Bridge) WindowID() string {
|
|
if b == nil {
|
|
return ""
|
|
}
|
|
return b.windowID
|
|
}
|
|
|
|
// OpenWindow asks the parent manager to create a child owned by this detached
|
|
// window. The parent validates ownership for all subsequent focus/close calls.
|
|
func (b *Bridge) OpenWindow(request OpenRequest) OperationResult {
|
|
return b.control(controlRequest{Action: "open", Request: request})
|
|
}
|
|
|
|
func (b *Bridge) FocusWindow(id string) OperationResult {
|
|
return b.control(controlRequest{Action: "focus", ID: id})
|
|
}
|
|
|
|
func (b *Bridge) CloseWindow(id string) OperationResult {
|
|
return b.control(controlRequest{Action: "close", ID: id})
|
|
}
|
|
|
|
func (b *Bridge) CloseOwnedWindows() OperationResult {
|
|
return b.control(controlRequest{Action: "close-owned"})
|
|
}
|
|
|
|
func (b *Bridge) control(request controlRequest) OperationResult {
|
|
var result OperationResult
|
|
status, err := b.doJSON(
|
|
context.Background(),
|
|
http.MethodPost,
|
|
ControlPath,
|
|
request,
|
|
&result,
|
|
)
|
|
if err != nil {
|
|
return operationFailure(err.Error())
|
|
}
|
|
if status != http.StatusOK && result.Message == "" {
|
|
return operationFailure(fmt.Sprintf("detached control failed with status %d", status))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Action acknowledges child readiness or forwards sync, attach, or close state
|
|
// to the main window.
|
|
func (b *Bridge) Action(action string, payload any) OperationResult {
|
|
normalizedAction := strings.ToLower(strings.TrimSpace(action))
|
|
if normalizedAction == "ready" {
|
|
if result := b.presentFrontendReady(); !result.Success {
|
|
return result
|
|
}
|
|
}
|
|
|
|
var result OperationResult
|
|
status, err := b.doJSON(context.Background(), http.MethodPost, ActionPath, actionRequest{Action: action, Payload: payload}, &result)
|
|
if err != nil {
|
|
return operationFailure(err.Error())
|
|
}
|
|
if status != http.StatusOK {
|
|
return operationFailure(fmt.Sprintf("detached action failed with status %d", status))
|
|
}
|
|
if result.Success {
|
|
b.mu.Lock()
|
|
if normalizedAction == "attach" || normalizedAction == "close" {
|
|
b.terminal = normalizedAction
|
|
}
|
|
b.mu.Unlock()
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (b *Bridge) setReadyHandler(handler func() OperationResult) {
|
|
if b == nil {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
b.onReady = handler
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *Bridge) presentFrontendReady() OperationResult {
|
|
if b == nil {
|
|
return operationFailure("detached bridge is unavailable")
|
|
}
|
|
b.mu.Lock()
|
|
if b.ready {
|
|
b.mu.Unlock()
|
|
return OperationResult{Success: true, ID: b.windowID}
|
|
}
|
|
onReady := b.onReady
|
|
b.mu.Unlock()
|
|
if onReady == nil {
|
|
return operationFailure("native window ready handler is unavailable")
|
|
}
|
|
result := onReady()
|
|
if !result.Success {
|
|
return result
|
|
}
|
|
b.mu.Lock()
|
|
b.ready = true
|
|
b.mu.Unlock()
|
|
return OperationResult{Success: true, ID: b.windowID}
|
|
}
|
|
|
|
func (b *Bridge) frontendReady() bool {
|
|
if b == nil {
|
|
return false
|
|
}
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.ready
|
|
}
|
|
|
|
func (b *Bridge) notifyClosing() {
|
|
if b == nil {
|
|
return
|
|
}
|
|
b.closeOnce.Do(func() {
|
|
b.mu.Lock()
|
|
terminal := b.terminal
|
|
b.mu.Unlock()
|
|
if terminal == "attach" || terminal == "close" {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
|
|
defer cancel()
|
|
var result OperationResult
|
|
_, _ = b.doJSON(ctx, http.MethodPost, ActionPath, actionRequest{
|
|
Action: "close",
|
|
Payload: map[string]any{
|
|
"id": b.windowID,
|
|
"kind": b.kind,
|
|
},
|
|
}, &result)
|
|
})
|
|
}
|
|
|
|
func (b *Bridge) stop() {
|
|
if b == nil {
|
|
return
|
|
}
|
|
b.mu.Lock()
|
|
cancel := b.cancel
|
|
b.cancel = nil
|
|
b.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
}
|
|
|
|
func (b *Bridge) doJSON(ctx context.Context, method string, requestPath string, requestBody any, responseBody any) (int, error) {
|
|
if b == nil || b.client == nil {
|
|
return 0, fmt.Errorf("detached bridge is unavailable")
|
|
}
|
|
var body io.Reader
|
|
if requestBody != nil {
|
|
payload, err := json.Marshal(requestBody)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
body = bytes.NewReader(payload)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, method, b.parentURL+requestPath, body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
b.addHeaders(request)
|
|
if requestBody != nil {
|
|
request.Header.Set("Content-Type", "application/json")
|
|
}
|
|
response, err := b.client.Do(request)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer response.Body.Close()
|
|
if responseBody != nil {
|
|
decoder := json.NewDecoder(io.LimitReader(response.Body, maxDetachedJSONBytes))
|
|
if err := decoder.Decode(responseBody); err != nil && !errorsIsEOF(err) {
|
|
return response.StatusCode, err
|
|
}
|
|
}
|
|
return response.StatusCode, nil
|
|
}
|
|
|
|
func errorsIsEOF(err error) bool {
|
|
return err == io.EOF
|
|
}
|
|
|
|
func (b *Bridge) addHeaders(request *http.Request) {
|
|
request.Header.Set(HeaderToken, b.token)
|
|
request.Header.Set(HeaderWindowID, b.windowID)
|
|
}
|
|
|
|
func (b *Bridge) consumeEvents(ctx context.Context) {
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
err := b.consumeEventStream(ctx)
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
delay := 750 * time.Millisecond
|
|
if err == nil {
|
|
delay = 100 * time.Millisecond
|
|
}
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Bridge) consumeEventStream(ctx context.Context) error {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, b.parentURL+EventsPath, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.addHeaders(request)
|
|
request.Header.Set("Accept", "text/event-stream")
|
|
response, err := b.client.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
|
|
return fmt.Errorf("detached event stream failed with status %d", response.StatusCode)
|
|
}
|
|
if err := b.replayPendingCommand(ctx); err != nil {
|
|
return err
|
|
}
|
|
// The SSE subscription exists before this GET starts, so a concurrent host
|
|
// update is either present in the retained response, queued on SSE, or both.
|
|
// Frontend revision checks make the possible duplicate harmless.
|
|
if err := b.replayHostState(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
scanner := bufio.NewScanner(response.Body)
|
|
scanner.Buffer(make([]byte, 64<<10), 4<<20)
|
|
var data strings.Builder
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" {
|
|
if data.Len() > 0 {
|
|
if err := b.dispatchEvent(ctx, data.String()); err != nil {
|
|
return err
|
|
}
|
|
data.Reset()
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "data:") {
|
|
chunk := strings.TrimPrefix(line, "data:")
|
|
if strings.HasPrefix(chunk, " ") {
|
|
chunk = chunk[1:]
|
|
}
|
|
if int64(data.Len()+len(chunk)) > maxDetachedSSEEventBytes {
|
|
return fmt.Errorf("detached event exceeds the maximum payload size")
|
|
}
|
|
data.WriteString(chunk)
|
|
}
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func (b *Bridge) replayPendingCommand(ctx context.Context) error {
|
|
var command childCommand
|
|
status, err := b.doJSON(ctx, http.MethodGet, CommandStatePath, nil, &command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
if status != http.StatusOK {
|
|
return fmt.Errorf("detached command-state replay failed with status %d", status)
|
|
}
|
|
if strings.TrimSpace(command.ID) != b.windowID || command.Action != "close" {
|
|
return fmt.Errorf("detached command-state replay is invalid")
|
|
}
|
|
b.emitRuntimeEvent(CommandEventName, command)
|
|
return nil
|
|
}
|
|
|
|
func (b *Bridge) replayHostState(ctx context.Context) error {
|
|
var snapshot HostStateRequest
|
|
status, err := b.doJSON(ctx, http.MethodGet, HostStatePath, nil, &snapshot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status == http.StatusNoContent {
|
|
return nil
|
|
}
|
|
if status != http.StatusOK {
|
|
return fmt.Errorf("detached host-state replay failed with status %d", status)
|
|
}
|
|
if strings.TrimSpace(snapshot.ID) != b.windowID || snapshot.Revision <= 0 || snapshot.StoreState == nil {
|
|
return fmt.Errorf("detached host-state replay is invalid")
|
|
}
|
|
b.emitRuntimeEvent(CommandEventName, childCommand{
|
|
ID: snapshot.ID,
|
|
Action: "sync-host-state",
|
|
Payload: hostStatePayload{
|
|
Revision: snapshot.Revision,
|
|
StoreState: snapshot.StoreState,
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (b *Bridge) dispatchEvent(ctx context.Context, payload string) error {
|
|
var event bridgeEvent
|
|
if err := json.Unmarshal([]byte(payload), &event); err != nil || strings.TrimSpace(event.Name) == "" {
|
|
return nil
|
|
}
|
|
if b.isHostStateInvalidation(event) {
|
|
return b.replayHostState(ctx)
|
|
}
|
|
b.emitRuntimeEvent(event.Name, event.Args...)
|
|
return nil
|
|
}
|
|
|
|
func (b *Bridge) isHostStateInvalidation(event bridgeEvent) bool {
|
|
if b == nil || event.Name != CommandEventName || len(event.Args) != 1 {
|
|
return false
|
|
}
|
|
command, ok := event.Args[0].(map[string]any)
|
|
if !ok || strings.TrimSpace(fmt.Sprint(command["id"])) != b.windowID || command["action"] != "sync-host-state" {
|
|
return false
|
|
}
|
|
payload, ok := command["payload"].(map[string]any)
|
|
if !ok {
|
|
return true
|
|
}
|
|
_, carriesStoreState := payload["storeState"]
|
|
return !carriesStoreState
|
|
}
|
|
|
|
func (b *Bridge) emitRuntimeEvent(name string, args ...any) {
|
|
b.mu.Lock()
|
|
ctx := b.ctx
|
|
emitToWails := b.emitToWails
|
|
b.mu.Unlock()
|
|
if ctx != nil && emitToWails != nil {
|
|
emitToWails(ctx, name, args...)
|
|
}
|
|
}
|
|
|
|
// Control is bound only in a child and always targets that process's native
|
|
// Wails window.
|
|
type Control struct {
|
|
mu sync.RWMutex
|
|
ctx context.Context
|
|
bridge *Bridge
|
|
closeGate closeGate
|
|
closeFallback *time.Timer
|
|
closeFallbackGeneration uint64
|
|
closeFallbackDelay time.Duration
|
|
closeCommitted bool
|
|
domReady bool
|
|
frontendReady bool
|
|
focusPending bool
|
|
visible bool
|
|
emitCommand func(context.Context, childCommand)
|
|
showWindow func(context.Context)
|
|
focusWindow func(context.Context)
|
|
quit func(context.Context)
|
|
}
|
|
|
|
func newControl(bridge *Bridge) *Control {
|
|
return &Control{
|
|
bridge: bridge,
|
|
closeFallbackDelay: defaultGracefulCloseTimeout,
|
|
emitCommand: func(ctx context.Context, command childCommand) {
|
|
wailsRuntime.EventsEmit(ctx, CommandEventName, command)
|
|
},
|
|
showWindow: func(ctx context.Context) {
|
|
wailsRuntime.WindowShow(ctx)
|
|
},
|
|
focusWindow: func(ctx context.Context) {
|
|
wailsRuntime.WindowUnminimise(ctx)
|
|
wailsRuntime.Show(ctx)
|
|
},
|
|
quit: wailsRuntime.Quit,
|
|
}
|
|
}
|
|
|
|
func InitializeControl(control *Control, ctx context.Context) {
|
|
if control == nil {
|
|
return
|
|
}
|
|
control.mu.Lock()
|
|
control.ctx = ctx
|
|
control.mu.Unlock()
|
|
}
|
|
|
|
// markDOMReady records that the WebView exists without exposing its still-empty
|
|
// surface. The frontend ready handshake releases the first presentation only
|
|
// after its own post-paint barrier.
|
|
func (c *Control) markDOMReady(ctx context.Context) {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
if c.ctx == nil {
|
|
c.ctx = ctx
|
|
}
|
|
c.domReady = true
|
|
presentation := c.takeInitialPresentationLocked()
|
|
c.mu.Unlock()
|
|
presentation.run()
|
|
}
|
|
|
|
func (c *Control) markFrontendReady() OperationResult {
|
|
if c == nil {
|
|
return operationFailure("native window control is unavailable")
|
|
}
|
|
c.mu.Lock()
|
|
if !c.domReady || c.ctx == nil || c.showWindow == nil {
|
|
c.mu.Unlock()
|
|
return operationFailure("native window DOM is not ready")
|
|
}
|
|
c.frontendReady = true
|
|
presentation := c.takeInitialPresentationLocked()
|
|
c.mu.Unlock()
|
|
presentation.run()
|
|
return OperationResult{Success: true}
|
|
}
|
|
|
|
// Present exposes the already-mounted child without acknowledging readiness to
|
|
// the parent. The visible WebView can then cross a real paint frame before the
|
|
// frontend sends its final ready action.
|
|
func (c *Control) Present() OperationResult {
|
|
if c == nil {
|
|
return operationFailure("native window control is unavailable")
|
|
}
|
|
c.mu.Lock()
|
|
if !c.domReady || c.ctx == nil || c.showWindow == nil {
|
|
c.mu.Unlock()
|
|
return operationFailure("native window DOM is not ready")
|
|
}
|
|
if c.visible {
|
|
c.mu.Unlock()
|
|
return OperationResult{Success: true}
|
|
}
|
|
c.visible = true
|
|
presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow}
|
|
if c.focusPending && c.focusWindow != nil {
|
|
c.focusPending = false
|
|
presentation.focus = c.focusWindow
|
|
}
|
|
c.mu.Unlock()
|
|
presentation.run()
|
|
return OperationResult{Success: true}
|
|
}
|
|
|
|
type childWindowPresentation struct {
|
|
ctx context.Context
|
|
show func(context.Context)
|
|
focus func(context.Context)
|
|
}
|
|
|
|
func (p childWindowPresentation) run() {
|
|
if p.show != nil {
|
|
p.show(p.ctx)
|
|
}
|
|
if p.focus != nil {
|
|
p.focus(p.ctx)
|
|
}
|
|
}
|
|
|
|
func (c *Control) takeInitialPresentationLocked() childWindowPresentation {
|
|
if c.visible || !c.domReady || !c.frontendReady || c.ctx == nil || c.showWindow == nil {
|
|
return childWindowPresentation{}
|
|
}
|
|
c.visible = true
|
|
presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow}
|
|
if c.focusPending && c.focusWindow != nil {
|
|
c.focusPending = false
|
|
presentation.focus = c.focusWindow
|
|
}
|
|
return presentation
|
|
}
|
|
|
|
func (c *Control) Close() OperationResult {
|
|
if c == nil {
|
|
return operationFailure("native window control is unavailable")
|
|
}
|
|
c.mu.Lock()
|
|
ctx := c.ctx
|
|
quit := c.quit
|
|
if ctx == nil || quit == nil {
|
|
c.mu.Unlock()
|
|
return operationFailure("native window is not ready")
|
|
}
|
|
c.closeCommitted = true
|
|
c.invalidateCloseFallbackLocked()
|
|
c.mu.Unlock()
|
|
c.closeGate.allow()
|
|
if c.bridge != nil {
|
|
c.bridge.notifyClosing()
|
|
}
|
|
quit(ctx)
|
|
return OperationResult{Success: true}
|
|
}
|
|
|
|
// CancelClose keeps the child alive after a failed final frontend flush. It
|
|
// also invalidates any native-close fallback so a retry starts a fresh grace
|
|
// period instead of inheriting the old timeout.
|
|
func (c *Control) CancelClose() OperationResult {
|
|
if c == nil {
|
|
return operationFailure("native window control is unavailable")
|
|
}
|
|
c.mu.Lock()
|
|
if c.closeCommitted {
|
|
c.mu.Unlock()
|
|
return operationFailure("native window close is already committed")
|
|
}
|
|
c.invalidateCloseFallbackLocked()
|
|
c.mu.Unlock()
|
|
c.closeGate.cancel()
|
|
return OperationResult{Success: true}
|
|
}
|
|
|
|
// handleBeforeClose gives a mounted frontend one opportunity to submit its
|
|
// terminal payload. A child that has not acknowledged ready has no state worth
|
|
// gating, so its existing notifyClosing fallback remains in force.
|
|
func (c *Control) handleBeforeClose(ctx context.Context) bool {
|
|
if c == nil {
|
|
return false
|
|
}
|
|
if c.bridge == nil || !c.bridge.frontendReady() {
|
|
if c.bridge != nil {
|
|
c.bridge.notifyClosing()
|
|
}
|
|
return false
|
|
}
|
|
veto, requestFrontendClose := c.closeGate.intercept()
|
|
if requestFrontendClose {
|
|
c.mu.RLock()
|
|
emitCommand := c.emitCommand
|
|
c.mu.RUnlock()
|
|
if emitCommand != nil {
|
|
emitCommand(ctx, childCommand{
|
|
ID: c.bridge.WindowID(),
|
|
Action: "close",
|
|
Reason: ExitReasonWindowClosed,
|
|
})
|
|
}
|
|
}
|
|
if veto {
|
|
c.scheduleCloseFallback(ctx)
|
|
}
|
|
return veto
|
|
}
|
|
|
|
func (c *Control) scheduleCloseFallback(fallbackCtx context.Context) {
|
|
c.mu.Lock()
|
|
if c.closeCommitted || c.closeFallback != nil {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
delay := c.closeFallbackDelay
|
|
if delay <= 0 {
|
|
delay = defaultGracefulCloseTimeout
|
|
}
|
|
c.closeFallbackGeneration++
|
|
generation := c.closeFallbackGeneration
|
|
c.closeFallback = time.AfterFunc(delay, func() {
|
|
c.mu.Lock()
|
|
if c.closeCommitted ||
|
|
c.closeFallbackGeneration != generation ||
|
|
c.closeGate.isAllowed() {
|
|
c.closeFallback = nil
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
c.closeFallback = nil
|
|
c.closeCommitted = true
|
|
ctx := c.ctx
|
|
quit := c.quit
|
|
c.mu.Unlock()
|
|
|
|
c.closeGate.allow()
|
|
if c.bridge != nil {
|
|
c.bridge.notifyClosing()
|
|
}
|
|
if ctx == nil {
|
|
ctx = fallbackCtx
|
|
}
|
|
if ctx != nil && quit != nil {
|
|
quit(ctx)
|
|
}
|
|
})
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *Control) invalidateCloseFallbackLocked() {
|
|
c.closeFallbackGeneration++
|
|
if c.closeFallback != nil {
|
|
c.closeFallback.Stop()
|
|
c.closeFallback = nil
|
|
}
|
|
}
|
|
|
|
func (c *Control) Focus() OperationResult {
|
|
if c == nil {
|
|
return operationFailure("native window control is unavailable")
|
|
}
|
|
c.mu.Lock()
|
|
ctx := c.ctx
|
|
focus := c.focusWindow
|
|
if ctx == nil || focus == nil {
|
|
c.mu.Unlock()
|
|
return operationFailure("native window is not ready")
|
|
}
|
|
if !c.visible {
|
|
c.focusPending = true
|
|
presentation := c.takeInitialPresentationLocked()
|
|
c.mu.Unlock()
|
|
presentation.run()
|
|
return OperationResult{Success: true}
|
|
}
|
|
c.mu.Unlock()
|
|
focus(ctx)
|
|
return OperationResult{Success: true}
|
|
}
|