Skip to content

Commit 917773f

Browse files
committed
fix(notifications): 通知唤醒窗口时保持原有窗口显示状态
抽取 activateWindowPreservingState,统一主窗口启动兜底、二次启动回前台、 通知点击跳转和协议唤醒的窗口激活流程。 对已可见的全屏窗口仅执行应用与窗口聚焦,避免 show、moveTop 和置顶抖动 破坏用户当前的全屏或可见状态。 同时补齐窗口激活单测,覆盖“全屏保持状态”和“最小化恢复激活”两条关键分支, 并修复测试文件末尾缺失的 describe 结束符,恢复 Electron 构建与 Vitest 校验。 Signed-off-by: Lulu <58587930+lulu-sk@users.noreply.github.com>
1 parent 0d8eeeb commit 917773f

File tree

4 files changed

+231
-56
lines changed

4 files changed

+231
-56
lines changed

electron/main.ts

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { getBaseUserDataDir, getFeatureFlags, updateFeatureFlags } from "./featu
4747
import { readDebugConfig, getDebugConfig, onDebugChanged, watchDebugConfig, updateDebugConfig, resetDebugConfig, unwatchDebugConfig } from "./debugConfig";
4848
import { getGitDirInfoBatchAsync, invalidateGitDirInfoCache } from "./git/status";
4949
import { initGitRepositoryAsync } from "./git/repoInit";
50+
import { activateWindowPreservingState } from "./windowActivation";
5051
import { autoCommitWorktreeIfDirtyAsync, createWorktreesAsync, listLocalBranchesAsync, recycleWorktreeAsync, removeWorktreeAsync } from "./git/worktreeOps";
5152
import { isWorktreeAlignedToMainAsync, resetWorktreeAsync } from "./git/worktreeReset";
5253
import { resolveWorktreeForkPointAsync, searchForkPointCommitsAsync, validateForkPointRefAsync } from "./git/worktreeForkPoint";
@@ -884,14 +885,8 @@ function ensureWindowInView(win: BrowserWindow): void {
884885
*/
885886
function forceShowWindow(win: BrowserWindow, reason: string): void {
886887
try {
887-
const minimized = (() => { try { return win.isMinimized(); } catch { return false; } })();
888-
const visible = (() => { try { return win.isVisible(); } catch { return false; } })();
889-
try { perfLogger.log(`[WIN] forceShow reason=${reason} minimized=${minimized ? 1 : 0} visible=${visible ? 1 : 0}`); } catch {}
890-
try { if (minimized) win.restore(); } catch {}
891-
try { win.show(); } catch {}
892-
try { win.focus(); } catch {}
893-
try { (win as any).moveTop?.(); } catch {}
894-
try { app.focus({ steal: true } as any); } catch { try { app.focus(); } catch {} }
888+
const activation = activateWindowPreservingState(win);
889+
try { perfLogger.log(`[WIN] forceShow reason=${reason} minimized=${activation.wasMinimized ? 1 : 0} visible=${activation.wasVisible ? 1 : 0} fullscreen=${activation.wasFullScreen ? 1 : 0}`); } catch {}
895890
} catch {}
896891
}
897892

@@ -1265,21 +1260,18 @@ function focusTabFromProtocol(rawUrl?: string | null) {
12651260
const parsed = new URL(rawUrl);
12661261
const intent = parsed.hostname || parsed.pathname.replace(/^\//, '');
12671262
if (intent !== 'focus-tab') return;
1268-
const tabId = parsed.searchParams.get('tabId') ?? parsed.searchParams.get('tab') ?? parsed.searchParams.get('id');
1269-
if (!tabId) return;
1270-
const handleWindow = (target: BrowserWindow) => {
1271-
if (target.isMinimized()) {
1272-
try { target.restore(); } catch {}
1273-
}
1274-
try { target.show(); target.focus(); } catch {}
1275-
const wc = target.webContents;
1276-
const dispatch = () => {
1277-
try { perfLogger.log(`[notifications] protocol focus tabId=${tabId}`); } catch {}
1278-
try { wc.send('notifications:focus-tab', { tabId }); } catch {}
1279-
};
1280-
if (wc.isDestroyed()) return;
1281-
if (wc.isLoading()) wc.once('did-finish-load', dispatch);
1282-
else dispatch();
1263+
const tabId = parsed.searchParams.get('tabId') ?? parsed.searchParams.get('tab') ?? parsed.searchParams.get('id');
1264+
if (!tabId) return;
1265+
const handleWindow = (target: BrowserWindow) => {
1266+
const activation = activateWindowPreservingState(target);
1267+
const wc = target.webContents;
1268+
const dispatch = () => {
1269+
try { perfLogger.log(`[notifications] protocol focus tabId=${tabId} minimized=${activation.wasMinimized ? 1 : 0} visible=${activation.wasVisible ? 1 : 0} fullscreen=${activation.wasFullScreen ? 1 : 0}`); } catch {}
1270+
try { wc.send('notifications:focus-tab', { tabId }); } catch {}
1271+
};
1272+
if (wc.isDestroyed()) return;
1273+
if (wc.isLoading()) wc.once('did-finish-load', dispatch);
1274+
else dispatch();
12831275
};
12841276
if (mainWindow) {
12851277
handleWindow(mainWindow);
@@ -1651,8 +1643,7 @@ if (!gotLock) {
16511643
}
16521644
} catch {}
16531645
if (mainWindow) {
1654-
if (mainWindow.isMinimized()) mainWindow.restore();
1655-
mainWindow.focus();
1646+
activateWindowPreservingState(mainWindow);
16561647
}
16571648
}
16581649

electron/notifications.ts

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url';
77
import { app, BrowserWindow, ipcMain, Notification, nativeImage, Event } from 'electron';
88
import settings from './settings';
99
import { perfLogger } from './log';
10+
import { activateWindowPreservingState } from './windowActivation';
1011

1112
type BadgePayload = { count?: number };
1213

@@ -503,40 +504,15 @@ export function registerNotificationIPC(getWindow: () => BrowserWindow | null, o
503504
logNotification(`notification focus source=${source} tabId=${payload.tabId}`);
504505
const win = getWindow();
505506
if (win) {
506-
// Windows 平台窗口恢复到前台的完整流程
507-
const wasMinimized = win.isMinimized();
508507
try {
509-
// 1. 先显示窗口(在恢复之前)
510-
win.show();
511-
512-
// 2. 如果窗口被最小化,恢复窗口
513-
if (wasMinimized) {
514-
win.restore();
515-
}
516-
517-
// 3. 使用 setAlwaysOnTop 技巧强制窗口到前台(Windows 平台常用做法)
518-
if (process.platform === 'win32') {
519-
const wasAlwaysOnTop = win.isAlwaysOnTop();
520-
if (!wasAlwaysOnTop) {
521-
win.setAlwaysOnTop(true);
522-
win.setAlwaysOnTop(false);
523-
}
524-
}
525-
526-
// 4. 聚焦窗口
527-
win.focus();
528-
529-
// 5. 尝试将窗口移到最前面(Windows 特定)
530-
if (process.platform === 'win32') {
531-
try { win.moveTop(); } catch {}
532-
}
533-
534-
logNotification(`window restored wasMinimized=${wasMinimized} platform=${process.platform}`);
508+
const activation = activateWindowPreservingState(win);
509+
logNotification(
510+
`window activated minimized=${activation.wasMinimized ? '1' : '0'} visible=${activation.wasVisible ? '1' : '0'} fullscreen=${activation.wasFullScreen ? '1' : '0'} platform=${process.platform}`
511+
);
535512
} catch (error) {
536-
logNotification(`window restore failed: ${String(error)}`);
513+
logNotification(`window activate failed: ${String(error)}`);
537514
}
538-
539-
// 6. 发送聚焦标签页的消息
515+
540516
try { win.webContents.send('notifications:focus-tab', { tabId: payload.tabId }); } catch {}
541517
} else {
542518
logNotification(`notification focus skipped (window missing) source=${source} tabId=${payload.tabId}`);

electron/windowActivation.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
3+
const { appFocusMock } = vi.hoisted(() => ({
4+
appFocusMock: vi.fn(),
5+
}));
6+
7+
vi.mock("electron", () => ({
8+
app: {
9+
focus: appFocusMock,
10+
},
11+
}));
12+
13+
import { activateWindowPreservingState } from "./windowActivation";
14+
15+
type MockBrowserWindow = {
16+
isMinimized: ReturnType<typeof vi.fn>;
17+
isVisible: ReturnType<typeof vi.fn>;
18+
isFullScreen: ReturnType<typeof vi.fn>;
19+
isAlwaysOnTop: ReturnType<typeof vi.fn>;
20+
restore: ReturnType<typeof vi.fn>;
21+
show: ReturnType<typeof vi.fn>;
22+
setAlwaysOnTop: ReturnType<typeof vi.fn>;
23+
focus: ReturnType<typeof vi.fn>;
24+
moveTop: ReturnType<typeof vi.fn>;
25+
};
26+
27+
/**
28+
* 中文说明:构造最小 BrowserWindow 测试替身,便于验证激活流程的调用分支。
29+
*/
30+
function createMockBrowserWindow(overrides?: Partial<MockBrowserWindow>): MockBrowserWindow {
31+
return {
32+
isMinimized: vi.fn(() => false),
33+
isVisible: vi.fn(() => true),
34+
isFullScreen: vi.fn(() => false),
35+
isAlwaysOnTop: vi.fn(() => false),
36+
restore: vi.fn(),
37+
show: vi.fn(),
38+
setAlwaysOnTop: vi.fn(),
39+
focus: vi.fn(),
40+
moveTop: vi.fn(),
41+
...overrides,
42+
};
43+
}
44+
45+
afterEach(() => {
46+
try { vi.clearAllMocks(); } catch {}
47+
});
48+
49+
describe("electron/windowActivation.activateWindowPreservingState", () => {
50+
it("全屏且已可见时不会触发 show、置顶抖动或 moveTop", () => {
51+
const win = createMockBrowserWindow({
52+
isFullScreen: vi.fn(() => true),
53+
});
54+
55+
const snapshot = activateWindowPreservingState(win as any, { platform: "win32" });
56+
57+
expect(win.restore).not.toHaveBeenCalled();
58+
expect(win.show).not.toHaveBeenCalled();
59+
expect(win.setAlwaysOnTop).not.toHaveBeenCalled();
60+
expect(win.moveTop).not.toHaveBeenCalled();
61+
expect(win.focus).toHaveBeenCalledTimes(1);
62+
expect(appFocusMock).toHaveBeenCalledTimes(1);
63+
expect(snapshot.wasFullScreen).toBe(true);
64+
expect(snapshot.usedShow).toBe(false);
65+
expect(snapshot.usedAlwaysOnTopHack).toBe(false);
66+
expect(snapshot.usedMoveTop).toBe(false);
67+
});
68+
69+
it("最小化非全屏窗口会先恢复,再执行 Windows 前台激活流程", () => {
70+
const win = createMockBrowserWindow({
71+
isMinimized: vi.fn(() => true),
72+
isVisible: vi.fn(() => false),
73+
});
74+
75+
const snapshot = activateWindowPreservingState(win as any, { platform: "win32" });
76+
77+
expect(win.restore).toHaveBeenCalledTimes(1);
78+
expect(win.show).not.toHaveBeenCalled();
79+
expect(win.setAlwaysOnTop).toHaveBeenNthCalledWith(1, true);
80+
expect(win.setAlwaysOnTop).toHaveBeenNthCalledWith(2, false);
81+
expect(win.focus).toHaveBeenCalledTimes(1);
82+
expect(win.moveTop).toHaveBeenCalledTimes(1);
83+
expect(snapshot.wasMinimized).toBe(true);
84+
expect(snapshot.usedRestore).toBe(true);
85+
expect(snapshot.usedAlwaysOnTopHack).toBe(true);
86+
expect(snapshot.usedMoveTop).toBe(true);
87+
});
88+
});

electron/windowActivation.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { app, type BrowserWindow } from "electron";
2+
3+
export type WindowActivationSnapshot = {
4+
wasMinimized: boolean;
5+
wasVisible: boolean;
6+
wasFullScreen: boolean;
7+
usedRestore: boolean;
8+
usedShow: boolean;
9+
usedAlwaysOnTopHack: boolean;
10+
usedMoveTop: boolean;
11+
focusedWindow: boolean;
12+
focusedApp: boolean;
13+
};
14+
15+
type ActivateWindowOptions = {
16+
platform?: NodeJS.Platform;
17+
};
18+
19+
/**
20+
* 中文说明:安全读取窗口布尔状态,读取失败时回退到给定默认值。
21+
*/
22+
function readWindowFlag(read: () => boolean, fallback: boolean): boolean {
23+
try {
24+
return !!read();
25+
} catch {
26+
return fallback;
27+
}
28+
}
29+
30+
/**
31+
* 中文说明:尝试让应用进入前台;在支持时优先请求 steal 模式,失败后回退到普通 focus。
32+
*/
33+
function focusApplication(): boolean {
34+
try {
35+
app.focus({ steal: true } as any);
36+
return true;
37+
} catch {
38+
try {
39+
app.focus();
40+
return true;
41+
} catch {
42+
return false;
43+
}
44+
}
45+
}
46+
47+
/**
48+
* 中文说明:唤醒主窗口到前台,同时尽量保持原有窗口状态不变。
49+
* - 已可见的全屏窗口只做应用/窗口聚焦,避免执行可能导致退出全屏的 show、moveTop、置顶抖动。
50+
* - 最小化窗口优先 restore;隐藏窗口仅在确有需要时 show。
51+
*/
52+
export function activateWindowPreservingState(win: BrowserWindow, options?: ActivateWindowOptions): WindowActivationSnapshot {
53+
const platform = options?.platform ?? process.platform;
54+
const wasMinimized = readWindowFlag(() => win.isMinimized(), false);
55+
const wasVisible = readWindowFlag(() => win.isVisible(), true);
56+
const wasFullScreen = readWindowFlag(() => win.isFullScreen(), false);
57+
58+
let usedRestore = false;
59+
let usedShow = false;
60+
let usedAlwaysOnTopHack = false;
61+
let usedMoveTop = false;
62+
let focusedWindow = false;
63+
64+
if (wasMinimized) {
65+
try {
66+
win.restore();
67+
usedRestore = true;
68+
} catch {}
69+
}
70+
71+
if (!wasMinimized && !wasVisible) {
72+
try {
73+
win.show();
74+
usedShow = true;
75+
} catch {}
76+
}
77+
78+
if (platform === "win32" && !wasFullScreen) {
79+
const wasAlwaysOnTop = readWindowFlag(() => win.isAlwaysOnTop(), false);
80+
if (!wasAlwaysOnTop) {
81+
let enabled = false;
82+
try {
83+
win.setAlwaysOnTop(true);
84+
enabled = true;
85+
} catch {}
86+
if (enabled) {
87+
try {
88+
win.setAlwaysOnTop(false);
89+
usedAlwaysOnTopHack = true;
90+
} catch {}
91+
}
92+
}
93+
}
94+
95+
const focusedApp = focusApplication();
96+
97+
try {
98+
win.focus();
99+
focusedWindow = true;
100+
} catch {}
101+
102+
if (platform === "win32" && !wasFullScreen) {
103+
try {
104+
win.moveTop();
105+
usedMoveTop = true;
106+
} catch {}
107+
}
108+
109+
return {
110+
wasMinimized,
111+
wasVisible,
112+
wasFullScreen,
113+
usedRestore,
114+
usedShow,
115+
usedAlwaysOnTopHack,
116+
usedMoveTop,
117+
focusedWindow,
118+
focusedApp,
119+
};
120+
}

0 commit comments

Comments
 (0)