mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-10 08:43:34 +08:00
✨ feat(native-window): 支持标签页拖出为原生独立窗口
- 支持 SQL、数据和结果标签拖出到跨显示器原生窗口 - 通过受认证 loopback bridge 复用主进程后端与状态 - 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复 - 补充多窗口协议、状态同步和跨平台回归测试
This commit is contained in:
31
internal/nativewindow/activation_policy_darwin.go
Normal file
31
internal/nativewindow/activation_policy_darwin.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package nativewindow
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Cocoa
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <dispatch/dispatch.h>
|
||||
|
||||
static void applyDetachedAccessoryActivationPolicy(void *unused) {
|
||||
(void)unused;
|
||||
NSApplication *application = [NSApplication sharedApplication];
|
||||
[application setActivationPolicy:NSApplicationActivationPolicyAccessory];
|
||||
[application activateIgnoringOtherApps:YES];
|
||||
}
|
||||
|
||||
static void setDetachedAccessoryActivationPolicy(void) {
|
||||
if ([NSThread isMainThread]) {
|
||||
applyDetachedAccessoryActivationPolicy(NULL);
|
||||
return;
|
||||
}
|
||||
dispatch_sync_f(dispatch_get_main_queue(), NULL, applyDetachedAccessoryActivationPolicy);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
func setDetachedAccessoryActivationPolicy() {
|
||||
C.setDetachedAccessoryActivationPolicy()
|
||||
}
|
||||
5
internal/nativewindow/activation_policy_other.go
Normal file
5
internal/nativewindow/activation_policy_other.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
package nativewindow
|
||||
|
||||
func setDetachedAccessoryActivationPolicy() {}
|
||||
395
internal/nativewindow/bridge.go
Normal file
395
internal/nativewindow/bridge.go
Normal file
@@ -0,0 +1,395 @@
|
||||
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
|
||||
terminal string
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
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},
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
normalizedAction := strings.ToLower(strings.TrimSpace(action))
|
||||
if result.Success && (normalizedAction == "attach" || normalizedAction == "close") {
|
||||
b.mu.Lock()
|
||||
b.terminal = normalizedAction
|
||||
b.mu.Unlock()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
b.dispatchEvent(data.String())
|
||||
data.Reset()
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "data:") {
|
||||
if data.Len() > 0 {
|
||||
data.WriteByte('\n')
|
||||
}
|
||||
data.WriteString(strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (b *Bridge) dispatchEvent(payload string) {
|
||||
var event bridgeEvent
|
||||
if err := json.Unmarshal([]byte(payload), &event); err != nil || strings.TrimSpace(event.Name) == "" {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
ctx := b.ctx
|
||||
b.mu.Unlock()
|
||||
if ctx != nil {
|
||||
wailsRuntime.EventsEmit(ctx, event.Name, event.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
|
||||
}
|
||||
|
||||
func newControl(bridge *Bridge) *Control {
|
||||
return &Control{bridge: bridge}
|
||||
}
|
||||
|
||||
func InitializeControl(control *Control, ctx context.Context) {
|
||||
if control == nil {
|
||||
return
|
||||
}
|
||||
control.mu.Lock()
|
||||
control.ctx = ctx
|
||||
control.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Control) Close() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
if c.bridge != nil {
|
||||
c.bridge.notifyClosing()
|
||||
}
|
||||
c.mu.RLock()
|
||||
ctx := c.ctx
|
||||
c.mu.RUnlock()
|
||||
if ctx == nil {
|
||||
return operationFailure("native window is not ready")
|
||||
}
|
||||
wailsRuntime.Quit(ctx)
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
func (c *Control) Focus() OperationResult {
|
||||
if c == nil {
|
||||
return operationFailure("native window control is unavailable")
|
||||
}
|
||||
c.mu.RLock()
|
||||
ctx := c.ctx
|
||||
c.mu.RUnlock()
|
||||
if ctx == nil {
|
||||
return operationFailure("native window is not ready")
|
||||
}
|
||||
wailsRuntime.WindowUnminimise(ctx)
|
||||
wailsRuntime.WindowShow(ctx)
|
||||
wailsRuntime.Show(ctx)
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
193
internal/nativewindow/child.go
Normal file
193
internal/nativewindow/child.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ChildOptions are intentionally small; the potentially large tab payload is
|
||||
// kept in the parent registry and fetched through Bootstrap.
|
||||
type ChildOptions struct {
|
||||
ParentURL string
|
||||
Token string
|
||||
ID string
|
||||
Kind string
|
||||
Title string
|
||||
X int
|
||||
Y int
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
func ParseChildOptions(args []string) (ChildOptions, error) {
|
||||
result := ChildOptions{
|
||||
ParentURL: strings.TrimSpace(os.Getenv(envParentURL)),
|
||||
Token: strings.TrimSpace(os.Getenv(envToken)),
|
||||
ID: strings.TrimSpace(os.Getenv(envWindowID)),
|
||||
Kind: strings.TrimSpace(os.Getenv(envKind)),
|
||||
Title: strings.TrimSpace(os.Getenv(envTitle)),
|
||||
X: environmentInt(envX, 0),
|
||||
Y: environmentInt(envY, 0),
|
||||
Width: environmentInt(envWidth, defaultWindowWidth),
|
||||
Height: environmentInt(envHeight, defaultWindowHeight),
|
||||
}
|
||||
flags := flag.NewFlagSet("gonavi detached-window", flag.ContinueOnError)
|
||||
flags.SetOutput(discardWriter{})
|
||||
flags.StringVar(&result.ParentURL, "parent-url", result.ParentURL, "parent loopback bridge URL")
|
||||
flags.StringVar(&result.Token, "token", result.Token, "parent bridge token")
|
||||
flags.StringVar(&result.ID, "id", result.ID, "detached window ID")
|
||||
flags.StringVar(&result.Kind, "kind", result.Kind, "detached window kind")
|
||||
flags.StringVar(&result.Title, "title", result.Title, "native window title")
|
||||
flags.IntVar(&result.X, "x", result.X, "virtual desktop x coordinate")
|
||||
flags.IntVar(&result.Y, "y", result.Y, "virtual desktop y coordinate")
|
||||
flags.IntVar(&result.Width, "width", result.Width, "window width")
|
||||
flags.IntVar(&result.Height, "height", result.Height, "window height")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return ChildOptions{}, err
|
||||
}
|
||||
if flags.NArg() > 0 {
|
||||
return ChildOptions{}, fmt.Errorf("unknown detached-window arguments: %s", strings.Join(flags.Args(), " "))
|
||||
}
|
||||
result.Kind = strings.TrimSpace(result.Kind)
|
||||
if result.Kind == "" {
|
||||
result.Kind = "workbench"
|
||||
}
|
||||
result.Title = strings.TrimSpace(result.Title)
|
||||
if result.Title == "" {
|
||||
result.Title = "GoNavi"
|
||||
}
|
||||
if result.Width <= 0 {
|
||||
result.Width = defaultWindowWidth
|
||||
}
|
||||
if result.Height <= 0 {
|
||||
result.Height = defaultWindowHeight
|
||||
}
|
||||
if err := validateChildOptions(result); err != nil {
|
||||
return ChildOptions{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func validateChildOptions(options ChildOptions) error {
|
||||
if strings.TrimSpace(options.Token) == "" || strings.TrimSpace(options.ID) == "" {
|
||||
return fmt.Errorf("detached-window token and id are required")
|
||||
}
|
||||
parentURL, err := url.Parse(options.ParentURL)
|
||||
if err != nil || parentURL.Scheme != "http" || parentURL.Host == "" || (parentURL.Path != "" && parentURL.Path != "/") || parentURL.RawQuery != "" || parentURL.User != nil {
|
||||
return fmt.Errorf("detached-window parent URL must be an HTTP loopback origin")
|
||||
}
|
||||
host := parentURL.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return fmt.Errorf("detached-window parent URL must use a loopback IP")
|
||||
}
|
||||
if len(options.ID) > 256 || strings.ContainsAny(options.ID, "\r\n\x00") {
|
||||
return fmt.Errorf("detached-window id is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func environmentInt(name string, fallback int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(os.Getenv(name)))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// RunChild starts one native system window in the current child process.
|
||||
func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
|
||||
if assetFS == nil {
|
||||
return fmt.Errorf("web assets are unavailable")
|
||||
}
|
||||
childOptions, err := ParseChildOptions(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parentURL, _ := url.Parse(childOptions.ParentURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(parentURL)
|
||||
originalDirector := proxy.Director
|
||||
proxy.Director = func(request *http.Request) {
|
||||
originalDirector(request)
|
||||
request.Host = parentURL.Host
|
||||
request.Header.Set(HeaderToken, childOptions.Token)
|
||||
request.Header.Set(HeaderWindowID, childOptions.ID)
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, proxyErr error) {
|
||||
http.Error(w, fmt.Sprintf("detached parent is unavailable: %v", proxyErr), http.StatusBadGateway)
|
||||
}
|
||||
|
||||
bridge := newBridge(childOptions)
|
||||
control := newControl(bridge)
|
||||
err = wails.Run(&options.App{
|
||||
Title: childOptions.Title,
|
||||
Width: childOptions.Width,
|
||||
Height: childOptions.Height,
|
||||
MinWidth: 420,
|
||||
MinHeight: 280,
|
||||
StartHidden: true,
|
||||
Frameless: true,
|
||||
AssetServer: &assetserver.Options{Handler: proxy},
|
||||
BackgroundColour: &options.RGBA{
|
||||
R: 255,
|
||||
G: 255,
|
||||
B: 255,
|
||||
A: 255,
|
||||
},
|
||||
OnStartup: func(ctx context.Context) {
|
||||
InitializeBridge(bridge, ctx)
|
||||
InitializeControl(control, ctx)
|
||||
if parentCtx != nil {
|
||||
go func() {
|
||||
select {
|
||||
case <-parentCtx.Done():
|
||||
wailsRuntime.Quit(ctx)
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
},
|
||||
OnDomReady: func(ctx context.Context) {
|
||||
setDetachedAccessoryActivationPolicy()
|
||||
applyDetachedWindowBounds(
|
||||
ctx,
|
||||
childOptions.X,
|
||||
childOptions.Y,
|
||||
childOptions.Width,
|
||||
childOptions.Height,
|
||||
)
|
||||
wailsRuntime.WindowSetTitle(ctx, childOptions.Title)
|
||||
wailsRuntime.WindowShow(ctx)
|
||||
},
|
||||
OnBeforeClose: func(context.Context) bool {
|
||||
bridge.notifyClosing()
|
||||
return false
|
||||
},
|
||||
OnShutdown: func(context.Context) {
|
||||
bridge.notifyClosing()
|
||||
bridge.stop()
|
||||
},
|
||||
Bind: []interface{}{control, bridge},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type discardWriter struct{}
|
||||
|
||||
func (discardWriter) Write(payload []byte) (int, error) {
|
||||
return len(payload), nil
|
||||
}
|
||||
775
internal/nativewindow/manager.go
Normal file
775
internal/nativewindow/manager.go
Normal file
@@ -0,0 +1,775 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"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 (
|
||||
forceCloseDelay = 3 * time.Second
|
||||
defaultOpenReadyTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type processExit struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type windowEntry struct {
|
||||
info WindowInfo
|
||||
payload any
|
||||
ownerID string
|
||||
process childProcess
|
||||
exitReason string
|
||||
ready chan struct{}
|
||||
done chan processExit
|
||||
readyOnce sync.Once
|
||||
doneOnce sync.Once
|
||||
acknowledged bool
|
||||
}
|
||||
|
||||
// Manager owns the loopback bridge and the registry of detached Wails child
|
||||
// processes. It is intended to be bound to the main Wails window.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
shared *webserver.SharedRuntime
|
||||
token string
|
||||
endpoint string
|
||||
listener net.Listener
|
||||
httpServer *http.Server
|
||||
runtimeCtx context.Context
|
||||
started bool
|
||||
closing bool
|
||||
windows map[string]*windowEntry
|
||||
|
||||
starter processStarter
|
||||
executable string
|
||||
openTimeout time.Duration
|
||||
emitToWails func(context.Context, string, ...any)
|
||||
}
|
||||
|
||||
// NewManager prepares a detached-window manager around the already-created
|
||||
// desktop backend instances. InitializeLifecycle starts its random loopback
|
||||
// listener after Wails provides the runtime context.
|
||||
func NewManager(assetFS fs.FS, app *appcore.App, ai *aiservice.Service) (*Manager, error) {
|
||||
token, err := newBridgeToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create detached-window token failed: %w", err)
|
||||
}
|
||||
shared, err := webserver.NewSharedRuntime(assetFS, app, ai, webserver.SharedRuntimeOptions{
|
||||
RuntimeBridgePath: RuntimePath,
|
||||
RuntimeBridgeScript: detachedRuntimeBridgeScript(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve executable failed: %w", err)
|
||||
}
|
||||
return &Manager{
|
||||
shared: shared,
|
||||
token: token,
|
||||
windows: make(map[string]*windowEntry),
|
||||
starter: execProcessStarter{},
|
||||
executable: executable,
|
||||
openTimeout: defaultOpenReadyTimeout,
|
||||
emitToWails: func(ctx context.Context, name string, args ...any) {
|
||||
wailsRuntime.EventsEmit(ctx, name, args...)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newBridgeToken() (string, error) {
|
||||
payload := make([]byte, 32)
|
||||
if _, err := rand.Read(payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(payload), nil
|
||||
}
|
||||
|
||||
// InitializeLifecycle starts the loopback bridge and attaches the main Wails
|
||||
// runtime. Call it before initialising App and AI lifecycle contexts.
|
||||
func InitializeLifecycle(manager *Manager, ctx context.Context) error {
|
||||
if manager == nil {
|
||||
return fmt.Errorf("native window manager is unavailable")
|
||||
}
|
||||
return manager.initialize(ctx)
|
||||
}
|
||||
|
||||
// WithLifecycleContext makes App/AI events fan out to both the main Wails
|
||||
// window and every detached child. It does not rerun either backend lifecycle.
|
||||
func WithLifecycleContext(manager *Manager, ctx context.Context) context.Context {
|
||||
if manager == nil {
|
||||
return ctx
|
||||
}
|
||||
return uievents.WithEmitter(ctx, managerEventEmitter{manager: manager})
|
||||
}
|
||||
|
||||
type managerEventEmitter struct {
|
||||
manager *Manager
|
||||
}
|
||||
|
||||
func (e managerEventEmitter) Emit(name string, args ...any) {
|
||||
e.manager.emit(name, args...)
|
||||
}
|
||||
|
||||
// ShutdownLifecycle closes child processes and the private loopback server.
|
||||
func ShutdownLifecycle(manager *Manager) {
|
||||
if manager != nil {
|
||||
manager.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) initialize(ctx context.Context) error {
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("start detached-window bridge failed: %w", err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.started {
|
||||
m.mu.Unlock()
|
||||
_ = listener.Close()
|
||||
return nil
|
||||
}
|
||||
m.runtimeCtx = ctx
|
||||
m.listener = listener
|
||||
m.endpoint = "http://" + listener.Addr().String()
|
||||
m.started = true
|
||||
httpServer := &http.Server{
|
||||
Handler: m.authenticatedHandler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
m.httpServer = httpServer
|
||||
m.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
_ = httpServer.Serve(listener)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// emit retains normal main-window delivery and also copies backend events to
|
||||
// the child-side Go SSE clients.
|
||||
func (m *Manager) emit(name string, args ...any) {
|
||||
if m == nil || strings.TrimSpace(name) == "" {
|
||||
return
|
||||
}
|
||||
m.mu.RLock()
|
||||
ctx := m.runtimeCtx
|
||||
emitToWails := m.emitToWails
|
||||
shared := m.shared
|
||||
m.mu.RUnlock()
|
||||
if ctx != nil && emitToWails != nil {
|
||||
emitToWails(ctx, name, args...)
|
||||
}
|
||||
if shared != nil {
|
||||
shared.Emit(name, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// Open launches one native Wails child process. Existing IDs are focused
|
||||
// instead of duplicated.
|
||||
func (m *Manager) Open(request OpenRequest) OperationResult {
|
||||
return m.open(request, "")
|
||||
}
|
||||
|
||||
func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
|
||||
if m == nil {
|
||||
return operationFailure("native window manager is unavailable")
|
||||
}
|
||||
request = normalizeOpenRequest(request)
|
||||
request.ID = strings.TrimSpace(request.ID)
|
||||
if request.ID == "" {
|
||||
request.ID = uuid.NewString()
|
||||
}
|
||||
if err := validateOpenRequest(request); err != nil {
|
||||
return operationFailure(err.Error())
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if !m.started || m.closing || m.endpoint == "" {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window manager is not running")
|
||||
}
|
||||
if _, exists := m.windows[request.ID]; exists {
|
||||
m.mu.Unlock()
|
||||
result := m.Focus(request.ID)
|
||||
result.ID = request.ID
|
||||
return result
|
||||
}
|
||||
entry := &windowEntry{
|
||||
info: WindowInfo{
|
||||
ID: request.ID,
|
||||
Kind: request.Kind,
|
||||
Title: request.Title,
|
||||
X: request.X,
|
||||
Y: request.Y,
|
||||
Width: request.Width,
|
||||
Height: request.Height,
|
||||
OpenedAt: time.Now().UnixMilli(),
|
||||
},
|
||||
payload: request.Payload,
|
||||
ownerID: strings.TrimSpace(ownerID),
|
||||
ready: make(chan struct{}),
|
||||
done: make(chan processExit, 1),
|
||||
}
|
||||
m.windows[request.ID] = entry
|
||||
spec := m.processSpecLocked(request)
|
||||
starter := m.starter
|
||||
m.mu.Unlock()
|
||||
|
||||
process, err := starter.Start(spec)
|
||||
if err != nil {
|
||||
m.mu.Lock()
|
||||
delete(m.windows, request.ID)
|
||||
m.mu.Unlock()
|
||||
return operationFailure(fmt.Sprintf("open native window failed: %v", err))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
current, exists := m.windows[request.ID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
_ = process.Kill()
|
||||
return operationFailure("native window was closed while starting")
|
||||
}
|
||||
current.process = process
|
||||
current.info.PID = process.PID()
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.watchProcess(request.ID, process)
|
||||
timeout := m.openTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultOpenReadyTimeout
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-entry.ready:
|
||||
m.mu.Lock()
|
||||
current, active := m.windows[request.ID]
|
||||
if active && current == entry {
|
||||
entry.acknowledged = true
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !active {
|
||||
return operationFailure("native window exited before it became ready")
|
||||
}
|
||||
return OperationResult{Success: true, ID: request.ID}
|
||||
case exit := <-entry.done:
|
||||
message := "native window exited before it became ready"
|
||||
if exit.err != nil {
|
||||
message = fmt.Sprintf("%s: %v", message, exit.err)
|
||||
}
|
||||
return operationFailure(message)
|
||||
case <-timer.C:
|
||||
m.mu.Lock()
|
||||
current, active := m.windows[request.ID]
|
||||
if active && current == entry {
|
||||
delete(m.windows, request.ID)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if active {
|
||||
_ = process.Kill()
|
||||
}
|
||||
return operationFailure("native window did not become ready in time")
|
||||
}
|
||||
}
|
||||
|
||||
// OpenResult is a convenience binding for the separately detachable result
|
||||
// surface.
|
||||
func (m *Manager) OpenResult(request OpenRequest) OperationResult {
|
||||
if strings.TrimSpace(request.Kind) == "" {
|
||||
request.Kind = "query-result"
|
||||
}
|
||||
return m.Open(request)
|
||||
}
|
||||
|
||||
// Focus restores and raises an existing native child window.
|
||||
func (m *Manager) Focus(id string) OperationResult {
|
||||
if m == nil {
|
||||
return operationFailure("native window manager is unavailable")
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
m.mu.RLock()
|
||||
_, exists := m.windows[id]
|
||||
shared := m.shared
|
||||
m.mu.RUnlock()
|
||||
if !exists {
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
shared.Emit(CommandEventName, childCommand{ID: id, Action: "focus"})
|
||||
return OperationResult{Success: true, ID: id}
|
||||
}
|
||||
|
||||
// Close requests a graceful child shutdown and force-kills it if the WebView is
|
||||
// no longer responsive.
|
||||
func (m *Manager) Close(id string) OperationResult {
|
||||
if m == nil {
|
||||
return operationFailure("native window manager is unavailable")
|
||||
}
|
||||
return m.requestClose(strings.TrimSpace(id), ExitReasonRequested)
|
||||
}
|
||||
|
||||
func (m *Manager) requestClose(id string, reason string) OperationResult {
|
||||
m.mu.Lock()
|
||||
entry, exists := m.windows[id]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return operationFailure("native window was not found")
|
||||
}
|
||||
childAlreadyClosing := entry.exitReason == ExitReasonAttached || entry.exitReason == ExitReasonWindowClosed
|
||||
if entry.exitReason == "" {
|
||||
entry.exitReason = reason
|
||||
}
|
||||
entry.info.CloseSent = true
|
||||
process := entry.process
|
||||
shared := m.shared
|
||||
m.mu.Unlock()
|
||||
|
||||
// attach/close actions are emitted before their HTTP response is written.
|
||||
// Do not race that response with a second close command: the child will quit
|
||||
// itself after receiving the successful terminal-action response.
|
||||
if !childAlreadyClosing {
|
||||
shared.Emit(CommandEventName, childCommand{ID: id, Action: "close"})
|
||||
}
|
||||
if process != nil {
|
||||
time.AfterFunc(forceCloseDelay, func() {
|
||||
m.mu.RLock()
|
||||
current, active := m.windows[id]
|
||||
m.mu.RUnlock()
|
||||
if active && current.process == process {
|
||||
_ = process.Kill()
|
||||
}
|
||||
})
|
||||
}
|
||||
return OperationResult{Success: true, ID: id}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func (m *Manager) processSpecLocked(request OpenRequest) processSpec {
|
||||
env := append([]string(nil), os.Environ()...)
|
||||
values := map[string]string{
|
||||
envParentURL: m.endpoint,
|
||||
envToken: m.token,
|
||||
envWindowID: request.ID,
|
||||
envKind: request.Kind,
|
||||
envTitle: request.Title,
|
||||
envX: fmt.Sprintf("%d", request.X),
|
||||
envY: fmt.Sprintf("%d", request.Y),
|
||||
envWidth: fmt.Sprintf("%d", request.Width),
|
||||
envHeight: fmt.Sprintf("%d", request.Height),
|
||||
}
|
||||
for name, value := range values {
|
||||
env = setEnvironmentValue(env, name, value)
|
||||
}
|
||||
return processSpec{Executable: m.executable, Env: env}
|
||||
}
|
||||
|
||||
func setEnvironmentValue(environment []string, name string, value string) []string {
|
||||
prefix := name + "="
|
||||
filtered := environment[:0]
|
||||
for _, item := range environment {
|
||||
if !strings.HasPrefix(item, prefix) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return append(filtered, prefix+value)
|
||||
}
|
||||
|
||||
func (m *Manager) watchProcess(id string, process childProcess) {
|
||||
err := process.Wait()
|
||||
m.mu.Lock()
|
||||
entry, exists := m.windows[id]
|
||||
if !exists || entry.process != process {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(m.windows, id)
|
||||
reason := entry.exitReason
|
||||
if reason == "" {
|
||||
if err != nil {
|
||||
reason = ExitReasonProcessError
|
||||
} else {
|
||||
reason = ExitReasonWindowClosed
|
||||
}
|
||||
}
|
||||
info := entry.info
|
||||
ownerID := entry.ownerID
|
||||
acknowledged := entry.acknowledged
|
||||
entry.doneOnce.Do(func() {
|
||||
if entry.done != nil {
|
||||
entry.done <- processExit{err: err}
|
||||
close(entry.done)
|
||||
}
|
||||
})
|
||||
m.mu.Unlock()
|
||||
if !acknowledged {
|
||||
return
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"reason": reason,
|
||||
"exited": true,
|
||||
}
|
||||
if err != nil && reason == ExitReasonProcessError {
|
||||
payload["error"] = err.Error()
|
||||
}
|
||||
m.emitDetached(Event{
|
||||
ID: info.ID,
|
||||
Kind: info.Kind,
|
||||
Action: "close",
|
||||
Payload: withOwnerWindowID(payload, ownerID),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) emitDetached(event Event) {
|
||||
m.emit(MainEventName, event)
|
||||
}
|
||||
|
||||
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" {
|
||||
return fmt.Errorf("native window kind is unsupported")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) shutdown() {
|
||||
m.mu.Lock()
|
||||
if m.closing {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.closing = true
|
||||
processes := make([]childProcess, 0, len(m.windows))
|
||||
for _, entry := range m.windows {
|
||||
entry.exitReason = ExitReasonParentShutdown
|
||||
if entry.process != nil {
|
||||
processes = append(processes, entry.process)
|
||||
}
|
||||
}
|
||||
httpServer := m.httpServer
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, process := range processes {
|
||||
_ = process.Kill()
|
||||
}
|
||||
if httpServer != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
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.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) 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":
|
||||
default:
|
||||
http.Error(w, "unsupported detached action", 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
|
||||
}
|
||||
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
|
||||
}
|
||||
info := entry.info
|
||||
ownerID := entry.ownerID
|
||||
m.mu.Unlock()
|
||||
|
||||
if request.Action != "ready" {
|
||||
m.emitDetached(Event{
|
||||
ID: info.ID,
|
||||
Kind: info.Kind,
|
||||
Action: request.Action,
|
||||
Payload: withOwnerWindowID(request.Payload, ownerID),
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(OperationResult{Success: true, ID: id})
|
||||
}
|
||||
|
||||
func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var request controlRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxDetachedJSONBytes))
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid detached control request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
|
||||
ownerID := strings.TrimSpace(r.Header.Get(HeaderWindowID))
|
||||
|
||||
var result OperationResult
|
||||
switch request.Action {
|
||||
case "open":
|
||||
if targetID := strings.TrimSpace(request.Request.ID); targetID != "" && !m.canOpenOwned(targetID, ownerID) {
|
||||
result = operationFailure("native window id belongs to another owner")
|
||||
break
|
||||
}
|
||||
result = m.open(request.Request, ownerID)
|
||||
if result.Success {
|
||||
m.emitDetached(Event{
|
||||
ID: result.ID,
|
||||
Kind: normalizeOpenRequest(request.Request).Kind,
|
||||
Action: "opened",
|
||||
Payload: withOwnerWindowID(
|
||||
openEventPayload(request.Request.Payload),
|
||||
ownerID,
|
||||
),
|
||||
})
|
||||
}
|
||||
case "focus":
|
||||
if !m.ownsWindow(request.ID, ownerID) {
|
||||
result = operationFailure("native window is not owned by this window")
|
||||
break
|
||||
}
|
||||
result = m.Focus(request.ID)
|
||||
case "close":
|
||||
if !m.ownsWindow(request.ID, ownerID) {
|
||||
result = operationFailure("native window is not owned by this window")
|
||||
break
|
||||
}
|
||||
result = m.Close(request.ID)
|
||||
case "close-owned":
|
||||
result = m.closeOwned(ownerID)
|
||||
default:
|
||||
http.Error(w, "unsupported detached control action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if !result.Success {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func openEventPayload(payload any) any {
|
||||
source, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, 2)
|
||||
for _, key := range []string{"tab", "resultWindow"} {
|
||||
if value, exists := source[key]; exists {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *Manager) canOpenOwned(id string, ownerID string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
entry, exists := m.windows[strings.TrimSpace(id)]
|
||||
return !exists || entry.ownerID == strings.TrimSpace(ownerID)
|
||||
}
|
||||
|
||||
func (m *Manager) ownsWindow(id string, ownerID string) bool {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
entry, exists := m.windows[strings.TrimSpace(id)]
|
||||
return exists && entry.ownerID == strings.TrimSpace(ownerID)
|
||||
}
|
||||
|
||||
func (m *Manager) closeOwned(ownerID string) OperationResult {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID == "" {
|
||||
return operationFailure("native owner window is required")
|
||||
}
|
||||
m.mu.RLock()
|
||||
ids := make([]string, 0)
|
||||
for id, entry := range m.windows {
|
||||
if entry.ownerID == ownerID {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
for _, id := range ids {
|
||||
m.requestClose(id, ExitReasonRequested)
|
||||
}
|
||||
return OperationResult{Success: true}
|
||||
}
|
||||
|
||||
func withOwnerWindowID(payload any, ownerID string) any {
|
||||
ownerID = strings.TrimSpace(ownerID)
|
||||
if ownerID == "" {
|
||||
return payload
|
||||
}
|
||||
result := make(map[string]any)
|
||||
if source, ok := payload.(map[string]any); ok {
|
||||
for key, value := range source {
|
||||
result[key] = value
|
||||
}
|
||||
} else if payload != nil {
|
||||
result["value"] = payload
|
||||
}
|
||||
result["ownerWindowId"] = ownerID
|
||||
return result
|
||||
}
|
||||
486
internal/nativewindow/manager_test.go
Normal file
486
internal/nativewindow/manager_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
aiservice "GoNavi-Wails/internal/ai/service"
|
||||
appcore "GoNavi-Wails/internal/app"
|
||||
)
|
||||
|
||||
type fakeProcessStarter struct {
|
||||
mu sync.Mutex
|
||||
nextPID int
|
||||
specs []processSpec
|
||||
processes []*fakeChildProcess
|
||||
onStart func(processSpec, *fakeChildProcess)
|
||||
}
|
||||
|
||||
func (s *fakeProcessStarter) Start(spec processSpec) (childProcess, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.nextPID++
|
||||
process := &fakeChildProcess{pid: s.nextPID, done: make(chan error, 1), killed: make(chan struct{})}
|
||||
s.specs = append(s.specs, spec)
|
||||
s.processes = append(s.processes, process)
|
||||
onStart := s.onStart
|
||||
if onStart != nil {
|
||||
go onStart(spec, process)
|
||||
}
|
||||
return process, nil
|
||||
}
|
||||
|
||||
type fakeChildProcess struct {
|
||||
pid int
|
||||
done chan error
|
||||
killed chan struct{}
|
||||
killOnce sync.Once
|
||||
}
|
||||
|
||||
func (p *fakeChildProcess) PID() int { return p.pid }
|
||||
func (p *fakeChildProcess) Wait() error {
|
||||
return <-p.done
|
||||
}
|
||||
func (p *fakeChildProcess) Kill() error {
|
||||
p.killOnce.Do(func() {
|
||||
close(p.killed)
|
||||
p.done <- errors.New("killed")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
func (p *fakeChildProcess) finish(err error) {
|
||||
p.killOnce.Do(func() { p.done <- err })
|
||||
}
|
||||
|
||||
func TestParseChildOptionsPreservesNegativeVirtualDesktopCoordinates(t *testing.T) {
|
||||
t.Setenv(envParentURL, "")
|
||||
t.Setenv(envToken, "")
|
||||
t.Setenv(envWindowID, "")
|
||||
|
||||
options, err := ParseChildOptions([]string{
|
||||
"--parent-url=http://127.0.0.1:43119",
|
||||
"--token=test-token",
|
||||
"--id=window-1",
|
||||
"--x=-2560",
|
||||
"--y=-180",
|
||||
"--width=1400",
|
||||
"--height=900",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseChildOptions returned error: %v", err)
|
||||
}
|
||||
if options.X != -2560 || options.Y != -180 {
|
||||
t.Fatalf("virtual desktop coordinates were clamped: x=%d y=%d", options.X, options.Y)
|
||||
}
|
||||
if options.Width != 1400 || options.Height != 900 {
|
||||
t.Fatalf("unexpected child size: %dx%d", options.Width, options.Height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOpenGeneratesUniqueIDsAndRegistersMultipleWindows(t *testing.T) {
|
||||
starter := &fakeProcessStarter{nextPID: 100}
|
||||
manager := &Manager{
|
||||
token: "test-token",
|
||||
endpoint: "http://127.0.0.1:43119",
|
||||
started: true,
|
||||
windows: make(map[string]*windowEntry),
|
||||
starter: starter,
|
||||
executable: "/tmp/GoNavi",
|
||||
openTimeout: time.Second,
|
||||
}
|
||||
starter.onStart = func(spec processSpec, _ *fakeChildProcess) {
|
||||
id := environmentValue(spec.Env, envWindowID)
|
||||
manager.mu.Lock()
|
||||
entry := manager.windows[id]
|
||||
if entry != nil {
|
||||
entry.info.Ready = true
|
||||
entry.readyOnce.Do(func() { close(entry.ready) })
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
first := manager.Open(OpenRequest{Kind: "workbench", X: -1920, Y: 40, Width: 1100, Height: 760})
|
||||
second := manager.Open(OpenRequest{Kind: "query-result", X: 1720, Y: -20, Width: 900, Height: 680})
|
||||
if !first.Success || !second.Success {
|
||||
t.Fatalf("Open results = %#v %#v", first, second)
|
||||
}
|
||||
if first.ID == "" || second.ID == "" || first.ID == second.ID {
|
||||
t.Fatalf("expected unique generated IDs, got %q and %q", first.ID, second.ID)
|
||||
}
|
||||
if windows := manager.List(); len(windows) != 2 {
|
||||
t.Fatalf("registry size = %d, want 2", len(windows))
|
||||
}
|
||||
|
||||
starter.mu.Lock()
|
||||
firstSpec := starter.specs[0]
|
||||
processes := append([]*fakeChildProcess(nil), starter.processes...)
|
||||
starter.mu.Unlock()
|
||||
if value := environmentValue(firstSpec.Env, envX); value != "-1920" {
|
||||
t.Fatalf("child x environment = %q, want -1920", value)
|
||||
}
|
||||
if value := environmentValue(firstSpec.Env, envY); value != "40" {
|
||||
t.Fatalf("child y environment = %q, want 40", value)
|
||||
}
|
||||
for _, process := range processes {
|
||||
process.finish(nil)
|
||||
}
|
||||
waitForRegistrySize(t, manager, 0)
|
||||
}
|
||||
|
||||
func TestAuthenticatedHandlerRequiresLoopbackTokenAndRegisteredWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.windows["window-1"] = &windowEntry{
|
||||
info: WindowInfo{ID: "window-1", Kind: "query-result", Title: "Result"},
|
||||
payload: map[string]any{"value": "shared"},
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
handler := manager.authenticatedHandler()
|
||||
|
||||
missingToken := httptest.NewRequest(http.MethodGet, BootstrapPath, nil)
|
||||
missingToken.RemoteAddr = "127.0.0.1:51001"
|
||||
missingToken.Header.Set(HeaderWindowID, "window-1")
|
||||
missingRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missingRecorder, missingToken)
|
||||
if missingRecorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing-token status = %d, want 401", missingRecorder.Code)
|
||||
}
|
||||
|
||||
remoteRequest := httptest.NewRequest(http.MethodGet, BootstrapPath, nil)
|
||||
remoteRequest.RemoteAddr = "192.0.2.10:51002"
|
||||
remoteRequest.Header.Set(HeaderToken, manager.token)
|
||||
remoteRequest.Header.Set(HeaderWindowID, "window-1")
|
||||
remoteRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(remoteRecorder, remoteRequest)
|
||||
if remoteRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-loopback status = %d, want 403", remoteRecorder.Code)
|
||||
}
|
||||
|
||||
unknownRequest := authenticatedRequest(manager, http.MethodGet, BootstrapPath, "window-2", nil)
|
||||
unknownRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(unknownRecorder, unknownRequest)
|
||||
if unknownRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("unknown-window status = %d, want 403", unknownRecorder.Code)
|
||||
}
|
||||
|
||||
validRequest := authenticatedRequest(manager, http.MethodGet, BootstrapPath, "window-1", nil)
|
||||
validRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(validRecorder, validRequest)
|
||||
if validRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("valid bootstrap status = %d body=%s", validRecorder.Code, validRecorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(validRecorder.Body.String(), `"id":"window-1"`) || !strings.Contains(validRecorder.Body.String(), `"value":"shared"`) {
|
||||
t.Fatalf("unexpected bootstrap body: %s", validRecorder.Body.String())
|
||||
}
|
||||
select {
|
||||
case <-manager.windows["window-1"].ready:
|
||||
t.Fatal("bootstrap read acknowledged the window before frontend mount")
|
||||
default:
|
||||
}
|
||||
|
||||
readyBody := strings.NewReader(`{"action":"ready","payload":{"id":"window-1","kind":"query-result"}}`)
|
||||
readyRequest := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", readyBody)
|
||||
readyRecorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(readyRecorder, readyRequest)
|
||||
if readyRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("ready status = %d body=%s", readyRecorder.Code, readyRecorder.Body.String())
|
||||
}
|
||||
select {
|
||||
case <-manager.windows["window-1"].ready:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("ready action did not acknowledge the native window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
manager.started = true
|
||||
manager.endpoint = "http://127.0.0.1:43119"
|
||||
manager.executable = "/tmp/GoNavi"
|
||||
manager.openTimeout = time.Second
|
||||
manager.windows["workbench:query-a"] = &windowEntry{
|
||||
info: WindowInfo{ID: "workbench:query-a", Kind: "workbench", Title: "SQL"},
|
||||
}
|
||||
|
||||
starter := &fakeProcessStarter{nextPID: 500}
|
||||
manager.starter = starter
|
||||
starter.onStart = func(spec processSpec, _ *fakeChildProcess) {
|
||||
id := environmentValue(spec.Env, envWindowID)
|
||||
manager.mu.Lock()
|
||||
entry := manager.windows[id]
|
||||
if entry != nil {
|
||||
entry.info.Ready = true
|
||||
entry.readyOnce.Do(func() { close(entry.ready) })
|
||||
}
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
events := make(chan Event, 4)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
events <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.NewReader(`{
|
||||
"action":"open",
|
||||
"request":{
|
||||
"id":"query-result:query-a:r1",
|
||||
"kind":"query-result",
|
||||
"title":"Result 1",
|
||||
"x":2100,
|
||||
"y":-120,
|
||||
"width":900,
|
||||
"height":620,
|
||||
"payload":{
|
||||
"storeState":{},
|
||||
"resultWindow":{"id":"query-result:query-a:r1","sourceQueryTabId":"query-a"}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
request := authenticatedRequest(manager, http.MethodPost, ControlPath, "workbench:query-a", body)
|
||||
recorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("owned open status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
manager.mu.RLock()
|
||||
ownedEntry := manager.windows["query-result:query-a:r1"]
|
||||
manager.mu.RUnlock()
|
||||
if ownedEntry == nil || ownedEntry.ownerID != "workbench:query-a" {
|
||||
t.Fatalf("owned entry = %#v, want owner workbench:query-a", ownedEntry)
|
||||
}
|
||||
opened := receiveEvent(t, events)
|
||||
if opened.ID != "query-result:query-a:r1" || opened.Action != "opened" {
|
||||
t.Fatalf("unexpected opened event: %#v", opened)
|
||||
}
|
||||
payload, ok := opened.Payload.(map[string]any)
|
||||
if !ok || payload["ownerWindowId"] != "workbench:query-a" {
|
||||
t.Fatalf("opened event owner metadata = %#v", opened.Payload)
|
||||
}
|
||||
|
||||
manager.windows["foreign-window"] = &windowEntry{
|
||||
info: WindowInfo{ID: "foreign-window", Kind: "query-result"},
|
||||
ownerID: "workbench:query-b",
|
||||
}
|
||||
foreignBody := strings.NewReader(`{"action":"close","id":"foreign-window"}`)
|
||||
foreignRequest := authenticatedRequest(manager, http.MethodPost, ControlPath, "workbench:query-a", foreignBody)
|
||||
foreignRecorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(foreignRecorder, foreignRequest)
|
||||
if foreignRecorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("foreign close status = %d, want 400", foreignRecorder.Code)
|
||||
}
|
||||
|
||||
starter.mu.Lock()
|
||||
process := starter.processes[0]
|
||||
starter.mu.Unlock()
|
||||
process.finish(nil)
|
||||
waitForRegistrySize(t, manager, 2)
|
||||
}
|
||||
|
||||
func TestActionAndProcessExitEmitStableMainEventPayload(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
events := make(chan Event, 4)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
if event, ok := args[0].(Event); ok {
|
||||
events <- event
|
||||
}
|
||||
}
|
||||
}
|
||||
process := &fakeChildProcess{pid: 42, done: make(chan error, 1), killed: make(chan struct{})}
|
||||
manager.windows["window-1"] = &windowEntry{
|
||||
info: WindowInfo{ID: "window-1", Kind: "workbench", Title: "SQL"},
|
||||
ownerID: "workbench:source",
|
||||
process: process,
|
||||
acknowledged: true,
|
||||
}
|
||||
|
||||
actionBody := strings.NewReader(`{"action":"sync","payload":{"id":"window-1","storeState":{"activeTab":"sql"}}}`)
|
||||
actionRequest := authenticatedRequest(manager, http.MethodPost, ActionPath, "window-1", actionBody)
|
||||
actionRecorder := httptest.NewRecorder()
|
||||
manager.authenticatedHandler().ServeHTTP(actionRecorder, actionRequest)
|
||||
if actionRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("action status = %d body=%s", actionRecorder.Code, actionRecorder.Body.String())
|
||||
}
|
||||
syncEvent := receiveEvent(t, events)
|
||||
if syncEvent.ID != "window-1" || syncEvent.Kind != "workbench" || syncEvent.Action != "sync" {
|
||||
t.Fatalf("unexpected sync event: %#v", syncEvent)
|
||||
}
|
||||
if payload, ok := syncEvent.Payload.(map[string]any); !ok || payload["ownerWindowId"] != "workbench:source" {
|
||||
t.Fatalf("sync event is missing owner metadata: %#v", syncEvent.Payload)
|
||||
}
|
||||
|
||||
go manager.watchProcess("window-1", process)
|
||||
process.finish(errors.New("exit status 9"))
|
||||
exitEvent := receiveEvent(t, events)
|
||||
if exitEvent.ID != "window-1" || exitEvent.Action != "close" {
|
||||
t.Fatalf("unexpected exit event: %#v", exitEvent)
|
||||
}
|
||||
payload, ok := exitEvent.Payload.(map[string]any)
|
||||
if !ok || payload["reason"] != ExitReasonProcessError || payload["exited"] != true {
|
||||
t.Fatalf("unexpected exit payload: %#v", exitEvent.Payload)
|
||||
}
|
||||
if payload["ownerWindowId"] != "workbench:source" {
|
||||
t.Fatalf("exit event is missing owner metadata: %#v", exitEvent.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestedCloseReasonSurvivesForcedProcessExit(t *testing.T) {
|
||||
manager := newHTTPTestManager(t)
|
||||
events := make(chan Event, 2)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName && len(args) == 1 {
|
||||
events <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
process := &fakeChildProcess{pid: 43, done: make(chan error, 1), killed: make(chan struct{})}
|
||||
manager.windows["window-2"] = &windowEntry{
|
||||
info: WindowInfo{ID: "window-2", Kind: "query-result"},
|
||||
process: process,
|
||||
exitReason: ExitReasonRequested,
|
||||
acknowledged: true,
|
||||
}
|
||||
go manager.watchProcess("window-2", process)
|
||||
process.finish(errors.New("signal: killed"))
|
||||
event := receiveEvent(t, events)
|
||||
payload := event.Payload.(map[string]any)
|
||||
if payload["reason"] != ExitReasonRequested {
|
||||
t.Fatalf("exit reason = %#v, want %q", payload["reason"], ExitReasonRequested)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOpenFailureBeforeBootstrapKeepsWindowUnacknowledged(t *testing.T) {
|
||||
starter := &fakeProcessStarter{nextPID: 200}
|
||||
manager := &Manager{
|
||||
token: "test-token",
|
||||
endpoint: "http://127.0.0.1:43119",
|
||||
started: true,
|
||||
windows: make(map[string]*windowEntry),
|
||||
starter: starter,
|
||||
executable: "/tmp/GoNavi",
|
||||
openTimeout: 25 * time.Millisecond,
|
||||
}
|
||||
|
||||
result := manager.Open(OpenRequest{ID: "never-ready", Kind: "workbench"})
|
||||
if result.Success || !strings.Contains(result.Message, "did not become ready") {
|
||||
t.Fatalf("Open result = %#v, want readiness failure", result)
|
||||
}
|
||||
if len(manager.List()) != 0 {
|
||||
t.Fatalf("timed-out child remained registered: %#v", manager.List())
|
||||
}
|
||||
starter.mu.Lock()
|
||||
process := starter.processes[0]
|
||||
starter.mu.Unlock()
|
||||
select {
|
||||
case <-process.killed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed-out child was not killed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOpenReportsChildExitBeforeBootstrapWithoutClosingMainTab(t *testing.T) {
|
||||
starter := &fakeProcessStarter{nextPID: 300}
|
||||
manager := &Manager{
|
||||
token: "test-token",
|
||||
endpoint: "http://127.0.0.1:43119",
|
||||
started: true,
|
||||
windows: make(map[string]*windowEntry),
|
||||
starter: starter,
|
||||
executable: "/tmp/GoNavi",
|
||||
openTimeout: time.Second,
|
||||
}
|
||||
events := make(chan Event, 1)
|
||||
manager.runtimeCtx = context.Background()
|
||||
manager.emitToWails = func(_ context.Context, name string, args ...any) {
|
||||
if name == MainEventName {
|
||||
events <- args[0].(Event)
|
||||
}
|
||||
}
|
||||
starter.onStart = func(_ processSpec, process *fakeChildProcess) {
|
||||
process.finish(errors.New("child startup failed"))
|
||||
}
|
||||
|
||||
result := manager.Open(OpenRequest{ID: "startup-failure", Kind: "workbench"})
|
||||
if result.Success || !strings.Contains(result.Message, "child startup failed") {
|
||||
t.Fatalf("Open result = %#v, want child startup failure", result)
|
||||
}
|
||||
if len(manager.List()) != 0 {
|
||||
t.Fatalf("failed child remained registered: %#v", manager.List())
|
||||
}
|
||||
select {
|
||||
case event := <-events:
|
||||
t.Fatalf("unacknowledged child emitted a main close event: %#v", event)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func newHTTPTestManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
assets := fstest.MapFS{
|
||||
"frontend/dist/index.html": &fstest.MapFile{Data: []byte("<html><head></head><body></body></html>")},
|
||||
}
|
||||
manager, err := NewManager(fs.FS(assets), appcore.NewWebApp(), aiservice.NewService())
|
||||
if err != nil {
|
||||
t.Fatalf("NewManager returned error: %v", err)
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
func authenticatedRequest(manager *Manager, method string, target string, id string, body *strings.Reader) *http.Request {
|
||||
var request *http.Request
|
||||
if body == nil {
|
||||
request = httptest.NewRequest(method, target, nil)
|
||||
} else {
|
||||
request = httptest.NewRequest(method, target, body)
|
||||
}
|
||||
request.RemoteAddr = "127.0.0.1:51003"
|
||||
request.Header.Set(HeaderToken, manager.token)
|
||||
request.Header.Set(HeaderWindowID, id)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
|
||||
func environmentValue(environment []string, name string) string {
|
||||
prefix := name + "="
|
||||
for _, item := range environment {
|
||||
if strings.HasPrefix(item, prefix) {
|
||||
return strings.TrimPrefix(item, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func waitForRegistrySize(t *testing.T, manager *Manager, size int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(manager.List()) == size {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("registry size = %d, want %d", len(manager.List()), size)
|
||||
}
|
||||
|
||||
func receiveEvent(t *testing.T, events <-chan Event) Event {
|
||||
t.Helper()
|
||||
select {
|
||||
case event := <-events:
|
||||
return event
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for detached event")
|
||||
return Event{}
|
||||
}
|
||||
}
|
||||
59
internal/nativewindow/process.go
Normal file
59
internal/nativewindow/process.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
type processSpec struct {
|
||||
Executable string
|
||||
Env []string
|
||||
}
|
||||
|
||||
type childProcess interface {
|
||||
PID() int
|
||||
Wait() error
|
||||
Kill() error
|
||||
}
|
||||
|
||||
type processStarter interface {
|
||||
Start(processSpec) (childProcess, error)
|
||||
}
|
||||
|
||||
type execProcessStarter struct{}
|
||||
|
||||
func (execProcessStarter) Start(spec processSpec) (childProcess, error) {
|
||||
command := exec.Command(spec.Executable, DetachedWindowArgument)
|
||||
command.Env = spec.Env
|
||||
command.Stdout = os.Stdout
|
||||
command.Stderr = os.Stderr
|
||||
if err := command.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &execChildProcess{command: command}, nil
|
||||
}
|
||||
|
||||
type execChildProcess struct {
|
||||
command *exec.Cmd
|
||||
}
|
||||
|
||||
func (p *execChildProcess) PID() int {
|
||||
if p == nil || p.command == nil || p.command.Process == nil {
|
||||
return 0
|
||||
}
|
||||
return p.command.Process.Pid
|
||||
}
|
||||
|
||||
func (p *execChildProcess) Wait() error {
|
||||
if p == nil || p.command == nil {
|
||||
return nil
|
||||
}
|
||||
return p.command.Wait()
|
||||
}
|
||||
|
||||
func (p *execChildProcess) Kill() error {
|
||||
if p == nil || p.command == nil || p.command.Process == nil {
|
||||
return nil
|
||||
}
|
||||
return p.command.Process.Kill()
|
||||
}
|
||||
110
internal/nativewindow/runtime_script.go
Normal file
110
internal/nativewindow/runtime_script.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package nativewindow
|
||||
|
||||
func detachedRuntimeBridgeScript() string {
|
||||
return `(function () {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
var existingGo = window.go || {};
|
||||
var nativeNamespace = existingGo.nativewindow || {};
|
||||
var bridge = nativeNamespace.Bridge;
|
||||
var control = nativeNamespace.Control;
|
||||
if (
|
||||
!bridge
|
||||
|| typeof bridge.Invoke !== 'function'
|
||||
|| typeof bridge.WindowID !== 'function'
|
||||
|| typeof bridge.OpenWindow !== 'function'
|
||||
|| typeof bridge.FocusWindow !== 'function'
|
||||
|| typeof bridge.CloseWindow !== 'function'
|
||||
|| typeof bridge.CloseOwnedWindows !== 'function'
|
||||
) {
|
||||
console.error('[GoNavi Detached] native bridge is unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
var invoke = function (namespace, receiver, method, args) {
|
||||
return bridge.Invoke(namespace, receiver, method, Array.isArray(args) ? args : []);
|
||||
};
|
||||
var buildServiceProxy = function (namespace, receiver) {
|
||||
return new Proxy({}, {
|
||||
get: function (_target, property) {
|
||||
if (typeof property !== 'string') return undefined;
|
||||
return function () {
|
||||
return invoke(namespace, receiver, property, Array.prototype.slice.call(arguments));
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
var parentWindowManager = {
|
||||
Open: function (request) {
|
||||
return bridge.OpenWindow(request || {});
|
||||
},
|
||||
Focus: function (id) {
|
||||
return bridge.FocusWindow(String(id || ''));
|
||||
},
|
||||
Close: function (id) {
|
||||
return bridge.CloseWindow(String(id || ''));
|
||||
},
|
||||
CloseAll: function () {
|
||||
return bridge.CloseOwnedWindows();
|
||||
}
|
||||
};
|
||||
nativeNamespace = {
|
||||
...nativeNamespace,
|
||||
Manager: parentWindowManager
|
||||
};
|
||||
|
||||
window.go = {
|
||||
...existingGo,
|
||||
nativewindow: nativeNamespace,
|
||||
app: {
|
||||
...(existingGo.app || {}),
|
||||
App: buildServiceProxy('app', 'App')
|
||||
},
|
||||
aiservice: {
|
||||
...(existingGo.aiservice || {}),
|
||||
Service: buildServiceProxy('aiservice', 'Service')
|
||||
}
|
||||
};
|
||||
|
||||
var detached = {
|
||||
active: true,
|
||||
bootstrap: null,
|
||||
bootstrapPromise: null,
|
||||
loadBootstrap: function () {
|
||||
if (!detached.bootstrapPromise) {
|
||||
detached.bootstrapPromise = bridge.Bootstrap().then(function (payload) {
|
||||
detached.bootstrap = payload;
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
return detached.bootstrapPromise;
|
||||
},
|
||||
action: function (action, payload) {
|
||||
return bridge.Action(String(action || ''), payload || {});
|
||||
}
|
||||
};
|
||||
window.__GONAVI_DETACHED__ = detached;
|
||||
window.__GONAVI_DETACHED_RUNTIME__ = {
|
||||
buildType: 'desktop-detached',
|
||||
capabilities: { nativeWindow: true, sharedBackend: true }
|
||||
};
|
||||
|
||||
var detachedWindowIDPromise = Promise.resolve(bridge.WindowID()).then(function (windowID) {
|
||||
return String(windowID || '');
|
||||
});
|
||||
|
||||
var runtime = window.runtime || {};
|
||||
if (typeof runtime.EventsOnMultiple === 'function') {
|
||||
runtime.EventsOnMultiple('` + CommandEventName + `', function (command) {
|
||||
detachedWindowIDPromise.then(function (windowID) {
|
||||
if (!command || String(command.id || '') !== windowID) return;
|
||||
if (command.action === 'close' && control && typeof control.Close === 'function') {
|
||||
control.Close();
|
||||
} else if (command.action === 'focus' && control && typeof control.Focus === 'function') {
|
||||
control.Focus();
|
||||
}
|
||||
});
|
||||
}, -1);
|
||||
}
|
||||
})();`
|
||||
}
|
||||
37
internal/nativewindow/runtime_script_test.go
Normal file
37
internal/nativewindow/runtime_script_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBridgeWindowIDUsesChildIdentity(t *testing.T) {
|
||||
bridge := newBridge(ChildOptions{ID: "workbench:query-1"})
|
||||
if got := bridge.WindowID(); got != "workbench:query-1" {
|
||||
t.Fatalf("WindowID() = %q, want %q", got, "workbench:query-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCommandsDoNotReloadBootstrapForWindowIdentity(t *testing.T) {
|
||||
script := detachedRuntimeBridgeScript()
|
||||
if !strings.Contains(script, "bridge.WindowID()") {
|
||||
t.Fatal("runtime bridge does not read the lightweight window identity")
|
||||
}
|
||||
if strings.Contains(script, "detached.loadBootstrap().then(function (bootstrap)") {
|
||||
t.Fatal("runtime command routing still reloads the full bootstrap payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeExposesParentWindowManagerInsideDetachedChildren(t *testing.T) {
|
||||
script := detachedRuntimeBridgeScript()
|
||||
for _, expected := range []string{
|
||||
"Manager: parentWindowManager",
|
||||
"bridge.OpenWindow(request || {})",
|
||||
"bridge.FocusWindow",
|
||||
"bridge.CloseWindow",
|
||||
} {
|
||||
if !strings.Contains(script, expected) {
|
||||
t.Fatalf("runtime bridge is missing %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
125
internal/nativewindow/types.go
Normal file
125
internal/nativewindow/types.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// DetachedWindowArgument selects the native detached-window child mode.
|
||||
DetachedWindowArgument = "--detached-window"
|
||||
|
||||
HeaderToken = "X-GoNavi-Detached-Token"
|
||||
HeaderWindowID = "X-GoNavi-Detached-Window-ID"
|
||||
|
||||
BootstrapPath = "/__gonavi/detached/bootstrap"
|
||||
ActionPath = "/__gonavi/detached/action"
|
||||
ControlPath = "/__gonavi/detached/control"
|
||||
RuntimePath = "/__gonavi/detached-runtime.js"
|
||||
InvokePath = "/__gonavi/api/invoke"
|
||||
EventsPath = "/__gonavi/events"
|
||||
|
||||
MainEventName = "gonavi:native-detached-event"
|
||||
CommandEventName = "gonavi:native-detached-command"
|
||||
|
||||
ExitReasonRequested = "requested"
|
||||
ExitReasonWindowClosed = "window-closed"
|
||||
ExitReasonAttached = "attached"
|
||||
ExitReasonParentShutdown = "parent-shutdown"
|
||||
ExitReasonProcessError = "process-error"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWindowWidth = 1080
|
||||
defaultWindowHeight = 720
|
||||
// Detached query results can be substantially larger than ordinary RPC
|
||||
// payloads. Keep one shared ceiling for child actions and parent responses.
|
||||
maxDetachedJSONBytes int64 = 512 << 20
|
||||
)
|
||||
|
||||
// OpenRequest describes one independently movable native window. X and Y use
|
||||
// virtual-desktop coordinates and deliberately allow negative values.
|
||||
type OpenRequest struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
// WindowInfo is the serialisable view of a registered child process.
|
||||
type WindowInfo struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
OpenedAt int64 `json:"openedAt"`
|
||||
Ready bool `json:"ready"`
|
||||
CloseSent bool `json:"closeSent"`
|
||||
}
|
||||
|
||||
// Bootstrap is fetched by the child after Wails has installed its native
|
||||
// runtime and bindings.
|
||||
type Bootstrap struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// OperationResult is returned by the Wails-bound Manager commands.
|
||||
type OperationResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// Event is emitted to the main Wails window. Action mirrors the detached HTTP
|
||||
// action protocol so the frontend can use one reducer for child messages and
|
||||
// process-exit notifications.
|
||||
type Event struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Action string `json:"action"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type actionRequest struct {
|
||||
Action string `json:"action"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type controlRequest struct {
|
||||
Action string `json:"action"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Request OpenRequest `json:"request,omitempty"`
|
||||
}
|
||||
|
||||
type childCommand struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
func normalizeOpenRequest(request OpenRequest) OpenRequest {
|
||||
request.Kind = strings.TrimSpace(request.Kind)
|
||||
request.Title = strings.TrimSpace(request.Title)
|
||||
if request.Kind == "" {
|
||||
request.Kind = "workbench"
|
||||
}
|
||||
if request.Title == "" {
|
||||
request.Title = "GoNavi"
|
||||
}
|
||||
if request.Width <= 0 {
|
||||
request.Width = defaultWindowWidth
|
||||
}
|
||||
if request.Height <= 0 {
|
||||
request.Height = defaultWindowHeight
|
||||
}
|
||||
return request
|
||||
}
|
||||
66
internal/nativewindow/window_bounds_darwin.go
Normal file
66
internal/nativewindow/window_bounds_darwin.go
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build darwin && cgo
|
||||
|
||||
package nativewindow
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Cocoa
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <dispatch/dispatch.h>
|
||||
|
||||
typedef struct {
|
||||
int x;
|
||||
int y;
|
||||
int width;
|
||||
int height;
|
||||
} DetachedWindowBounds;
|
||||
|
||||
static NSWindow *resolveDetachedWindow(void) {
|
||||
NSApplication *application = [NSApplication sharedApplication];
|
||||
NSWindow *window = [application mainWindow];
|
||||
if (window == nil) {
|
||||
window = [application keyWindow];
|
||||
}
|
||||
if (window == nil && [[application windows] count] > 0) {
|
||||
window = [[application windows] objectAtIndex:0];
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
static void applyDetachedWindowBounds(void *rawBounds) {
|
||||
DetachedWindowBounds *bounds = (DetachedWindowBounds *)rawBounds;
|
||||
NSWindow *window = resolveDetachedWindow();
|
||||
NSArray<NSScreen *> *screens = [NSScreen screens];
|
||||
if (window == nil || [screens count] == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser screenX/screenY use the primary display's top-left as the global
|
||||
// origin. Cocoa uses the primary display's bottom-left, so only Y needs to
|
||||
// be flipped around the primary screen's top edge.
|
||||
NSRect primaryFrame = [[screens objectAtIndex:0] frame];
|
||||
NSRect frame = [window frame];
|
||||
frame.origin.x = (CGFloat)bounds->x;
|
||||
frame.origin.y = NSMaxY(primaryFrame) - (CGFloat)bounds->y - (CGFloat)bounds->height;
|
||||
frame.size.width = (CGFloat)bounds->width;
|
||||
frame.size.height = (CGFloat)bounds->height;
|
||||
[window setFrame:frame display:NO animate:NO];
|
||||
}
|
||||
|
||||
static void setDetachedWindowBounds(int x, int y, int width, int height) {
|
||||
DetachedWindowBounds bounds = { x, y, width, height };
|
||||
if ([NSThread isMainThread]) {
|
||||
applyDetachedWindowBounds(&bounds);
|
||||
return;
|
||||
}
|
||||
dispatch_sync_f(dispatch_get_main_queue(), &bounds, applyDetachedWindowBounds);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "context"
|
||||
|
||||
func applyDetachedWindowBounds(_ context.Context, x, y, width, height int) {
|
||||
C.setDetachedWindowBounds(C.int(x), C.int(y), C.int(width), C.int(height))
|
||||
}
|
||||
14
internal/nativewindow/window_bounds_other.go
Normal file
14
internal/nativewindow/window_bounds_other.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
package nativewindow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
func applyDetachedWindowBounds(ctx context.Context, x, y, width, height int) {
|
||||
wailsRuntime.WindowSetSize(ctx, width, height)
|
||||
wailsRuntime.WindowSetPosition(ctx, x, y)
|
||||
}
|
||||
@@ -146,7 +146,8 @@ func (h *eventHub) unsubscribe(ch chan eventMessage) {
|
||||
}
|
||||
|
||||
type methodInvoker struct {
|
||||
targets map[string]reflect.Value
|
||||
targets map[string]reflect.Value
|
||||
allowDesktopMethods bool
|
||||
}
|
||||
|
||||
func newMethodInvoker(app *appcore.App, ai *aiservice.Service) *methodInvoker {
|
||||
@@ -179,7 +180,7 @@ func (i *methodInvoker) Invoke(req invokeRequest) (any, error) {
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported invoke target: %s.%s", namespace, receiver)
|
||||
}
|
||||
if (key == "app" || key == "app.app") && isDesktopOnlyAppMethod(methodName) {
|
||||
if !i.allowDesktopMethods && (key == "app" || key == "app.app") && isDesktopOnlyAppMethod(methodName) {
|
||||
return nil, fmt.Errorf("method %s is unavailable in web runtime", methodName)
|
||||
}
|
||||
|
||||
@@ -263,6 +264,130 @@ type Server struct {
|
||||
auditHeavySem chan struct{}
|
||||
}
|
||||
|
||||
// SharedRuntimeOptions configures the authenticated loopback runtime used by
|
||||
// native child windows. Authentication is intentionally owned by the caller so
|
||||
// the same handler can be protected by a process-scoped token instead of the
|
||||
// browser server's password/session flow.
|
||||
type SharedRuntimeOptions struct {
|
||||
RuntimeBridgePath string
|
||||
RuntimeBridgeScript string
|
||||
}
|
||||
|
||||
// SharedRuntime exposes the existing frontend assets and reflective App/AI RPC
|
||||
// bridge without creating a second backend. It is safe to host this on a
|
||||
// loopback-only listener owned by the desktop process.
|
||||
type SharedRuntime struct {
|
||||
server *Server
|
||||
runtimeBridgePath string
|
||||
runtimeBridgeScript string
|
||||
handler http.Handler
|
||||
}
|
||||
|
||||
// NewSharedRuntime creates an HTTP runtime backed by the already-running
|
||||
// desktop App and AI service. The caller remains responsible for their
|
||||
// lifecycle.
|
||||
func NewSharedRuntime(assetFS fs.FS, app *appcore.App, ai *aiservice.Service, options SharedRuntimeOptions) (*SharedRuntime, error) {
|
||||
if assetFS == nil {
|
||||
return nil, fmt.Errorf("web assets are unavailable")
|
||||
}
|
||||
if app == nil || ai == nil {
|
||||
return nil, fmt.Errorf("shared App and AI service are required")
|
||||
}
|
||||
frontendFS, err := fs.Sub(assetFS, "frontend/dist")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve frontend dist assets failed: %w", err)
|
||||
}
|
||||
|
||||
bridgePath := strings.TrimSpace(options.RuntimeBridgePath)
|
||||
if bridgePath == "" || !strings.HasPrefix(bridgePath, internalRoutePrefix+"/") {
|
||||
return nil, fmt.Errorf("runtime bridge path must be under %s", internalRoutePrefix)
|
||||
}
|
||||
|
||||
events := newEventHub()
|
||||
shared := &SharedRuntime{
|
||||
server: &Server{
|
||||
assets: frontendFS,
|
||||
app: app,
|
||||
ai: ai,
|
||||
events: events,
|
||||
invoker: func() *methodInvoker {
|
||||
invoker := newMethodInvoker(app, ai)
|
||||
invoker.allowDesktopMethods = true
|
||||
return invoker
|
||||
}(),
|
||||
auditHeavySem: make(chan struct{}, 1),
|
||||
},
|
||||
runtimeBridgePath: bridgePath,
|
||||
runtimeBridgeScript: options.RuntimeBridgeScript,
|
||||
}
|
||||
shared.handler = shared.routes()
|
||||
return shared, nil
|
||||
}
|
||||
|
||||
// Handler returns the shared runtime HTTP handler.
|
||||
func (s *SharedRuntime) Handler() http.Handler {
|
||||
if s == nil {
|
||||
return http.NotFoundHandler()
|
||||
}
|
||||
return s.handler
|
||||
}
|
||||
|
||||
// Emit publishes a backend event to every native child window connected to the
|
||||
// shared runtime event stream.
|
||||
func (s *SharedRuntime) Emit(name string, args ...any) {
|
||||
if s == nil || s.server == nil || s.server.events == nil {
|
||||
return
|
||||
}
|
||||
s.server.events.Emit(name, args...)
|
||||
}
|
||||
|
||||
func (s *SharedRuntime) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(internalRoutePrefix+"/api/invoke", s.server.handleInvoke)
|
||||
mux.HandleFunc(internalRoutePrefix+"/events", s.server.handleEvents)
|
||||
mux.HandleFunc(s.runtimeBridgePath, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
_, _ = w.Write([]byte(s.runtimeBridgeScript))
|
||||
})
|
||||
mux.HandleFunc(internalRoutePrefix+"/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
fileServer := http.FileServer(http.FS(s.server.assets))
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, internalRoutePrefix+"/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if s.server.shouldServeIndex(r.URL.Path) {
|
||||
payload, err := fs.ReadFile(s.server.assets, "index.html")
|
||||
if err != nil {
|
||||
http.Error(w, "frontend index is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
html := injectBodyScript(string(payload), s.runtimeBridgePath)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, strings.NewReader(html))
|
||||
return
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
return withSecurityHeaders(mux)
|
||||
}
|
||||
|
||||
func ParseOptions(args []string) (Options, error) {
|
||||
options := Options{
|
||||
Addr: defaultWebServerAddr,
|
||||
@@ -451,16 +576,31 @@ func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func injectRuntimeBridge(indexHTML string) string {
|
||||
if strings.Contains(indexHTML, internalRoutePrefix+"/web-runtime.js") {
|
||||
return injectScript(indexHTML, internalRoutePrefix+"/web-runtime.js")
|
||||
}
|
||||
|
||||
func injectScript(indexHTML string, scriptPath string) string {
|
||||
if strings.Contains(indexHTML, scriptPath) {
|
||||
return indexHTML
|
||||
}
|
||||
scriptTag := fmt.Sprintf(`<script src="%s/web-runtime.js"></script>`, internalRoutePrefix)
|
||||
scriptTag := fmt.Sprintf(`<script src="%s"></script>`, scriptPath)
|
||||
if strings.Contains(indexHTML, "</head>") {
|
||||
return strings.Replace(indexHTML, "</head>", scriptTag+"\n</head>", 1)
|
||||
}
|
||||
return scriptTag + "\n" + indexHTML
|
||||
}
|
||||
|
||||
func injectBodyScript(indexHTML string, scriptPath string) string {
|
||||
if strings.Contains(indexHTML, scriptPath) {
|
||||
return indexHTML
|
||||
}
|
||||
scriptTag := fmt.Sprintf(`<script src="%s"></script>`, scriptPath)
|
||||
if strings.Contains(indexHTML, "</body>") {
|
||||
return strings.Replace(indexHTML, "</body>", scriptTag+"\n</body>", 1)
|
||||
}
|
||||
return injectScript(indexHTML, scriptPath)
|
||||
}
|
||||
|
||||
func (s *Server) handleInvoke(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -2,9 +2,16 @@ package webserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
aiservice "GoNavi-Wails/internal/ai/service"
|
||||
appcore "GoNavi-Wails/internal/app"
|
||||
)
|
||||
|
||||
type webserverTestReceiver struct{}
|
||||
@@ -17,6 +24,10 @@ func (webserverTestReceiver) Sum(left int, right int) int {
|
||||
return left + right
|
||||
}
|
||||
|
||||
func (webserverTestReceiver) OpenSQLFile() string {
|
||||
return "desktop-method-reached"
|
||||
}
|
||||
|
||||
func TestInjectRuntimeBridgeAddsScriptOnce(t *testing.T) {
|
||||
indexHTML := "<html><head><title>GoNavi</title></head><body></body></html>"
|
||||
|
||||
@@ -99,6 +110,22 @@ func TestMethodInvokerRejectsDesktopOnlyAppMethodsBeforeReflection(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedMethodInvokerAllowsDesktopMethods(t *testing.T) {
|
||||
invoker := &methodInvoker{
|
||||
targets: map[string]reflect.Value{
|
||||
"app.app": reflect.ValueOf(webserverTestReceiver{}),
|
||||
},
|
||||
allowDesktopMethods: true,
|
||||
}
|
||||
result, err := invoker.Invoke(invokeRequest{Namespace: "app", Receiver: "app", Method: "OpenSQLFile"})
|
||||
if err != nil {
|
||||
t.Fatalf("shared desktop method was rejected: %v", err)
|
||||
}
|
||||
if result != "desktop-method-reached" {
|
||||
t.Fatalf("unexpected shared desktop result: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLAuditHeavyInvokeIncludesExportAndIntegrityVerification(t *testing.T) {
|
||||
for _, method := range []string{"BuildSQLAuditExport", "VerifySQLAuditIntegrity"} {
|
||||
if !isSQLAuditHeavyInvoke(invokeRequest{Namespace: "app", Receiver: "app", Method: method}) {
|
||||
@@ -109,3 +136,41 @@ func TestSQLAuditHeavyInvokeIncludesExportAndIntegrityVerification(t *testing.T)
|
||||
t.Fatal("ordinary paged audit reads must not use the heavy-operation semaphore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedRuntimeInjectsRequestedBridgeWithoutBrowserAuthentication(t *testing.T) {
|
||||
assets := fstest.MapFS{
|
||||
"frontend/dist/index.html": &fstest.MapFile{Data: []byte(`<html><head><script src="/wails/runtime.js"></script><title>GoNavi</title><script type="module" src="/assets/index.js"></script></head><body><div id="root"></div></body></html>`)},
|
||||
}
|
||||
shared, err := NewSharedRuntime(fs.FS(assets), appcore.NewWebApp(), aiservice.NewService(), SharedRuntimeOptions{
|
||||
RuntimeBridgePath: "/__gonavi/detached-runtime.js",
|
||||
RuntimeBridgeScript: "window.detachedRuntime = true;",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSharedRuntime returned error: %v", err)
|
||||
}
|
||||
|
||||
indexRequest := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
indexRecorder := httptest.NewRecorder()
|
||||
shared.Handler().ServeHTTP(indexRecorder, indexRequest)
|
||||
if indexRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("shared index status = %d", indexRecorder.Code)
|
||||
}
|
||||
if !strings.Contains(indexRecorder.Body.String(), "/__gonavi/detached-runtime.js") {
|
||||
t.Fatalf("shared index is missing detached bridge: %s", indexRecorder.Body.String())
|
||||
}
|
||||
html := indexRecorder.Body.String()
|
||||
bodyIndex := strings.Index(html, "<body>")
|
||||
runtimeIndex := strings.Index(html, "/wails/runtime.js")
|
||||
bridgeIndex := strings.Index(html, "/__gonavi/detached-runtime.js")
|
||||
bodyCloseIndex := strings.Index(html, "</body>")
|
||||
if runtimeIndex < 0 || bridgeIndex <= runtimeIndex || bodyIndex < 0 || bridgeIndex <= bodyIndex || bodyCloseIndex <= bridgeIndex {
|
||||
t.Fatalf("detached bridge must run after Wails runtime as the final body script, got: %s", html)
|
||||
}
|
||||
|
||||
bridgeRequest := httptest.NewRequest(http.MethodGet, "/__gonavi/detached-runtime.js", nil)
|
||||
bridgeRecorder := httptest.NewRecorder()
|
||||
shared.Handler().ServeHTTP(bridgeRecorder, bridgeRequest)
|
||||
if bridgeRecorder.Code != http.StatusOK || !strings.Contains(bridgeRecorder.Body.String(), "detachedRuntime") {
|
||||
t.Fatalf("unexpected bridge response: status=%d body=%s", bridgeRecorder.Code, bridgeRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user