From b761fa796448966294704687e18be46fc14853a9 Mon Sep 17 00:00:00 2001 From: purocean Date: Sat, 19 Sep 2026 12:46:48 +0800 Subject: [PATCH] fix: sync native titlebar window state --- .../support/__tests__/window-state.ts | 37 +++++++++++++++++++ src/renderer/support/window-state.ts | 29 +++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/renderer/support/__tests__/window-state.ts b/src/renderer/support/__tests__/window-state.ts index dbcaab42..e1826745 100644 --- a/src/renderer/support/__tests__/window-state.ts +++ b/src/renderer/support/__tests__/window-state.ts @@ -6,8 +6,15 @@ const mocks = vi.hoisted(() => ({ isSaved: undefined as any, win: undefined as any, listeners: new Map(), + hooks: new Map(), isElectron: true, isMacOS: true, + colorScheme: 'light', +})) + +vi.mock('@fe/core/hook', () => ({ + registerHook: (name: string, handler: Function) => mocks.hooks.set(name, handler), + removeHook: (name: string) => mocks.hooks.delete(name), })) vi.mock('@fe/support/store', () => ({ @@ -32,6 +39,10 @@ vi.mock('@fe/services/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }), })) +vi.mock('@fe/services/theme', () => ({ + getColorScheme: () => mocks.colorScheme, +})) + import { isWindowAlwaysOnTop, toggleWindowAlwaysOnTop, useWindowState } from '../window-state' const TestComponent = defineComponent({ @@ -48,11 +59,14 @@ beforeEach(() => { }) mocks.isSaved = ref(false) mocks.listeners.clear() + mocks.hooks.clear() mocks.isElectron = true mocks.isMacOS = true + mocks.colorScheme = 'light' mocks.win = { isAlwaysOnTop: vi.fn(() => false), setAlwaysOnTop: vi.fn(), + setTitleBarOverlay: vi.fn(), setDocumentEdited: vi.fn(), on: vi.fn((name: string, handler: Function) => mocks.listeners.set(name, handler)), removeListener: vi.fn((name: string) => mocks.listeners.delete(name)), @@ -81,6 +95,7 @@ describe('window state', () => { mocks.listeners.get('enter-full-screen')?.() expect(mocks.storeState.isFullscreen).toBe(true) + expect(mocks.win.setAlwaysOnTop).toHaveBeenLastCalledWith(false) mocks.listeners.get('leave-full-screen')?.() expect(mocks.storeState.isFullscreen).toBe(false) @@ -94,6 +109,28 @@ describe('window state', () => { expect(mocks.win.removeListener).toHaveBeenCalledWith('leave-full-screen', expect.any(Function)) }) + test('keeps native window controls legible when the app theme changes', () => { + mocks.isMacOS = false + const wrapper = mount(TestComponent) + + expect(mocks.win.setTitleBarOverlay).toHaveBeenLastCalledWith({ + color: '#00000000', + symbolColor: '#151518', + height: 30, + }) + + mocks.colorScheme = 'dark' + mocks.hooks.get('THEME_CHANGE')?.() + expect(mocks.win.setTitleBarOverlay).toHaveBeenLastCalledWith({ + color: '#00000000', + symbolColor: '#e6e6e6', + height: 30, + }) + + wrapper.unmount() + expect(mocks.hooks.has('THEME_CHANGE')).toBe(false) + }) + test('keeps the browser unsaved-document prompt', () => { mocks.isElectron = false const wrapper = mount(TestComponent) diff --git a/src/renderer/support/window-state.ts b/src/renderer/support/window-state.ts index 4d1cbdf7..e467406c 100644 --- a/src/renderer/support/window-state.ts +++ b/src/renderer/support/window-state.ts @@ -1,7 +1,9 @@ import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue' +import { registerHook, removeHook } from '@fe/core/hook' import { getElectronRemote, isElectron, isMacOS, nodeRequire } from '@fe/support/env' import store from '@fe/support/store' import { useI18n } from '@fe/services/i18n' +import { getColorScheme } from '@fe/services/theme' let win: ReturnType['getCurrentWindow']> | null = null @@ -11,6 +13,23 @@ function updateAlwaysOnTop () { isWindowAlwaysOnTop.value = !!win?.isAlwaysOnTop() } +function clearAlwaysOnTop () { + if (win?.isAlwaysOnTop()) { + win.setAlwaysOnTop(false) + } + updateAlwaysOnTop() +} + +function updateTitleBarOverlay () { + if (win && !isMacOS) { + win.setTitleBarOverlay({ + color: '#00000000', + symbolColor: getColorScheme() === 'dark' ? '#e6e6e6' : '#151518', + height: 30, + }) + } +} + export function toggleWindowAlwaysOnTop () { if (!win) { return @@ -27,6 +46,7 @@ export function useWindowState () { function handleFullscreenEnter () { store.state.isFullscreen = true + clearAlwaysOnTop() } function handleFullscreenLeave () { @@ -34,10 +54,7 @@ export function useWindowState () { } function handleMaximize () { - if (win?.isAlwaysOnTop()) { - win.setAlwaysOnTop(false) - } - updateAlwaysOnTop() + clearAlwaysOnTop() } function clean () { @@ -54,6 +71,8 @@ export function useWindowState () { win.removeListener('leave-full-screen', handleFullscreenLeave) } + removeHook('THEME_CHANGE', updateTitleBarOverlay) + win = null isWindowAlwaysOnTop.value = false } @@ -67,6 +86,8 @@ export function useWindowState () { if (nodeRequire) { win = getElectronRemote().getCurrentWindow() updateAlwaysOnTop() + updateTitleBarOverlay() + registerHook('THEME_CHANGE', updateTitleBarOverlay) win.on('maximize', handleMaximize) win.on('always-on-top-changed', updateAlwaysOnTop) win.on('enter-full-screen', handleFullscreenEnter)