feat(native-window): 支持标签页拖出为原生独立窗口

- 支持 SQL、数据和结果标签拖出到跨显示器原生窗口
- 通过受认证 loopback bridge 复用主进程后端与状态
- 支持子窗口继续拆窗、聚焦、关闭、还原与异常退出恢复
- 补充多窗口协议、状态同步和跨平台回归测试
This commit is contained in:
Syngnat
2026-07-16 20:25:08 +08:00
parent d3dee295d6
commit fa9d21be8b
33 changed files with 5036 additions and 33 deletions

View 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()
}

View File

@@ -0,0 +1,5 @@
//go:build !darwin || !cgo
package nativewindow
func setDetachedAccessoryActivationPolicy() {}

View 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}
}

View 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
}

View 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
}

View 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{}
}
}

View 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()
}

View 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);
}
})();`
}

View 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)
}
}
}

View 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
}

View 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))
}

View 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)
}

View File

@@ -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)

View File

@@ -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())
}
}