feat(native-window): 支持 macOS Dock 管理独立窗口

- 在主程序 Dock 菜单动态列出并聚焦已就绪的独立窗口
- 在 Wails 启动前固定子进程为 Accessory,消除临时 Dock 图标闪现
- 按多显示器可见区域修正越界窗口并回写实际坐标
- 将自定义 AI 面板快捷键同步到独立窗口并转交主窗口处理
- 补齐 Go、Objective-C、前端及 Wails 绑定回归测试
This commit is contained in:
Syngnat
2026-07-21 18:52:54 +08:00
parent d7b60891c5
commit 5c3535c85d
29 changed files with 2075 additions and 49 deletions

View File

@@ -8,6 +8,66 @@ package nativewindow
#import <Cocoa/Cocoa.h>
#import <dispatch/dispatch.h>
#import <objc/runtime.h>
static IMP detachedOriginalSetActivationPolicy = NULL;
static BOOL detachedAccessoryActivationPolicyGuardActive = NO;
// Wails v2 requests Regular during applicationWillFinishLaunching. Intercept
// the setter so the detached child never enters a Dock-visible policy. The
// child owns this process, so the guard intentionally remains for its lifetime.
static BOOL setDetachedGuardedActivationPolicy(
NSApplication *application,
SEL selector,
NSApplicationActivationPolicy requestedPolicy
) {
(void)requestedPolicy;
if (detachedOriginalSetActivationPolicy == NULL) {
return NO;
}
BOOL (*originalImplementation)(id, SEL, NSApplicationActivationPolicy) =
(BOOL (*)(id, SEL, NSApplicationActivationPolicy))detachedOriginalSetActivationPolicy;
return originalImplementation(
application,
selector,
NSApplicationActivationPolicyAccessory
);
}
static void installDetachedAccessoryActivationPolicyGuard(void) {
@synchronized ([NSApplication class]) {
if (detachedAccessoryActivationPolicyGuardActive) {
return;
}
Method setter = class_getInstanceMethod(
[NSApplication class],
@selector(setActivationPolicy:)
);
if (setter == NULL) {
return;
}
detachedOriginalSetActivationPolicy = method_setImplementation(
setter,
(IMP)setDetachedGuardedActivationPolicy
);
detachedAccessoryActivationPolicyGuardActive =
detachedOriginalSetActivationPolicy != NULL;
}
}
static void prepareDetachedAccessoryActivationPolicy(void) {
installDetachedAccessoryActivationPolicyGuard();
if ([NSThread isMainThread]) {
[[NSApplication sharedApplication]
setActivationPolicy:NSApplicationActivationPolicyAccessory];
}
}
static BOOL isDetachedAccessoryActivationPolicyGuardInstalled(void) {
@synchronized ([NSApplication class]) {
return detachedAccessoryActivationPolicyGuardActive;
}
}
static void applyDetachedAccessoryActivationPolicy(void *unused) {
(void)unused;
@@ -26,6 +86,14 @@ static void setDetachedAccessoryActivationPolicy(void) {
*/
import "C"
func prepareDetachedAccessoryActivationPolicy() {
C.prepareDetachedAccessoryActivationPolicy()
}
func detachedAccessoryActivationPolicyGuardInstalled() bool {
return bool(C.isDetachedAccessoryActivationPolicyGuardInstalled())
}
func setDetachedAccessoryActivationPolicy() {
C.setDetachedAccessoryActivationPolicy()
}

View File

@@ -0,0 +1,30 @@
//go:build darwin && cgo
package nativewindow
import (
"errors"
"testing"
"github.com/wailsapp/wails/v2/pkg/options"
)
func TestRunDetachedChildApplicationInstallsAccessoryGuardBeforeWailsRun(t *testing.T) {
runnerErr := errors.New("runner stopped")
runnerCalled := false
err := runDetachedChildApplication(&options.App{}, func(*options.App) error {
runnerCalled = true
if !detachedAccessoryActivationPolicyGuardInstalled() {
t.Fatal("accessory activation-policy guard was not installed before Wails started")
}
return runnerErr
})
if !runnerCalled {
t.Fatal("Wails runner was not called")
}
if !errors.Is(err, runnerErr) {
t.Fatalf("runDetachedChildApplication() error = %v, want %v", err, runnerErr)
}
}

View File

@@ -2,4 +2,6 @@
package nativewindow
func prepareDetachedAccessoryActivationPolicy() {}
func setDetachedAccessoryActivationPolicy() {}

View File

@@ -515,6 +515,10 @@ func newControl(bridge *Bridge) *Control {
focusWindow: func(ctx context.Context) {
wailsRuntime.WindowUnminimise(ctx)
wailsRuntime.Show(ctx)
// Accessory applications do not reliably make their window key when
// only the application is activated. Explicitly order the detached
// window front after unhiding the process.
wailsRuntime.WindowShow(ctx)
},
quit: wailsRuntime.Quit,
}

View File

@@ -135,7 +135,7 @@ func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
control := newControl(bridge)
bridge.setReadyHandler(control.markFrontendReady)
minWidth, minHeight := detachedWindowMinimumSize(childOptions.Kind)
err = wails.Run(&options.App{
err = runDetachedChildApplication(&options.App{
Title: childOptions.Title,
Width: childOptions.Width,
Height: childOptions.Height,
@@ -183,10 +183,15 @@ func RunChild(parentCtx context.Context, assetFS fs.FS, args []string) error {
bridge.stop()
},
Bind: []interface{}{control, bridge},
})
}, wails.Run)
return err
}
func runDetachedChildApplication(app *options.App, runner func(*options.App) error) error {
prepareDetachedAccessoryActivationPolicy()
return runner(app)
}
func detachedWindowMinimumSize(kind string) (width int, height int) {
if strings.TrimSpace(kind) == "ai-chat" {
return 360, 420

View File

@@ -0,0 +1,128 @@
package nativewindow
import (
"encoding/json"
"sort"
"strings"
"sync"
)
type dockMenuWindow struct {
ID string `json:"id"`
Title string `json:"title"`
PID int `json:"pid,omitempty"`
}
var detachedDockMenuManager struct {
sync.RWMutex
manager *Manager
}
var detachedDockMenuPublisher struct {
sync.Mutex
revision uint64
}
func registerDetachedDockMenuManager(manager *Manager) {
if manager == nil || !supportsDetachedDockMenu() {
return
}
detachedDockMenuManager.Lock()
detachedDockMenuManager.manager = manager
detachedDockMenuManager.Unlock()
installDetachedDockMenu()
publishDetachedDockMenuSnapshot(manager)
}
func unregisterDetachedDockMenuManager(manager *Manager) {
if !supportsDetachedDockMenu() {
return
}
detachedDockMenuManager.Lock()
if detachedDockMenuManager.manager == manager {
detachedDockMenuManager.manager = nil
detachedDockMenuManager.Unlock()
publishDetachedDockMenuSnapshot(nil)
return
}
detachedDockMenuManager.Unlock()
}
func currentDetachedDockMenuManager() *Manager {
detachedDockMenuManager.RLock()
manager := detachedDockMenuManager.manager
detachedDockMenuManager.RUnlock()
return manager
}
func publishDetachedDockMenuSnapshot(manager *Manager) {
if !supportsDetachedDockMenu() {
return
}
detachedDockMenuPublisher.Lock()
defer detachedDockMenuPublisher.Unlock()
var windows []WindowInfo
if manager == nil {
if currentDetachedDockMenuManager() != nil {
return
}
} else {
if currentDetachedDockMenuManager() != manager {
return
}
manager.mu.RLock()
if manager.started && !manager.closing {
windows = make([]WindowInfo, 0, len(manager.windows))
for _, entry := range manager.windows {
windows = append(windows, entry.info)
}
}
manager.mu.RUnlock()
}
payload, err := json.Marshal(buildDockMenuSnapshot(windows))
if err != nil {
return
}
detachedDockMenuPublisher.revision++
publishDetachedDockMenuSnapshotToPlatform(payload, detachedDockMenuPublisher.revision)
}
func buildDockMenuSnapshot(windows []WindowInfo) []dockMenuWindow {
current := make([]WindowInfo, 0, len(windows))
for _, window := range windows {
window.ID = strings.TrimSpace(window.ID)
if window.ID == "" || !window.Ready || window.CloseSent {
continue
}
current = append(current, window)
}
sort.Slice(current, func(i int, j int) bool {
if current[i].OpenedAt != current[j].OpenedAt {
return current[i].OpenedAt < current[j].OpenedAt
}
return current[i].ID < current[j].ID
})
result := make([]dockMenuWindow, 0, len(current))
for _, window := range current {
title := strings.TrimSpace(window.Title)
if title == "" {
title = "GoNavi"
}
result = append(result, dockMenuWindow{
ID: window.ID,
Title: title,
PID: window.PID,
})
}
return result
}
func focusDetachedDockMenuWindow(id string) {
manager := currentDetachedDockMenuManager()
if manager == nil {
return
}
manager.Focus(strings.TrimSpace(id))
}

View File

@@ -0,0 +1,36 @@
//go:build darwin && cgo
package nativewindow
/*
#include <stdlib.h>
void gonaviInstallDetachedDockMenu(void);
void gonaviPublishDetachedDockMenuSnapshot(const char *snapshotJSON, unsigned long long revision);
*/
import "C"
import "unsafe"
func supportsDetachedDockMenu() bool {
return true
}
func installDetachedDockMenu() {
C.gonaviInstallDetachedDockMenu()
}
func publishDetachedDockMenuSnapshotToPlatform(payload []byte, revision uint64) {
snapshotJSON := C.CString(string(payload))
defer C.free(unsafe.Pointer(snapshotJSON))
C.gonaviPublishDetachedDockMenuSnapshot(snapshotJSON, C.ulonglong(revision))
}
//export gonaviFocusDetachedDockMenuWindow
func gonaviFocusDetachedDockMenuWindow(windowID *C.char) {
if windowID == nil {
return
}
id := C.GoString(windowID)
go focusDetachedDockMenuWindow(id)
}

View File

@@ -0,0 +1,219 @@
//go:build darwin && cgo
#import <Cocoa/Cocoa.h>
#import <dispatch/dispatch.h>
#import <objc/runtime.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
extern void gonaviFocusDetachedDockMenuWindow(char *windowID);
@interface GoNaviDockMenuTarget : NSObject {
BOOL focusesMainWindow;
NSString *detachedWindowID;
}
- (instancetype)initForMainWindow;
- (instancetype)initWithDetachedWindowID:(NSString *)windowID;
- (void)focusWindow:(id)sender;
@end
static NSMutableArray *gonaviDockMenuActionTargets = nil;
static NSArray *gonaviDockMenuSnapshot = nil;
static uint64_t gonaviDockMenuSnapshotRevision = 0;
typedef struct {
char *json;
uint64_t revision;
} GoNaviDockMenuSnapshotUpdate;
static NSWindow *gonaviMainWindow(void) {
id delegate = [NSApp delegate];
SEL mainWindowSelector = NSSelectorFromString(@"mainWindow");
if (delegate != nil && [delegate respondsToSelector:mainWindowSelector]) {
id candidate = [delegate performSelector:mainWindowSelector];
if ([candidate isKindOfClass:[NSWindow class]]) {
return (NSWindow *)candidate;
}
}
NSWindow *window = [NSApp mainWindow];
if (window != nil) {
return window;
}
for (NSWindow *candidate in [NSApp windows]) {
if ([NSStringFromClass([candidate class]) isEqualToString:@"WailsWindow"]) {
return candidate;
}
}
return nil;
}
@implementation GoNaviDockMenuTarget
- (instancetype)initForMainWindow {
self = [super init];
if (self != nil) {
focusesMainWindow = YES;
}
return self;
}
- (instancetype)initWithDetachedWindowID:(NSString *)windowID {
self = [super init];
if (self != nil) {
detachedWindowID = [windowID copy];
}
return self;
}
- (void)focusWindow:(id)sender {
(void)sender;
if (focusesMainWindow) {
NSWindow *window = gonaviMainWindow();
if (window == nil) {
return;
}
[NSApp unhide:nil];
if ([window isMiniaturized]) {
[window deminiaturize:nil];
}
[window makeKeyAndOrderFront:nil];
[NSApp activateIgnoringOtherApps:YES];
return;
}
const char *windowID = [detachedWindowID UTF8String];
if (windowID != NULL) {
gonaviFocusDetachedDockMenuWindow((char *)windowID);
}
}
- (void)dealloc {
[detachedWindowID release];
[super dealloc];
}
@end
static void gonaviMarkDockMenuItemForPID(NSMenuItem *item, pid_t itemPID, pid_t frontmostPID) {
if (item != nil && itemPID > 0 && itemPID == frontmostPID) {
[item setState:NSControlStateValueOn];
}
}
static NSMenu *gonaviApplicationDockMenu(id self, SEL command, NSApplication *sender) {
(void)self;
(void)command;
(void)sender;
[gonaviDockMenuActionTargets release];
gonaviDockMenuActionTargets = [[NSMutableArray alloc] init];
NSMenu *menu = [[[NSMenu alloc] initWithTitle:@"GoNavi"] autorelease];
pid_t frontmostPID = [[[NSWorkspace sharedWorkspace] frontmostApplication] processIdentifier];
NSWindow *mainWindow = gonaviMainWindow();
NSString *mainTitle = [[mainWindow title]
stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([mainTitle length] == 0) {
mainTitle = @"GoNavi";
}
GoNaviDockMenuTarget *mainTarget = [[[GoNaviDockMenuTarget alloc] initForMainWindow] autorelease];
[gonaviDockMenuActionTargets addObject:mainTarget];
NSMenuItem *mainItem = [[[NSMenuItem alloc]
initWithTitle:mainTitle
action:@selector(focusWindow:)
keyEquivalent:@""] autorelease];
[mainItem setTarget:mainTarget];
gonaviMarkDockMenuItemForPID(
mainItem,
[[NSProcessInfo processInfo] processIdentifier],
frontmostPID
);
[menu addItem:mainItem];
for (id record in gonaviDockMenuSnapshot ?: @[]) {
if (![record isKindOfClass:[NSDictionary class]]) {
continue;
}
id rawID = [(NSDictionary *)record objectForKey:@"id"];
id rawTitle = [(NSDictionary *)record objectForKey:@"title"];
if (![rawID isKindOfClass:[NSString class]] || ![rawTitle isKindOfClass:[NSString class]] ||
[(NSString *)rawID length] == 0 || [(NSString *)rawTitle length] == 0) {
continue;
}
GoNaviDockMenuTarget *target = [[[GoNaviDockMenuTarget alloc]
initWithDetachedWindowID:(NSString *)rawID] autorelease];
[gonaviDockMenuActionTargets addObject:target];
NSMenuItem *item = [[[NSMenuItem alloc]
initWithTitle:(NSString *)rawTitle
action:@selector(focusWindow:)
keyEquivalent:@""] autorelease];
[item setTarget:target];
id rawPID = [(NSDictionary *)record objectForKey:@"pid"];
if ([rawPID isKindOfClass:[NSNumber class]]) {
gonaviMarkDockMenuItemForPID(item, [(NSNumber *)rawPID intValue], frontmostPID);
}
[menu addItem:item];
}
return menu;
}
static void gonaviApplyDetachedDockMenuSnapshot(void *rawUpdate) {
GoNaviDockMenuSnapshotUpdate *update = (GoNaviDockMenuSnapshotUpdate *)rawUpdate;
if (update == NULL) {
return;
}
@autoreleasepool {
if (update->revision > gonaviDockMenuSnapshotRevision && update->json != NULL) {
NSString *json = [NSString stringWithUTF8String:update->json];
NSData *data = [json dataUsingEncoding:NSUTF8StringEncoding];
id decoded = data == nil ? nil : [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if ([decoded isKindOfClass:[NSArray class]]) {
NSArray *snapshot = [(NSArray *)decoded copy];
[gonaviDockMenuSnapshot release];
gonaviDockMenuSnapshot = snapshot;
gonaviDockMenuSnapshotRevision = update->revision;
}
}
}
free(update->json);
free(update);
}
void gonaviPublishDetachedDockMenuSnapshot(const char *snapshotJSON, unsigned long long revision) {
if (snapshotJSON == NULL) {
return;
}
GoNaviDockMenuSnapshotUpdate *update = calloc(1, sizeof(GoNaviDockMenuSnapshotUpdate));
if (update == NULL) {
return;
}
update->json = strdup(snapshotJSON);
update->revision = (uint64_t)revision;
if (update->json == NULL) {
free(update);
return;
}
if ([NSThread isMainThread]) {
gonaviApplyDetachedDockMenuSnapshot(update);
return;
}
dispatch_async_f(dispatch_get_main_queue(), update, gonaviApplyDetachedDockMenuSnapshot);
}
void gonaviInstallDetachedDockMenu(void) {
Class delegateClass = objc_getClass("AppDelegate");
if (delegateClass == Nil) {
return;
}
SEL selector = @selector(applicationDockMenu:);
if (class_getInstanceMethod(delegateClass, selector) != NULL) {
return;
}
if (!class_addMethod(
delegateClass,
selector,
(IMP)gonaviApplicationDockMenu,
"@@:@"
)) {
return;
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin || !cgo
package nativewindow
func supportsDetachedDockMenu() bool {
return false
}
func installDetachedDockMenu() {}
func publishDetachedDockMenuSnapshotToPlatform([]byte, uint64) {}

View File

@@ -0,0 +1,43 @@
package nativewindow
import (
"reflect"
"testing"
)
func TestBuildDockMenuSnapshotIncludesOnlyCurrentReadyWindows(t *testing.T) {
windows := []WindowInfo{
{ID: "closing", Title: "Closing", PID: 42, OpenedAt: 1, Ready: true, CloseSent: true},
{ID: "not-ready", Title: "Starting", PID: 43, OpenedAt: 2},
{ID: " result-2 ", Title: " Result 2 ", PID: 44, OpenedAt: 40, Ready: true},
{ID: "workbench-1", Title: "Workbench", PID: 45, OpenedAt: 20, Ready: true},
{ID: "", Title: "Missing ID", PID: 46, OpenedAt: 10, Ready: true},
{ID: "ai-chat", Title: " ", PID: 47, OpenedAt: 30, Ready: true},
}
got := buildDockMenuSnapshot(windows)
want := []dockMenuWindow{
{ID: "workbench-1", Title: "Workbench", PID: 45},
{ID: "ai-chat", Title: "GoNavi", PID: 47},
{ID: "result-2", Title: "Result 2", PID: 44},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("dock menu snapshot = %#v, want %#v", got, want)
}
}
func TestBuildDockMenuSnapshotUsesStableIDOrderForEqualOpenTimes(t *testing.T) {
windows := []WindowInfo{
{ID: "window-b", Title: "B", OpenedAt: 12, Ready: true},
{ID: "window-a", Title: "A", OpenedAt: 12, Ready: true},
}
got := buildDockMenuSnapshot(windows)
want := []dockMenuWindow{
{ID: "window-a", Title: "A"},
{ID: "window-b", Title: "B"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("dock menu snapshot = %#v, want %#v", got, want)
}
}

View File

@@ -80,6 +80,7 @@ type Manager struct {
starter processStarter
executable string
resolveBounds func(WindowBounds) WindowBounds
openTimeout time.Duration
closeFallbackDelay time.Duration
shutdownGracePeriod time.Duration
@@ -108,12 +109,13 @@ func NewManager(assetFS fs.FS, app *appcore.App, ai *aiservice.Service) (*Manage
if err != nil {
return nil, fmt.Errorf("resolve executable failed: %w", err)
}
return &Manager{
manager := &Manager{
shared: shared,
token: token,
windows: make(map[string]*windowEntry),
starter: execProcessStarter{},
executable: executable,
resolveBounds: normalizeDetachedWindowBounds,
openTimeout: defaultOpenReadyTimeout,
closeFallbackDelay: defaultGracefulCloseTimeout,
shutdownGracePeriod: defaultGracefulCloseTimeout,
@@ -123,7 +125,9 @@ func NewManager(assetFS fs.FS, app *appcore.App, ai *aiservice.Service) (*Manage
emitToChildren: shared.Emit,
emitToChild: shared.EmitTo,
emitToChildBestEffort: shared.EmitToBestEffort,
}, nil
}
installDetachedDockMenu()
return manager, nil
}
func newBridgeToken() (string, error) {
@@ -189,6 +193,7 @@ func (m *Manager) initialize(ctx context.Context) error {
}
m.httpServer = httpServer
m.mu.Unlock()
registerDetachedDockMenuManager(m)
go func() {
_ = httpServer.Serve(listener)
@@ -265,6 +270,15 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
return operationFailure("native window manager is unavailable")
}
request = normalizeOpenRequest(request)
if m.resolveBounds != nil {
bounds := m.resolveBounds(WindowBounds{
X: request.X, Y: request.Y, Width: request.Width, Height: request.Height,
})
request.X = bounds.X
request.Y = bounds.Y
request.Width = bounds.Width
request.Height = bounds.Height
}
request.ID = strings.TrimSpace(request.ID)
if request.ID == "" {
request.ID = uuid.NewString()
@@ -323,6 +337,9 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
current.process = process
current.info.PID = process.PID()
m.mu.Unlock()
// A very fast child can acknowledge readiness before Start returns. Republish
// after recording its PID so the Dock menu can identify the frontmost child.
publishDetachedDockMenuSnapshot(m)
go m.watchProcess(request.ID, process)
timeout := m.openTimeout
@@ -342,7 +359,7 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
if !active {
return operationFailure("native window exited before it became ready")
}
return OperationResult{Success: true, ID: request.ID}
return OperationResult{Success: true, ID: request.ID, Bounds: windowBoundsFromRequest(request)}
case exit := <-entry.done:
message := "native window exited before it became ready"
if exit.err != nil {
@@ -355,7 +372,7 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult {
if active && current == entry && entry.info.Ready {
entry.acknowledged = true
m.mu.Unlock()
return OperationResult{Success: true, ID: request.ID}
return OperationResult{Success: true, ID: request.ID, Bounds: windowBoundsFromRequest(request)}
}
registered := active && current == entry
if registered {
@@ -385,7 +402,11 @@ func (m *Manager) Focus(id string) OperationResult {
}
id = strings.TrimSpace(id)
m.mu.RLock()
_, exists := m.windows[id]
entry, exists := m.windows[id]
var bounds *WindowBounds
if exists {
bounds = windowBoundsFromInfo(entry.info)
}
emitToChild := m.emitToChild
shared := m.shared
m.mu.RUnlock()
@@ -398,7 +419,15 @@ func (m *Manager) Focus(id string) OperationResult {
} else if shared != nil {
shared.EmitTo(id, CommandEventName, command)
}
return OperationResult{Success: true, ID: id}
return OperationResult{Success: true, ID: id, Bounds: bounds}
}
func windowBoundsFromRequest(request OpenRequest) *WindowBounds {
return &WindowBounds{X: request.X, Y: request.Y, Width: request.Width, Height: request.Height}
}
func windowBoundsFromInfo(info WindowInfo) *WindowBounds {
return &WindowBounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}
}
// Close requests a graceful child shutdown and force-kills it if the WebView is
@@ -436,6 +465,7 @@ func (m *Manager) requestClose(id string, reason string) OperationResult {
delay = defaultGracefulCloseTimeout
}
m.mu.Unlock()
publishDetachedDockMenuSnapshot(m)
// attach/close actions are emitted before their HTTP response is written.
// Do not race that response with a second close command: the child will quit
@@ -479,6 +509,7 @@ func (m *Manager) CancelClose(id string) OperationResult {
}
m.cancelCloseLocked(entry)
m.mu.Unlock()
publishDetachedDockMenuSnapshot(m)
return OperationResult{Success: true, ID: id}
}
@@ -649,6 +680,7 @@ func (m *Manager) watchProcess(id string, process childProcess) {
}
})
m.mu.Unlock()
publishDetachedDockMenuSnapshot(m)
if !acknowledged {
return
}
@@ -725,6 +757,7 @@ func validateOpenRequest(request OpenRequest) error {
}
func (m *Manager) shutdown() {
unregisterDetachedDockMenuManager(m)
m.mu.Lock()
if m.closing {
m.mu.Unlock()
@@ -983,6 +1016,9 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) {
info := entry.info
ownerID := entry.ownerID
m.mu.Unlock()
if request.Action == "ready" || request.Action == "cancel-close" {
publishDetachedDockMenuSnapshot(m)
}
if request.Action != "ready" {
m.emitDetached(Event{
@@ -1058,7 +1094,7 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
Kind: normalizeOpenRequest(request.Request).Kind,
Action: "opened",
Payload: withOwnerWindowID(
openEventPayload(request.Request.Payload),
openEventPayload(request.Request.Payload, result.Bounds),
ownerID,
),
})
@@ -1091,7 +1127,7 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(result)
}
func openEventPayload(payload any) any {
func openEventPayload(payload any, bounds *WindowBounds) any {
source, ok := payload.(map[string]any)
if !ok {
return nil
@@ -1099,6 +1135,19 @@ func openEventPayload(payload any) any {
result := make(map[string]any, 2)
for _, key := range []string{"tab", "resultWindow"} {
if value, exists := source[key]; exists {
if key == "resultWindow" && bounds != nil {
if resultWindow, ok := value.(map[string]any); ok {
corrected := make(map[string]any, len(resultWindow)+4)
for field, fieldValue := range resultWindow {
corrected[field] = fieldValue
}
corrected["x"] = bounds.X
corrected["y"] = bounds.Y
corrected["width"] = bounds.Width
corrected["height"] = bounds.Height
value = corrected
}
}
result[key] = value
}
}

View File

@@ -48,10 +48,11 @@ type fakeChildProcess struct {
}
type readyBeforeReturnStarter struct {
manager *Manager
mu sync.Mutex
nextPID int
started []*fakeChildProcess
manager *Manager
mu sync.Mutex
nextPID int
started []*fakeChildProcess
skipReadySignal bool
}
func (s *readyBeforeReturnStarter) Start(spec processSpec) (childProcess, error) {
@@ -70,7 +71,9 @@ func (s *readyBeforeReturnStarter) Start(spec processSpec) (childProcess, error)
entry := s.manager.windows[id]
if entry != nil {
entry.info.Ready = true
entry.readyOnce.Do(func() { close(entry.ready) })
if !s.skipReadySignal {
entry.readyOnce.Do(func() { close(entry.ready) })
}
}
s.manager.mu.Unlock()
return process, nil
@@ -192,9 +195,27 @@ func TestManagerOpenGeneratesUniqueIDsAndRegistersMultipleWindows(t *testing.T)
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 {
windows := manager.List()
if len(windows) != 2 {
t.Fatalf("registry size = %d, want 2", len(windows))
}
firstBounds := WindowBounds{X: -1920, Y: 40, Width: 1100, Height: 760}
if first.Bounds == nil || *first.Bounds != firstBounds {
t.Fatalf("first Open bounds = %#v, want %#v", first.Bounds, firstBounds)
}
var firstInfo *WindowInfo
for index := range windows {
if windows[index].ID == first.ID {
firstInfo = &windows[index]
break
}
}
if firstInfo == nil {
t.Fatalf("first window %q is missing from registry: %#v", first.ID, windows)
}
if got := *windowBoundsFromInfo(*firstInfo); got != firstBounds {
t.Fatalf("first registry bounds = %#v, want %#v", got, firstBounds)
}
starter.mu.Lock()
firstSpec := starter.specs[0]
@@ -212,16 +233,76 @@ func TestManagerOpenGeneratesUniqueIDsAndRegistersMultipleWindows(t *testing.T)
waitForRegistrySize(t, manager, 0)
}
func TestManagerOpenAcceptsReadyAtTimeoutBoundary(t *testing.T) {
func TestManagerOpenUsesResolvedBoundsForChildRegistryAndResponse(t *testing.T) {
starter := &fakeProcessStarter{nextPID: 300}
corrected := WindowBounds{X: 563, Y: 182, Width: 921, Height: 812}
manager := &Manager{
token: "test-token",
endpoint: "http://127.0.0.1:43119",
started: true,
windows: make(map[string]*windowEntry),
executable: "/tmp/GoNavi",
openTimeout: time.Nanosecond,
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,
resolveBounds: func(WindowBounds) WindowBounds { return corrected },
}
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()
}
result := manager.Open(OpenRequest{
ID: "ai-chat", Kind: "ai-chat", X: 0, Y: 1152, Width: 921, Height: 812,
})
if !result.Success || result.Bounds == nil || *result.Bounds != corrected {
t.Fatalf("Open result = %#v, want corrected bounds %#v", result, corrected)
}
starter.mu.Lock()
spec := starter.specs[0]
process := starter.processes[0]
starter.mu.Unlock()
if value := environmentValue(spec.Env, envX); value != "563" {
t.Fatalf("child x environment = %q, want 563", value)
}
if value := environmentValue(spec.Env, envY); value != "182" {
t.Fatalf("child y environment = %q, want 182", value)
}
if value := environmentValue(spec.Env, envWidth); value != "921" {
t.Fatalf("child width environment = %q, want 921", value)
}
if value := environmentValue(spec.Env, envHeight); value != "812" {
t.Fatalf("child height environment = %q, want 812", value)
}
windows := manager.List()
if len(windows) != 1 || windowBoundsFromInfo(windows[0]) == nil || *windowBoundsFromInfo(windows[0]) != corrected {
t.Fatalf("registered windows = %#v, want corrected bounds %#v", windows, corrected)
}
process.finish(nil)
waitForRegistrySize(t, manager, 0)
}
func TestManagerOpenAcceptsReadyAtTimeoutBoundary(t *testing.T) {
corrected := WindowBounds{X: -1520, Y: 80, Width: 920, Height: 700}
manager := &Manager{
token: "test-token",
endpoint: "http://127.0.0.1:43119",
started: true,
windows: make(map[string]*windowEntry),
executable: "/tmp/GoNavi",
openTimeout: time.Nanosecond,
resolveBounds: func(WindowBounds) WindowBounds { return corrected },
}
starter := &readyBeforeReturnStarter{
manager: manager, nextPID: 400, skipReadySignal: true,
}
starter := &readyBeforeReturnStarter{manager: manager, nextPID: 400}
manager.starter = starter
for attempt := 0; attempt < 64; attempt++ {
@@ -230,6 +311,9 @@ func TestManagerOpenAcceptsReadyAtTimeoutBoundary(t *testing.T) {
if !result.Success {
t.Fatalf("Open attempt %d rejected an already-ready child: %#v", attempt, result)
}
if result.Bounds == nil || *result.Bounds != corrected {
t.Fatalf("Open attempt %d bounds = %#v, want %#v", attempt, result.Bounds, corrected)
}
starter.mu.Lock()
process := starter.started[len(starter.started)-1]
@@ -528,6 +612,14 @@ func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
manager.endpoint = "http://127.0.0.1:43119"
manager.executable = "/tmp/GoNavi"
manager.openTimeout = time.Second
requestedBounds := WindowBounds{X: 2100, Y: -120, Width: 900, Height: 620}
correctedBounds := WindowBounds{X: 96, Y: 80, Width: 800, Height: 500}
manager.resolveBounds = func(bounds WindowBounds) WindowBounds {
if bounds != requestedBounds {
t.Fatalf("bounds resolver input = %#v, want %#v", bounds, requestedBounds)
}
return correctedBounds
}
manager.windows["workbench:query-a"] = &windowEntry{
info: WindowInfo{ID: "workbench:query-a", Kind: "workbench", Title: "SQL"},
}
@@ -565,7 +657,14 @@ func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
"height":620,
"payload":{
"storeState":{},
"resultWindow":{"id":"query-result:query-a:r1","sourceQueryTabId":"query-a"}
"resultWindow":{
"id":"query-result:query-a:r1",
"sourceQueryTabId":"query-a",
"x":2100,
"y":-120,
"width":900,
"height":620
}
}
}
}`)
@@ -590,6 +689,19 @@ func TestChildControlOpensAndRoutesAnOwnedNativeWindow(t *testing.T) {
if !ok || payload["ownerWindowId"] != "workbench:query-a" {
t.Fatalf("opened event owner metadata = %#v", opened.Payload)
}
resultWindow, ok := payload["resultWindow"].(map[string]any)
if !ok {
t.Fatalf("opened event result window = %#v, want structured payload", payload["resultWindow"])
}
if resultWindow["sourceQueryTabId"] != "query-a" {
t.Fatalf("opened event result window lost source metadata: %#v", resultWindow)
}
if resultWindow["x"] != correctedBounds.X ||
resultWindow["y"] != correctedBounds.Y ||
resultWindow["width"] != correctedBounds.Width ||
resultWindow["height"] != correctedBounds.Height {
t.Fatalf("opened event result bounds = %#v, want %#v", resultWindow, correctedBounds)
}
manager.windows["foreign-window"] = &windowEntry{
info: WindowInfo{ID: "foreign-window", Kind: "query-result"},

View File

@@ -11,14 +11,14 @@ const (
HeaderToken = "X-GoNavi-Detached-Token"
HeaderWindowID = "X-GoNavi-Detached-Window-ID"
BootstrapPath = "/__gonavi/detached/bootstrap"
ActionPath = "/__gonavi/detached/action"
ControlPath = "/__gonavi/detached/control"
HostStatePath = "/__gonavi/detached/host-state"
BootstrapPath = "/__gonavi/detached/bootstrap"
ActionPath = "/__gonavi/detached/action"
ControlPath = "/__gonavi/detached/control"
HostStatePath = "/__gonavi/detached/host-state"
CommandStatePath = "/__gonavi/detached/command-state"
RuntimePath = "/__gonavi/detached-runtime.js"
InvokePath = "/__gonavi/api/invoke"
EventsPath = "/__gonavi/events"
RuntimePath = "/__gonavi/detached-runtime.js"
InvokePath = "/__gonavi/api/invoke"
EventsPath = "/__gonavi/events"
MainEventName = "gonavi:native-detached-event"
CommandEventName = "gonavi:native-detached-command"
@@ -38,8 +38,8 @@ const (
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
maxDetachedSSEEventBytes = maxDetachedJSONBytes + (1 << 20)
maxDetachedJSONBytes int64 = 512 << 20
maxDetachedSSEEventBytes = maxDetachedJSONBytes + (1 << 20)
)
// OpenRequest describes one independently movable native window. X and Y use
@@ -55,6 +55,16 @@ type OpenRequest struct {
Height int `json:"height"`
}
// WindowBounds uses the browser/Wails virtual desktop coordinate system with
// the primary display's top-left as the origin. Negative coordinates remain
// valid for displays arranged to the left or above the primary display.
type WindowBounds struct {
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"`
@@ -81,9 +91,10 @@ type Bootstrap struct {
// 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"`
Success bool `json:"success"`
Message string `json:"message,omitempty"`
ID string `json:"id,omitempty"`
Bounds *WindowBounds `json:"bounds,omitempty"`
}
// HostStateRequest carries main-window state that an active detached child

View File

@@ -8,6 +8,7 @@ package nativewindow
#import <Cocoa/Cocoa.h>
#import <dispatch/dispatch.h>
#include <math.h>
typedef struct {
int x;
@@ -16,6 +17,51 @@ typedef struct {
int height;
} DetachedWindowBounds;
typedef struct {
DetachedWindowBounds *items;
int capacity;
int count;
} DetachedScreenBoundsRequest;
static void copyDetachedScreenBounds(void *rawRequest) {
DetachedScreenBoundsRequest *request = (DetachedScreenBoundsRequest *)rawRequest;
NSArray<NSScreen *> *screens = [NSScreen screens];
if ([screens count] == 0 || request->capacity <= 0 || request->items == NULL) {
request->count = 0;
return;
}
NSRect primaryFrame = [[screens objectAtIndex:0] frame];
int count = MIN((int)[screens count], request->capacity);
for (int index = 0; index < count; index++) {
NSRect visible = [[screens objectAtIndex:index] visibleFrame];
request->items[index] = (DetachedWindowBounds) {
.x = (int)llround(NSMinX(visible)),
.y = (int)llround(NSMaxY(primaryFrame) - NSMaxY(visible)),
.width = (int)llround(NSWidth(visible)),
.height = (int)llround(NSHeight(visible)),
};
}
request->count = count;
}
static int getDetachedScreenBounds(DetachedWindowBounds *items, int capacity) {
DetachedScreenBoundsRequest request = { items, capacity, 0 };
if ([NSThread isMainThread]) {
copyDetachedScreenBounds(&request);
return request.count;
}
// A Go test binary has no AppKit run loop to drain the main dispatch queue.
// Returning no displays preserves the requested bounds and avoids blocking
// forever; Wails calls this only after NSApplication is running.
if (NSApp == nil || ![NSApp isRunning]) {
return 0;
}
dispatch_sync_f(dispatch_get_main_queue(), &request, copyDetachedScreenBounds);
return request.count;
}
static NSWindow *resolveDetachedWindow(void) {
NSApplication *application = [NSApplication sharedApplication];
NSWindow *window = [application mainWindow];
@@ -62,5 +108,33 @@ 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))
bounds := normalizeDetachedWindowBounds(WindowBounds{
X: x, Y: y, Width: width, Height: height,
})
C.setDetachedWindowBounds(
C.int(bounds.X),
C.int(bounds.Y),
C.int(bounds.Width),
C.int(bounds.Height),
)
}
func activeDetachedDisplayBounds() []WindowBounds {
const maximumDisplays = 32
rawBounds := make([]C.DetachedWindowBounds, maximumDisplays)
count := int(C.getDetachedScreenBounds(&rawBounds[0], C.int(len(rawBounds))))
if count <= 0 {
return nil
}
result := make([]WindowBounds, 0, count)
for index := 0; index < count; index++ {
raw := rawBounds[index]
result = append(result, WindowBounds{
X: int(raw.x),
Y: int(raw.y),
Width: int(raw.width),
Height: int(raw.height),
})
}
return result
}

View File

@@ -0,0 +1,22 @@
//go:build darwin && cgo
package nativewindow
import (
"testing"
"time"
)
func TestActiveDetachedDisplayBoundsReturnsWithoutAppKitRunLoop(t *testing.T) {
done := make(chan struct{})
go func() {
_ = activeDetachedDisplayBounds()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("screen bounds query blocked without an AppKit run loop")
}
}

View File

@@ -12,3 +12,7 @@ func applyDetachedWindowBounds(ctx context.Context, x, y, width, height int) {
wailsRuntime.WindowSetSize(ctx, width, height)
wailsRuntime.WindowSetPosition(ctx, x, y)
}
func activeDetachedDisplayBounds() []WindowBounds {
return nil
}

View File

@@ -0,0 +1,126 @@
package nativewindow
const (
detachedWindowDragRegionHeight = 36
detachedWindowMinVisibleWidth = 96
detachedWindowMinVisibleHeight = 24
)
func normalizeDetachedWindowBounds(bounds WindowBounds) WindowBounds {
return normalizeDetachedWindowBoundsForDisplays(bounds, activeDetachedDisplayBounds())
}
func normalizeDetachedWindowBoundsForDisplays(
bounds WindowBounds,
displays []WindowBounds,
) WindowBounds {
validDisplays := make([]WindowBounds, 0, len(displays))
for _, display := range displays {
if display.Width > 0 && display.Height > 0 {
validDisplays = append(validDisplays, display)
}
}
if len(validDisplays) == 0 || bounds.Width <= 0 || bounds.Height <= 0 {
return bounds
}
dragRegion := bounds
if dragRegion.Height > detachedWindowDragRegionHeight {
dragRegion.Height = detachedWindowDragRegionHeight
}
minVisibleWidth := minInt(detachedWindowMinVisibleWidth, dragRegion.Width)
minVisibleHeight := minInt(detachedWindowMinVisibleHeight, dragRegion.Height)
dragRegionVisible := false
targetIndex := 0
maxIntersectionArea := int64(-1)
for index, display := range validDisplays {
intersection := intersectWindowBounds(dragRegion, display)
if intersection.Width >= minVisibleWidth && intersection.Height >= minVisibleHeight {
dragRegionVisible = true
}
windowIntersection := intersectWindowBounds(bounds, display)
area := int64(windowIntersection.Width) * int64(windowIntersection.Height)
if area > maxIntersectionArea {
maxIntersectionArea = area
targetIndex = index
}
}
target := validDisplays[targetIndex]
if dragRegionVisible && bounds.Width <= target.Width && bounds.Height <= target.Height {
return bounds
}
if maxIntersectionArea == 0 {
closestDistance := windowBoundsDistanceSquared(bounds, validDisplays[0])
for index := 1; index < len(validDisplays); index++ {
distance := windowBoundsDistanceSquared(bounds, validDisplays[index])
if distance < closestDistance {
closestDistance = distance
targetIndex = index
}
}
}
target = validDisplays[targetIndex]
next := bounds
next.Width = minInt(next.Width, target.Width)
next.Height = minInt(next.Height, target.Height)
if maxIntersectionArea == 0 {
next.X = target.X + (target.Width-next.Width)/2
next.Y = target.Y + (target.Height-next.Height)/2
return next
}
next.X = clampInt(next.X, target.X, target.X+target.Width-next.Width)
next.Y = clampInt(next.Y, target.Y, target.Y+target.Height-next.Height)
return next
}
func intersectWindowBounds(left, right WindowBounds) WindowBounds {
x1 := maxInt(left.X, right.X)
y1 := maxInt(left.Y, right.Y)
x2 := minInt(left.X+left.Width, right.X+right.Width)
y2 := minInt(left.Y+left.Height, right.Y+right.Height)
if x2 <= x1 || y2 <= y1 {
return WindowBounds{}
}
return WindowBounds{X: x1, Y: y1, Width: x2 - x1, Height: y2 - y1}
}
func windowBoundsDistanceSquared(window, display WindowBounds) int64 {
dx := 0
if window.X+window.Width < display.X {
dx = display.X - (window.X + window.Width)
} else if display.X+display.Width < window.X {
dx = window.X - (display.X + display.Width)
}
dy := 0
if window.Y+window.Height < display.Y {
dy = display.Y - (window.Y + window.Height)
} else if display.Y+display.Height < window.Y {
dy = window.Y - (display.Y + display.Height)
}
return int64(dx)*int64(dx) + int64(dy)*int64(dy)
}
func clampInt(value, minimum, maximum int) int {
if value < minimum {
return minimum
}
if value > maximum {
return maximum
}
return value
}
func minInt(left, right int) int {
if left < right {
return left
}
return right
}
func maxInt(left, right int) int {
if left > right {
return left
}
return right
}

View File

@@ -0,0 +1,69 @@
package nativewindow
import "testing"
func TestNormalizeDetachedWindowBoundsForDisplaysRecentersOffscreenWindow(t *testing.T) {
displays := []WindowBounds{{X: 0, Y: 24, Width: 2048, Height: 1128}}
got := normalizeDetachedWindowBoundsForDisplays(
WindowBounds{X: 0, Y: 1152, Width: 921, Height: 812},
displays,
)
want := WindowBounds{X: 563, Y: 182, Width: 921, Height: 812}
if got != want {
t.Fatalf("normalized bounds = %#v, want %#v", got, want)
}
}
func TestNormalizeDetachedWindowBoundsForDisplaysPreservesVisibleSecondaryDisplay(t *testing.T) {
displays := []WindowBounds{
{X: 0, Y: 24, Width: 2048, Height: 1128},
{X: -1920, Y: 0, Width: 1920, Height: 1080},
}
want := WindowBounds{X: -1600, Y: 120, Width: 921, Height: 812}
if got := normalizeDetachedWindowBoundsForDisplays(want, displays); got != want {
t.Fatalf("normalized bounds = %#v, want unchanged %#v", got, want)
}
}
func TestNormalizeDetachedWindowBoundsForDisplaysPreservesDisplayAbovePrimary(t *testing.T) {
displays := []WindowBounds{
{X: 0, Y: 24, Width: 2048, Height: 1128},
{X: 224, Y: -900, Width: 1600, Height: 900},
}
want := WindowBounds{X: 420, Y: -820, Width: 921, Height: 812}
if got := normalizeDetachedWindowBoundsForDisplays(want, displays); got != want {
t.Fatalf("normalized bounds = %#v, want unchanged %#v", got, want)
}
}
func TestNormalizeDetachedWindowBoundsForDisplaysFitsOversizedWindow(t *testing.T) {
displays := []WindowBounds{{X: 0, Y: 24, Width: 1280, Height: 696}}
got := normalizeDetachedWindowBoundsForDisplays(
WindowBounds{X: 2200, Y: 200, Width: 1600, Height: 900},
displays,
)
want := WindowBounds{X: 0, Y: 24, Width: 1280, Height: 696}
if got != want {
t.Fatalf("normalized bounds = %#v, want %#v", got, want)
}
}
func TestNormalizeDetachedWindowBoundsForDisplaysFitsVisibleOversizedWindow(t *testing.T) {
displays := []WindowBounds{{X: 0, Y: 24, Width: 1280, Height: 696}}
got := normalizeDetachedWindowBoundsForDisplays(
WindowBounds{X: 0, Y: 24, Width: 1600, Height: 900},
displays,
)
want := WindowBounds{X: 0, Y: 24, Width: 1280, Height: 696}
if got != want {
t.Fatalf("normalized bounds = %#v, want %#v", got, want)
}
}