mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
✨ feat(native-window): 支持 macOS Dock 管理独立窗口
- 在主程序 Dock 菜单动态列出并聚焦已就绪的独立窗口 - 在 Wails 启动前固定子进程为 Accessory,消除临时 Dock 图标闪现 - 按多显示器可见区域修正越界窗口并回写实际坐标 - 将自定义 AI 面板快捷键同步到独立窗口并转交主窗口处理 - 补齐 Go、Objective-C、前端及 Wails 绑定回归测试
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
30
internal/nativewindow/activation_policy_darwin_test.go
Normal file
30
internal/nativewindow/activation_policy_darwin_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,6 @@
|
||||
|
||||
package nativewindow
|
||||
|
||||
func prepareDetachedAccessoryActivationPolicy() {}
|
||||
|
||||
func setDetachedAccessoryActivationPolicy() {}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
128
internal/nativewindow/dock_menu.go
Normal file
128
internal/nativewindow/dock_menu.go
Normal 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))
|
||||
}
|
||||
36
internal/nativewindow/dock_menu_darwin.go
Normal file
36
internal/nativewindow/dock_menu_darwin.go
Normal 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)
|
||||
}
|
||||
219
internal/nativewindow/dock_menu_darwin.m
Normal file
219
internal/nativewindow/dock_menu_darwin.m
Normal 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;
|
||||
}
|
||||
}
|
||||
11
internal/nativewindow/dock_menu_other.go
Normal file
11
internal/nativewindow/dock_menu_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin || !cgo
|
||||
|
||||
package nativewindow
|
||||
|
||||
func supportsDetachedDockMenu() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func installDetachedDockMenu() {}
|
||||
|
||||
func publishDetachedDockMenuSnapshotToPlatform([]byte, uint64) {}
|
||||
43
internal/nativewindow/dock_menu_test.go
Normal file
43
internal/nativewindow/dock_menu_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
22
internal/nativewindow/window_bounds_darwin_test.go
Normal file
22
internal/nativewindow/window_bounds_darwin_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
126
internal/nativewindow/window_visibility.go
Normal file
126
internal/nativewindow/window_visibility.go
Normal 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
|
||||
}
|
||||
69
internal/nativewindow/window_visibility_test.go
Normal file
69
internal/nativewindow/window_visibility_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user