@@ -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
6992import { createSwitchifyTray } from './tray' ;
93+ import { trayMenuPosition } from './tray' ;
7094
7195describe ( '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+
155221function 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
194262function setPlatform ( platform : NodeJS . Platform ) : void {
0 commit comments