Skip to content

Commit e9d2f1c

Browse files
Use anchored Windows tray popup (#226)
* Use anchored Windows tray popup * Fix tray diagnostics typing --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent d8fdd5b commit e9d2f1c

2 files changed

Lines changed: 156 additions & 17 deletions

File tree

src/main/tray.test.ts

Lines changed: 83 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,18 @@ const electronMock = vi.hoisted(() => {
1010
click?: () => void;
1111
type?: string;
1212
};
13+
type FakeMenu = {
14+
template: MenuTemplateItem[];
15+
once: ReturnType<typeof vi.fn>;
16+
emitMenuWillClose: () => void;
17+
};
1318

1419
class FakeTray {
1520
readonly handlers = new Map<string, Handler[]>();
1621
readonly setToolTip = vi.fn();
1722
readonly setContextMenu = vi.fn();
1823
readonly popUpContextMenu = vi.fn();
24+
readonly focus = vi.fn();
1925
readonly destroy = vi.fn();
2026

2127
on(event: string, handler: Handler): this {
@@ -38,11 +44,28 @@ const electronMock = vi.hoisted(() => {
3844
setTemplateImage: vi.fn()
3945
}))
4046
};
41-
const buildFromTemplate = vi.fn((template: MenuTemplateItem[]) => ({ template }));
47+
const menus: FakeMenu[] = [];
48+
const buildFromTemplate = vi.fn((template: MenuTemplateItem[]) => {
49+
let menuWillCloseHandler: Handler | null = null;
50+
const menu = {
51+
template,
52+
once: vi.fn((event: string, handler: Handler) => {
53+
if (event === 'menu-will-close') {
54+
menuWillCloseHandler = handler;
55+
}
56+
}),
57+
emitMenuWillClose: () => {
58+
menuWillCloseHandler?.();
59+
}
60+
};
61+
menus.push(menu);
62+
return menu;
63+
});
4264

4365
return {
4466
FakeTray,
4567
trayInstances,
68+
menus,
4669
buildFromTemplate,
4770
image
4871
};
@@ -67,12 +90,14 @@ vi.mock('electron', () => ({
6790
}));
6891

6992
import { createSwitchifyTray } from './tray';
93+
import { trayMenuPosition } from './tray';
7094

7195
describe('createSwitchifyTray', () => {
7296
const originalPlatform = process.platform;
7397

7498
beforeEach(() => {
7599
electronMock.trayInstances.length = 0;
100+
electronMock.menus.length = 0;
76101
electronMock.buildFromTemplate.mockClear();
77102
electronMock.image.resize.mockClear();
78103
setPlatform('win32');
@@ -98,33 +123,63 @@ describe('createSwitchifyTray', () => {
98123
expect(callbacks.quit).not.toHaveBeenCalled();
99124
});
100125

101-
it('attaches the context menu on Windows', () => {
126+
it('uses an anchored manual popup on Windows right-click', () => {
102127
const callbacks = createCallbacks();
103128
createSwitchifyTray({
104129
...callbacks,
105130
getStatus: () => status()
106131
});
107132

108-
expect(tray().setContextMenu).toHaveBeenCalledTimes(1);
109-
expect(tray().setContextMenu).toHaveBeenCalledWith(lastMenu());
110-
expect(tray().popUpContextMenu).not.toHaveBeenCalled();
111-
expect(tray().handlers.has('right-click')).toBe(false);
133+
expect(tray().setContextMenu).not.toHaveBeenCalled();
134+
expect(tray().handlers.has('right-click')).toBe(true);
135+
136+
tray().emit('right-click', {}, { x: 100, y: 100, width: 16, height: 24 });
137+
138+
expect(tray().popUpContextMenu).toHaveBeenCalledTimes(1);
139+
expect(tray().popUpContextMenu).toHaveBeenCalledWith(lastMenu(), { x: 100, y: 124 });
112140
});
113141

114-
it('refreshes the context menu with the latest status on update', () => {
142+
it('refreshes the context menu with the latest status before Windows popup', () => {
115143
const callbacks = createCallbacks();
116144
let connectedClientCount = 0;
117-
const switchifyTray = createSwitchifyTray({
145+
createSwitchifyTray({
118146
...callbacks,
119147
getStatus: () => status({ connectedClientCount })
120148
});
121149

122150
connectedClientCount = 1;
123-
switchifyTray.update();
151+
tray().emit('right-click', {}, { x: 100, y: 100, width: 16, height: 24 });
124152

125153
const disconnectItem = lastMenu().template.find((item) => item.label === 'Disconnect device');
126154
expect(disconnectItem?.enabled).toBe(true);
127-
expect(tray().setContextMenu).toHaveBeenLastCalledWith(lastMenu());
155+
expect(tray().popUpContextMenu).toHaveBeenLastCalledWith(lastMenu(), { x: 100, y: 124 });
156+
});
157+
158+
it('restores notification area focus when the Windows popup closes', () => {
159+
const callbacks = createCallbacks();
160+
createSwitchifyTray({
161+
...callbacks,
162+
getStatus: () => status()
163+
});
164+
165+
tray().emit('right-click', {}, { x: 100, y: 100, width: 16, height: 24 });
166+
lastMenu().emitMenuWillClose();
167+
168+
expect(tray().focus).toHaveBeenCalledTimes(1);
169+
});
170+
171+
it('attaches the context menu on non-Windows platforms', () => {
172+
setPlatform('darwin');
173+
const callbacks = createCallbacks();
174+
createSwitchifyTray({
175+
...callbacks,
176+
getStatus: () => status()
177+
});
178+
179+
expect(tray().setContextMenu).toHaveBeenCalledTimes(1);
180+
expect(tray().setContextMenu).toHaveBeenCalledWith(lastMenu());
181+
expect(tray().popUpContextMenu).not.toHaveBeenCalled();
182+
expect(tray().handlers.has('right-click')).toBe(false);
128183
});
129184

130185
it('refreshes the tooltip on update', () => {
@@ -152,6 +207,17 @@ describe('createSwitchifyTray', () => {
152207
});
153208
});
154209

210+
describe('trayMenuPosition', () => {
211+
it('anchors a menu below the tray bounds', () => {
212+
expect(trayMenuPosition({ x: 10.4, y: 20.2, width: 16, height: 18.6 })).toEqual({ x: 10, y: 39 });
213+
});
214+
215+
it('returns undefined for missing or invalid bounds', () => {
216+
expect(trayMenuPosition(undefined)).toBeUndefined();
217+
expect(trayMenuPosition({ x: Number.NaN, y: 20, width: 16, height: 18 })).toBeUndefined();
218+
});
219+
});
220+
155221
function createCallbacks(): Omit<Parameters<typeof createSwitchifyTray>[0], 'getStatus'> {
156222
return {
157223
showWindow: vi.fn(),
@@ -182,13 +248,15 @@ function tray(): InstanceType<typeof electronMock.FakeTray> {
182248
return currentTray;
183249
}
184250

185-
function lastMenu(): { template: Array<{ label?: string; enabled?: boolean; click?: () => void; type?: string }> } {
186-
const calls = electronMock.buildFromTemplate.mock.calls;
187-
const template = calls.at(-1)?.[0];
188-
if (!template) {
251+
function lastMenu(): {
252+
template: Array<{ label?: string; enabled?: boolean; click?: () => void; type?: string }>;
253+
emitMenuWillClose: () => void;
254+
} {
255+
const menu = electronMock.menus.at(-1);
256+
if (!menu) {
189257
throw new Error('Expected a menu template.');
190258
}
191-
return { template };
259+
return menu;
192260
}
193261

194262
function setPlatform(platform: NodeJS.Platform): void {

src/main/tray.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { app, Menu, nativeImage, Tray, type NativeImage } from 'electron';
1+
import { app, Menu, nativeImage, Tray, type NativeImage, type Rectangle } from 'electron';
22
import { existsSync } from 'node:fs';
33
import { join } from 'node:path';
44
import type { PcControlStatus } from '../shared/server-status';
@@ -16,6 +16,9 @@ export type SwitchifyTray = {
1616
destroy: () => void;
1717
};
1818

19+
type TrayEventName = 'click' | 'right-click' | 'double-click' | 'mouse-enter' | 'mouse-leave';
20+
type TrayBounds = Pick<Rectangle, 'x' | 'y' | 'width' | 'height'>;
21+
1922
export function createSwitchifyTray(options: SwitchifyTrayOptions): SwitchifyTray {
2023
const tray = new Tray(createTrayIcon());
2124
let currentMenu: Menu | null = null;
@@ -24,10 +27,16 @@ export function createSwitchifyTray(options: SwitchifyTrayOptions): SwitchifyTra
2427
const status = options.getStatus();
2528
currentMenu = buildTrayMenu(options, status);
2629
tray.setToolTip(`Switchify PC - ${formatTooltipStatus(status)}`);
27-
tray.setContextMenu(currentMenu);
30+
if (process.platform !== 'win32') {
31+
tray.setContextMenu(currentMenu);
32+
}
2833
};
2934

3035
tray.on('click', options.showWindow);
36+
if (process.platform === 'win32') {
37+
registerWindowsTrayMenu(tray, update, () => currentMenu);
38+
registerWindowsTrayDiagnostics(tray);
39+
}
3140
update();
3241

3342
return {
@@ -52,6 +61,68 @@ function buildTrayMenu(options: SwitchifyTrayOptions, status: PcControlStatus):
5261
]);
5362
}
5463

64+
function registerWindowsTrayMenu(tray: Tray, update: () => void, getCurrentMenu: () => Menu | null): void {
65+
tray.on('right-click', (_event, bounds) => {
66+
update();
67+
const menu = getCurrentMenu();
68+
if (!menu) return;
69+
70+
menu.once?.('menu-will-close', () => {
71+
tray.focus?.();
72+
});
73+
tray.popUpContextMenu(menu, trayMenuPosition(bounds));
74+
});
75+
}
76+
77+
function registerWindowsTrayDiagnostics(tray: Tray): void {
78+
tray.on('click', (_event, bounds) => logTrayEvent('click', bounds));
79+
tray.on('right-click', (_event, bounds) => logTrayEvent('right-click', bounds));
80+
tray.on('double-click', (_event, bounds) => logTrayEvent('double-click', bounds));
81+
tray.on('mouse-enter', (_event, position) => logTrayEvent('mouse-enter', isTrayBounds(position) ? position : undefined));
82+
tray.on('mouse-leave', (_event, position) => logTrayEvent('mouse-leave', isTrayBounds(position) ? position : undefined));
83+
}
84+
85+
export function trayMenuPosition(bounds: TrayBounds | undefined): { x: number; y: number } | undefined {
86+
if (!bounds || !isFiniteBounds(bounds)) return undefined;
87+
return {
88+
x: Math.round(bounds.x),
89+
y: Math.round(bounds.y + bounds.height)
90+
};
91+
}
92+
93+
function isFiniteBounds(bounds: TrayBounds): boolean {
94+
return (
95+
Number.isFinite(bounds.x) &&
96+
Number.isFinite(bounds.y) &&
97+
Number.isFinite(bounds.width) &&
98+
Number.isFinite(bounds.height)
99+
);
100+
}
101+
102+
function isTrayBounds(value: unknown): value is TrayBounds {
103+
if (!value || typeof value !== 'object') return false;
104+
const maybeBounds = value as Partial<TrayBounds>;
105+
return (
106+
typeof maybeBounds.x === 'number' &&
107+
typeof maybeBounds.y === 'number' &&
108+
typeof maybeBounds.width === 'number' &&
109+
typeof maybeBounds.height === 'number'
110+
);
111+
}
112+
113+
function shouldLogTrayDiagnostics(): boolean {
114+
return process.platform === 'win32' && (app.isPackaged || process.env.SWITCHIFY_TRAY_DEBUG === '1');
115+
}
116+
117+
function logTrayEvent(eventName: TrayEventName, bounds?: TrayBounds): void {
118+
if (!shouldLogTrayDiagnostics()) return;
119+
console.info('[tray]', eventName, bounds ? formatBounds(bounds) : '');
120+
}
121+
122+
function formatBounds(bounds: TrayBounds): string {
123+
return `x=${Math.round(bounds.x)} y=${Math.round(bounds.y)} width=${Math.round(bounds.width)} height=${Math.round(bounds.height)}`;
124+
}
125+
55126
function createTrayIcon(): NativeImage {
56127
const iconPath = app.isPackaged
57128
? join(process.resourcesPath, 'icon.png')

0 commit comments

Comments
 (0)