diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3c6825da..30149678 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,7 @@ import { Layout, Button, ConfigProvider, theme, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select, Segmented, Tooltip } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined, RobotOutlined } from '@ant-design/icons'; -import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime'; +import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetPosition, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowIsMinimised, WindowIsNormal, WindowMaximise, WindowMinimise, WindowSetPosition, WindowSetSize, WindowToggleMaximise, WindowUnfullscreen } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; import ConnectionModal from './components/ConnectionModal'; @@ -130,6 +130,9 @@ function App() { const toggleAIPanel = useStore(state => state.toggleAIPanel); const setAIPanelVisible = useStore(state => state.setAIPanelVisible); const globalProxyInvalidHintShownRef = React.useRef(false); + const windowDiagSequenceRef = React.useRef(0); + const windowDiagLastSignatureRef = React.useRef(''); + const windowDiagLastAtRef = React.useRef(0); const connectionWorkbenchState = getConnectionWorkbenchState(isStoreHydrated, hasLoadedSecureConfig); // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, @@ -468,6 +471,10 @@ function App() { const store = useStore.getState(); const newState = isFs ? 'fullscreen' : (isMax ? 'maximized' : 'normal'); if (store.windowState !== newState) { + void emitWindowDiagnostic('transition:windowState', { + from: store.windowState, + to: newState, + }); store.setWindowState(newState); } @@ -483,15 +490,18 @@ function App() { const h = Math.trunc(Number(size.h || 0)); const x = Math.trunc(Number(pos.x || 0)); const y = Math.trunc(Number(pos.y || 0)); - if (w < 400 || h < 300) return; + if (w < 400 || h < 300) return; - const key = `${w},${h},${x},${y}`; - if (key === lastSaved) return; - lastSaved = key; - store.setWindowBounds({ width: w, height: h, x, y }); - } catch (e) { - // 静默忽略 - } + const key = `${w},${h},${x},${y}`; + if (key === lastSaved) return; + lastSaved = key; + if (Math.abs(x) > 5000 || Math.abs(y) > 5000) { + void emitWindowDiagnostic('anomaly:windowBounds', { width: w, height: h, x, y }); + } + store.setWindowBounds({ width: w, height: h, x, y }); + } catch (e) { + // 静默忽略 + } }; const timer = window.setInterval(saveWindowState, SAVE_INTERVAL_MS); @@ -841,6 +851,63 @@ function App() { || (runtimePlatform === '' && isWindowsPlatform()); const useNativeMacWindowControls = isMacRuntime && appearance.useNativeMacWindowControls === true; + const emitWindowDiagnostic = useCallback(async (stage: string, extra: Record = {}) => { + if (!isMacRuntime) { + return; + } + const backendApp = (window as any).go?.app?.App; + if (typeof backendApp?.LogWindowDiagnostic !== 'function') { + return; + } + try { + const [isFullscreen, isMaximised, isMinimised, isNormal, size, position] = await Promise.all([ + WindowIsFullscreen().catch(() => false), + WindowIsMaximised().catch(() => false), + WindowIsMinimised().catch(() => false), + WindowIsNormal().catch(() => false), + WindowGetSize().catch(() => null), + WindowGetPosition().catch(() => null), + ]); + const payload = { + seq: ++windowDiagSequenceRef.current, + ts: new Date().toISOString(), + stage, + nativeControls: useNativeMacWindowControls, + documentVisible: document.visibilityState, + documentHasFocus: document.hasFocus(), + devicePixelRatio: Number(window.devicePixelRatio) || 1, + windowState: { + isFullscreen, + isMaximised, + isMinimised, + isNormal, + }, + size: size ? { w: Math.trunc(Number(size.w || 0)), h: Math.trunc(Number(size.h || 0)) } : null, + position: position ? { x: Math.trunc(Number(position.x || 0)), y: Math.trunc(Number(position.y || 0)) } : null, + extra, + }; + const signature = JSON.stringify({ + stage, + nativeControls: payload.nativeControls, + visible: payload.documentVisible, + focus: payload.documentHasFocus, + state: payload.windowState, + size: payload.size, + position: payload.position, + extra, + }); + const now = Date.now(); + if (signature === windowDiagLastSignatureRef.current && now-windowDiagLastAtRef.current < 250) { + return; + } + windowDiagLastSignatureRef.current = signature; + windowDiagLastAtRef.current = now; + await backendApp.LogWindowDiagnostic(stage, JSON.stringify(payload)); + } catch (error) { + console.warn('Failed to emit window diagnostic', error); + } + }, [isMacRuntime, useNativeMacWindowControls]); + useEffect(() => { if (!isStoreHydrated || !isMacRuntime) { return; @@ -853,6 +920,104 @@ function App() { } }, [isMacRuntime, isStoreHydrated, useNativeMacWindowControls]); + useEffect(() => { + if (!isMacRuntime) { + return; + } + + let cancelled = false; + let pollTimer: number | null = null; + let burstTimer: number | null = null; + + const stopBurst = () => { + if (pollTimer !== null) { + window.clearInterval(pollTimer); + pollTimer = null; + } + if (burstTimer !== null) { + window.clearTimeout(burstTimer); + burstTimer = null; + } + }; + + const startBurst = (reason: string, extra: Record = {}) => { + if (cancelled) { + return; + } + void emitWindowDiagnostic(`burst:start:${reason}`, extra); + if (pollTimer === null) { + pollTimer = window.setInterval(() => { + void emitWindowDiagnostic(`burst:tick:${reason}`); + }, 250); + } + if (burstTimer !== null) { + window.clearTimeout(burstTimer); + } + burstTimer = window.setTimeout(() => { + stopBurst(); + void emitWindowDiagnostic(`burst:stop:${reason}`); + }, 6000); + }; + + const handleFocus = () => { + void emitWindowDiagnostic('event:focus'); + }; + const handleBlur = () => { + void emitWindowDiagnostic('event:blur'); + }; + const handleResize = () => { + void emitWindowDiagnostic('event:resize'); + }; + const handleVisibilityChange = () => { + void emitWindowDiagnostic('event:visibilitychange', { visibility: document.visibilityState }); + }; + const handleEditableKeydown = (event: KeyboardEvent) => { + if (!isEditableElement(event.target)) { + return; + } + const key = String(event.key || ''); + const maybeFullscreenKey = key === 'Escape' || key.toLowerCase() === 'f' || key === 'Process'; + const hasModifier = event.ctrlKey || event.metaKey || event.altKey; + startBurst('editable-keydown', { + key, + code: String(event.code || ''), + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + shiftKey: event.shiftKey, + maybeFullscreenKey, + hasModifier, + }); + }; + const handleCompositionStart = () => { + startBurst('compositionstart'); + }; + const handleCompositionEnd = () => { + startBurst('compositionend'); + }; + + void emitWindowDiagnostic('session:start'); + window.addEventListener('focus', handleFocus); + window.addEventListener('blur', handleBlur); + window.addEventListener('resize', handleResize); + window.addEventListener('keydown', handleEditableKeydown, true); + window.addEventListener('compositionstart', handleCompositionStart, true); + window.addEventListener('compositionend', handleCompositionEnd, true); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + cancelled = true; + stopBurst(); + window.removeEventListener('focus', handleFocus); + window.removeEventListener('blur', handleBlur); + window.removeEventListener('resize', handleResize); + window.removeEventListener('keydown', handleEditableKeydown, true); + window.removeEventListener('compositionstart', handleCompositionStart, true); + window.removeEventListener('compositionend', handleCompositionEnd, true); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [emitWindowDiagnostic, isMacRuntime]); + const formatBytes = (bytes?: number) => { if (!bytes || bytes <= 0) return '0 B'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; @@ -1360,15 +1525,19 @@ function App() { const handleTitleBarWindowToggle = async () => { try { + void emitWindowDiagnostic('action:titlebar-toggle:before'); if (await WindowIsFullscreen()) { await WindowUnfullscreen(); + void emitWindowDiagnostic('action:titlebar-toggle:after-unfullscreen'); return; } if (useNativeMacWindowControls && isMacRuntime) { await WindowFullscreen(); + void emitWindowDiagnostic('action:titlebar-toggle:after-fullscreen'); return; } await WindowToggleMaximise(); + void emitWindowDiagnostic('action:titlebar-toggle:after-toggle-maximise'); } catch (_) { // ignore } diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index f94ace7c..5c8201e9 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -110,6 +110,8 @@ export function InstallLocalDriverPackage(arg1:string,arg2:string,arg3:string,ar export function InstallUpdateAndRestart():Promise; +export function LogWindowDiagnostic(arg1:string,arg2:string):Promise; + export function MongoDiscoverMembers(arg1:connection.ConnectionConfig):Promise; export function MySQLConnect(arg1:connection.ConnectionConfig):Promise; diff --git a/frontend/wailsjs/go/app/App.js b/frontend/wailsjs/go/app/App.js index d2e2c505..6ba6f1fe 100755 --- a/frontend/wailsjs/go/app/App.js +++ b/frontend/wailsjs/go/app/App.js @@ -214,6 +214,10 @@ export function InstallUpdateAndRestart() { return window['go']['app']['App']['InstallUpdateAndRestart'](); } +export function LogWindowDiagnostic(arg1, arg2) { + return window['go']['app']['App']['LogWindowDiagnostic'](arg1, arg2); +} + export function MongoDiscoverMembers(arg1) { return window['go']['app']['App']['MongoDiscoverMembers'](arg1); } diff --git a/internal/app/app.go b/internal/app/app.go index 3f17b865..6e5fbf19 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -91,6 +91,7 @@ func (a *App) startup(ctx context.Context) { } logger.Init() a.loadPersistedGlobalProxy() + installMacNativeWindowDiagnostics(logger.Path()) applyMacWindowTranslucencyFix() logger.Infof("应用启动完成(首次连接保护窗口=%s,最多重试=%d 次)", startupConnectRetryWindow, startupConnectRetryAttempts) } @@ -108,6 +109,16 @@ func (a *App) SetMacNativeWindowControls(enabled bool) { setMacNativeWindowControls(enabled) } +// LogWindowDiagnostic 记录前端采集到的窗口诊断信息,便于排查 macOS 原生全屏异常。 +func (a *App) LogWindowDiagnostic(stage string, payload string) { + stage = strings.TrimSpace(stage) + payload = strings.TrimSpace(payload) + if stage == "" { + stage = "unknown" + } + logger.Warnf("窗口诊断:stage=%s payload=%s", stage, payload) +} + // Shutdown is called when the app terminates func (a *App) Shutdown(ctx context.Context) { logger.Infof("应用开始关闭,准备释放资源") diff --git a/internal/app/window_style_darwin.go b/internal/app/window_style_darwin.go index 41b2651c..78f95744 100644 --- a/internal/app/window_style_darwin.go +++ b/internal/app/window_style_darwin.go @@ -5,12 +5,143 @@ package app /* #cgo CFLAGS: -x objective-c -fblocks #cgo LDFLAGS: -framework Cocoa +#include #import #import static inline BOOL gonaviBoolYES() { return YES; } static inline BOOL gonaviBoolNO() { return NO; } +static char *gonaviNativeLogPath = NULL; +static BOOL gonaviNativeObserverInstalled = NO; + +static void gonaviWriteNativeWindowLogLine(NSString *line) { + if (line == nil || gonaviNativeLogPath == NULL) { + return; + } + NSFileHandle *handle = [NSFileHandle fileHandleForWritingAtPath:[NSString stringWithUTF8String:gonaviNativeLogPath]]; + if (handle == nil) { + return; + } + @try { + [handle seekToEndOfFile]; + NSData *data = [line dataUsingEncoding:NSUTF8StringEncoding]; + [handle writeData:data]; + } @catch (__unused NSException *exception) { + } @finally { + [handle closeFile]; + } +} + +static NSString *gonaviWindowDiagnosticLine(NSString *eventName, NSWindow *window) { + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + [formatter setDateFormat:@"yyyy/MM/dd HH:mm:ss.SSSSSS"]; + NSString *timestamp = [formatter stringFromDate:[NSDate date]]; + [formatter release]; + if (window == nil) { + return [NSString stringWithFormat:@"%@ [WARN] 原生窗口诊断:event=%@ window=nil\n", timestamp, eventName ?: @"unknown"]; + } + NSUInteger occlusionState = 0; + NSInteger windowNumber = [window windowNumber]; + NSInteger level = [window level]; + NSUInteger collectionBehavior = [window collectionBehavior]; + NSString *className = NSStringFromClass([window class]); + NSString *delegateClassName = [window delegate] ? NSStringFromClass([[window delegate] class]) : @"nil"; + if (@available(macOS 10.9, *)) { + occlusionState = [window occlusionState]; + } + return [NSString stringWithFormat:@"%@ [WARN] 原生窗口诊断:event=%@ ptr=%p number=%ld class=%@ delegate=%@ visible=%@ miniaturized=%@ key=%@ main=%@ canHide=%@ level=%ld occlusion=%lu styleMask=%lu collectionBehavior=%lu frame=%@ screen=%@ title=%@\n", + timestamp, + eventName ?: @"unknown", + window, + (long)windowNumber, + className ?: @"nil", + delegateClassName, + [window isVisible] ? @"true" : @"false", + [window isMiniaturized] ? @"true" : @"false", + [window isKeyWindow] ? @"true" : @"false", + [window isMainWindow] ? @"true" : @"false", + [window canHide] ? @"true" : @"false", + (long)level, + (unsigned long)occlusionState, + (unsigned long)[window styleMask], + (unsigned long)collectionBehavior, + NSStringFromRect([window frame]), + [window screen] ? NSStringFromRect([[window screen] frame]) : @"nil", + [window title] ?: @""]; +} + +@interface GoNaviNativeWindowObserver : NSObject +@end + +@implementation GoNaviNativeWindowObserver + +- (void)logNotification:(NSNotification *)notification { + NSString *name = [notification name] ?: @"unknown"; + NSWindow *window = nil; + if ([[notification object] isKindOfClass:[NSWindow class]]) { + window = (NSWindow *)[notification object]; + } else { + window = [NSApp keyWindow] ?: [NSApp mainWindow]; + } + gonaviWriteNativeWindowLogLine(gonaviWindowDiagnosticLine(name, window)); +} + +@end + +static GoNaviNativeWindowObserver *gonaviNativeWindowObserver = nil; + +static void gonaviInstallNativeWindowObserver(void) { + if (gonaviNativeObserverInstalled) { + return; + } + gonaviNativeObserverInstalled = YES; + gonaviNativeWindowObserver = [[GoNaviNativeWindowObserver alloc] init]; + NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; + NSArray *windowNotifications = @[ + NSWindowDidBecomeKeyNotification, + NSWindowDidResignKeyNotification, + NSWindowDidBecomeMainNotification, + NSWindowDidResignMainNotification, + NSWindowDidMiniaturizeNotification, + NSWindowDidDeminiaturizeNotification, + NSWindowDidEnterFullScreenNotification, + NSWindowDidExitFullScreenNotification, + NSWindowDidMoveNotification, + NSWindowDidResizeNotification, + NSWindowDidChangeOcclusionStateNotification, + ]; + for (NSString *notificationName in windowNotifications) { + [center addObserver:gonaviNativeWindowObserver selector:@selector(logNotification:) name:notificationName object:nil]; + } + NSArray *appNotifications = @[ + NSApplicationDidHideNotification, + NSApplicationDidUnhideNotification, + NSApplicationDidBecomeActiveNotification, + NSApplicationDidResignActiveNotification, + ]; + for (NSString *notificationName in appNotifications) { + [center addObserver:gonaviNativeWindowObserver selector:@selector(logNotification:) name:notificationName object:nil]; + } + for (NSWindow *window in [NSApp windows]) { + gonaviWriteNativeWindowLogLine(gonaviWindowDiagnosticLine(@"observer:snapshot", window)); + } +} + +static void gonaviConfigureNativeWindowDiagnostics(const char *logPath) { + if (logPath == NULL || logPath[0] == '\0') { + return; + } + if (gonaviNativeLogPath != NULL) { + free(gonaviNativeLogPath); + gonaviNativeLogPath = NULL; + } + gonaviNativeLogPath = strdup(logPath); + dispatch_async(dispatch_get_main_queue(), ^{ + gonaviInstallNativeWindowObserver(); + }); +} + static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) { if (window == nil) { return; @@ -24,12 +155,31 @@ static void gonaviSetWindowButtonsVisible(NSWindow *window, BOOL visible) { } } +static BOOL gonaviShouldApplyMacWindowStyle(NSWindow *window) { + if (window == nil) { + return NO; + } + NSString *className = NSStringFromClass([window class]) ?: @""; + NSString *delegateClassName = [window delegate] ? NSStringFromClass([[window delegate] class]) : @""; + NSString *title = [window title] ?: @""; + + // 仅对主 WailsWindow 套用原生标题栏/全屏样式,避免误伤输入法候选窗、全屏过渡窗。 + if ([className isEqualToString:@"WailsWindow"] || [delegateClassName isEqualToString:@"WindowDelegate"]) { + return YES; + } + return [title isEqualToString:@"GoNavi"]; +} + static void gonaviApplyMacWindowStyle(BOOL enabled) { dispatch_async(dispatch_get_main_queue(), ^{ for (NSWindow *window in [NSApp windows]) { if (window == nil) { continue; } + if (!gonaviShouldApplyMacWindowStyle(window)) { + gonaviWriteNativeWindowLogLine(gonaviWindowDiagnosticLine(@"style:skip-non-app-window", window)); + continue; + } NSUInteger styleMask = [window styleMask]; styleMask |= NSWindowStyleMaskClosable; @@ -57,12 +207,24 @@ static void gonaviApplyMacWindowStyle(BOOL enabled) { [[window contentView] setNeedsDisplay:YES]; [window invalidateShadow]; + gonaviWriteNativeWindowLogLine(gonaviWindowDiagnosticLine(enabled ? @"style:enable-native-controls" : @"style:disable-native-controls", window)); } }); } */ import "C" +import "unsafe" + +func installMacNativeWindowDiagnostics(logPath string) { + if logPath == "" { + return + } + cLogPath := C.CString(logPath) + defer C.free(unsafe.Pointer(cLogPath)) + C.gonaviConfigureNativeWindowDiagnostics(cLogPath) +} + func setMacNativeWindowControls(enabled bool) { state := resolveMacNativeWindowControlState(enabled) if state.ShowNativeButtons { diff --git a/internal/app/window_style_logic.go b/internal/app/window_style_logic.go index 79e33cb8..d5f573dd 100644 --- a/internal/app/window_style_logic.go +++ b/internal/app/window_style_logic.go @@ -1,5 +1,7 @@ package app +import "strings" + type macNativeWindowControlState struct { ShowNativeButtons bool UseTitledWindow bool @@ -30,3 +32,24 @@ func resolveMacNativeWindowControlState(enabled bool) macNativeWindowControlStat AllowNativeFullscreen: false, } } + +type macWindowIdentity struct { + ClassName string + DelegateClassName string + Title string +} + +// shouldApplyMacNativeWindowStyle 只允许对主 WailsWindow 应用原生标题栏/全屏能力, +// 避免误伤输入法候选窗、全屏过渡窗等系统辅助窗口。 +func shouldApplyMacNativeWindowStyle(identity macWindowIdentity) bool { + className := strings.TrimSpace(identity.ClassName) + delegateClassName := strings.TrimSpace(identity.DelegateClassName) + title := strings.TrimSpace(identity.Title) + + if className == "WailsWindow" || delegateClassName == "WindowDelegate" { + return true + } + + // 兜底只接受明确命名的主应用窗口,避免把无标题系统辅助窗口纳入样式改写范围。 + return title == "GoNavi" +} diff --git a/internal/app/window_style_logic_test.go b/internal/app/window_style_logic_test.go index 430c6e3a..c4aa8e2e 100644 --- a/internal/app/window_style_logic_test.go +++ b/internal/app/window_style_logic_test.go @@ -35,3 +35,33 @@ func TestResolveMacNativeWindowControlStateDisabled(t *testing.T) { t.Fatal("expected disabled state to avoid native fullscreen behavior") } } + +func TestShouldApplyMacNativeWindowStyleAcceptsMainWailsWindow(t *testing.T) { + tests := []macWindowIdentity{ + {ClassName: "WailsWindow", DelegateClassName: "WindowDelegate", Title: "GoNavi"}, + {ClassName: "WailsWindow", DelegateClassName: "", Title: ""}, + {ClassName: "", DelegateClassName: "WindowDelegate", Title: ""}, + {ClassName: "", DelegateClassName: "", Title: "GoNavi"}, + } + + for _, tt := range tests { + if !shouldApplyMacNativeWindowStyle(tt) { + t.Fatalf("expected window identity %+v to be treated as main app window", tt) + } + } +} + +func TestShouldApplyMacNativeWindowStyleRejectsSystemAuxiliaryWindows(t *testing.T) { + tests := []macWindowIdentity{ + {ClassName: "TUINSWindow", DelegateClassName: "TUINSWindow", Title: ""}, + {ClassName: "NSToolbarFullScreenWindow", DelegateClassName: "", Title: ""}, + {ClassName: "_NSFullScreenTransitionOverlayWindow", DelegateClassName: "", Title: ""}, + {ClassName: "NSPanel", DelegateClassName: "", Title: ""}, + } + + for _, tt := range tests { + if shouldApplyMacNativeWindowStyle(tt) { + t.Fatalf("expected window identity %+v to be rejected as auxiliary/system window", tt) + } + } +}