Skip to content

Commit 18d617f

Browse files
Add single instance lock (#228)
Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent e9d2f1c commit 18d617f

3 files changed

Lines changed: 165 additions & 127 deletions

File tree

src/main/index.ts

Lines changed: 142 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { JsonPairingStore } from './pairing/pairing-store';
2222
import { PairingManager } from './pairing/pairing-manager';
2323
import { registerServerIpc } from './server-ipc';
2424
import { registerSettingsWindowIpc } from './settings-window-ipc';
25+
import { secondInstanceAction } from './single-instance';
2526
import { registerSystemStartupIpc } from './system-startup-ipc';
2627
import { shouldStartHidden, SystemStartupService } from './system-startup';
2728
import { createSwitchifyTray, type SwitchifyTray } from './tray';
@@ -45,6 +46,8 @@ if (process.platform === 'win32' && app.isPackaged) {
4546
app.commandLine.appendSwitch('disable-gpu-sandbox');
4647
}
4748

49+
const gotSingleInstanceLock = app.requestSingleInstanceLock();
50+
4851
// Matches --color-bg in src/renderer/styles.css so the window frame paints
4952
// the correct base before the renderer loads.
5053
function shellBackgroundColor(): string {
@@ -233,139 +236,151 @@ function quitApp(): void {
233236
app.quit();
234237
}
235238

236-
app.whenReady().then(() => {
237-
if (process.platform === 'win32') {
238-
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
239-
}
239+
if (!gotSingleInstanceLock) {
240+
app.quit();
241+
} else {
242+
app.on('second-instance', (_event, argv) => {
243+
if (secondInstanceAction(argv, process.platform) === 'ignore') {
244+
return;
245+
}
240246

241-
const startHidden = shouldStartHidden(process.argv, process.platform);
242-
const systemStartup = new SystemStartupService({
243-
platform: process.platform,
244-
isPackaged: app.isPackaged,
245-
executablePath: process.execPath,
246-
appUserModelId: WINDOWS_APP_USER_MODEL_ID,
247-
getLoginItemSettings: (options) => app.getLoginItemSettings(options),
248-
setLoginItemSettings: (settings) => app.setLoginItemSettings(settings)
247+
showMainWindow();
249248
});
250-
const pairingStore = new JsonPairingStore(join(app.getPath('userData'), 'pairing-state.json'));
251-
const cursorOverlaySettingsStore = new JsonCursorOverlaySettingsStore(
252-
join(app.getPath('userData'), 'cursor-overlay-settings.json')
253-
);
254-
const pairingManager = new PairingManager(pairingStore);
255-
const pairingApprovalManager = new PairingApprovalManager(pairingStore);
256-
const inputAdapter = new LibnutWin32InputAdapter((position) => screen.getDisplayNearestPoint(position).scaleFactor);
257-
cursorOverlay = new CursorOverlay({ settings: cursorOverlaySettingsStore.load() });
258-
const commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
259-
releaseHeldMouseButtons = () => commandExecutor.releaseHeldMouseButtons();
260-
const releaseHeldMouseButtonsSafely = (): void => {
261-
void commandExecutor.releaseHeldMouseButtons().catch((error) => {
262-
console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.');
249+
250+
app.whenReady().then(() => {
251+
if (process.platform === 'win32') {
252+
app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID);
253+
}
254+
255+
const startHidden = shouldStartHidden(process.argv, process.platform);
256+
const systemStartup = new SystemStartupService({
257+
platform: process.platform,
258+
isPackaged: app.isPackaged,
259+
executablePath: process.execPath,
260+
appUserModelId: WINDOWS_APP_USER_MODEL_ID,
261+
getLoginItemSettings: (options) => app.getLoginItemSettings(options),
262+
setLoginItemSettings: (settings) => app.setLoginItemSettings(settings)
263263
});
264-
};
265-
controlService = new ControlService({
266-
pairingManager,
267-
pairingApprovalManager,
268-
authValidator: new CommandAuthValidator(pairingStore),
269-
getPointerProfile: () => {
270-
const cursor = inputAdapter.getMousePosition();
271-
const display = screen.getDisplayNearestPoint(cursor);
272-
return createPointerMovementProfile({
273-
cursor,
274-
display: {
275-
bounds: display.bounds,
276-
scaleFactor: display.scaleFactor
277-
}
264+
const pairingStore = new JsonPairingStore(join(app.getPath('userData'), 'pairing-state.json'));
265+
const cursorOverlaySettingsStore = new JsonCursorOverlaySettingsStore(
266+
join(app.getPath('userData'), 'cursor-overlay-settings.json')
267+
);
268+
const pairingManager = new PairingManager(pairingStore);
269+
const pairingApprovalManager = new PairingApprovalManager(pairingStore);
270+
const inputAdapter = new LibnutWin32InputAdapter((position) => screen.getDisplayNearestPoint(position).scaleFactor);
271+
cursorOverlay = new CursorOverlay({ settings: cursorOverlaySettingsStore.load() });
272+
const commandExecutor = new DesktopCommandExecutor(inputAdapter, cursorOverlay);
273+
releaseHeldMouseButtons = () => commandExecutor.releaseHeldMouseButtons();
274+
const releaseHeldMouseButtonsSafely = (): void => {
275+
void commandExecutor.releaseHeldMouseButtons().catch((error) => {
276+
console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.');
278277
});
279-
},
280-
onStatusChange: (status) => {
281-
if (status.connectedClientCount === 0) {
282-
releaseHeldMouseButtonsSafely();
283-
cursorOverlay?.endControlSession();
278+
};
279+
controlService = new ControlService({
280+
pairingManager,
281+
pairingApprovalManager,
282+
authValidator: new CommandAuthValidator(pairingStore),
283+
getPointerProfile: () => {
284+
const cursor = inputAdapter.getMousePosition();
285+
const display = screen.getDisplayNearestPoint(cursor);
286+
return createPointerMovementProfile({
287+
cursor,
288+
display: {
289+
bounds: display.bounds,
290+
scaleFactor: display.scaleFactor
291+
}
292+
});
293+
},
294+
onStatusChange: (status) => {
295+
if (status.connectedClientCount === 0) {
296+
releaseHeldMouseButtonsSafely();
297+
cursorOverlay?.endControlSession();
298+
}
299+
tray?.update();
300+
},
301+
onClientDisconnecting: (connectionId) => {
302+
bluetoothTransport?.markClientRequestedDisconnect(connectionId);
303+
},
304+
onCommand: (command) => commandExecutor.execute(command)
305+
});
306+
void pairingManager.getDesktopId().then((desktopId) => {
307+
controlService?.setDesktopId(desktopId);
308+
});
309+
bluetoothTransport = new WindowsBluetoothTransport({
310+
controlService,
311+
getDesktopId: () => pairingManager.getDesktopId(),
312+
displayName: 'Switchify PC',
313+
onStatusChange: () => {
314+
tray?.update();
284315
}
285-
tray?.update();
286-
},
287-
onClientDisconnecting: (connectionId) => {
288-
bluetoothTransport?.markClientRequestedDisconnect(connectionId);
289-
},
290-
onCommand: (command) => commandExecutor.execute(command)
291-
});
292-
void pairingManager.getDesktopId().then((desktopId) => {
293-
controlService?.setDesktopId(desktopId);
294-
});
295-
bluetoothTransport = new WindowsBluetoothTransport({
296-
controlService,
297-
getDesktopId: () => pairingManager.getDesktopId(),
298-
displayName: 'Switchify PC',
299-
onStatusChange: () => {
300-
tray?.update();
301-
}
302-
});
303-
registerServerIpc(controlService, pairingStore);
304-
registerCursorOverlayIpc(cursorOverlay, cursorOverlaySettingsStore);
305-
registerPairingApprovalIpc(controlService);
306-
registerSettingsWindowIpc(showSettingsWindow);
307-
registerAppWindowIpc();
308-
registerExternalUrlIpc();
309-
registerSystemStartupIpc(systemStartup);
310-
registerUpdateIpc(
311-
new UpdateService({
312-
currentVersion: app.getVersion(),
313-
isPackaged: app.isPackaged,
314-
platform: process.platform,
315-
autoUpdater
316-
})
317-
);
318-
void bluetoothTransport.start();
319-
320-
mainWindow = createMainWindow({ showOnReady: !startHidden });
321-
tray = createSwitchifyTray({
322-
getStatus: () =>
323-
controlService?.getStatus() ?? {
324-
state: 'stopped',
325-
desktopId: null,
326-
connectedClientCount: 0,
327-
connectedClients: [],
328-
lastSeenAt: null,
329-
lastError: null,
330-
bluetooth: DEFAULT_BLUETOOTH_STATUS
316+
});
317+
registerServerIpc(controlService, pairingStore);
318+
registerCursorOverlayIpc(cursorOverlay, cursorOverlaySettingsStore);
319+
registerPairingApprovalIpc(controlService);
320+
registerSettingsWindowIpc(showSettingsWindow);
321+
registerAppWindowIpc();
322+
registerExternalUrlIpc();
323+
registerSystemStartupIpc(systemStartup);
324+
registerUpdateIpc(
325+
new UpdateService({
326+
currentVersion: app.getVersion(),
327+
isPackaged: app.isPackaged,
328+
platform: process.platform,
329+
autoUpdater
330+
})
331+
);
332+
void bluetoothTransport.start();
333+
334+
mainWindow = createMainWindow({ showOnReady: !startHidden });
335+
tray = createSwitchifyTray({
336+
getStatus: () =>
337+
controlService?.getStatus() ?? {
338+
state: 'stopped',
339+
desktopId: null,
340+
connectedClientCount: 0,
341+
connectedClients: [],
342+
lastSeenAt: null,
343+
lastError: null,
344+
bluetooth: DEFAULT_BLUETOOTH_STATUS
345+
},
346+
showWindow: showMainWindow,
347+
openSettings: showSettingsWindow,
348+
disconnectClients: () => {
349+
releaseHeldMouseButtonsSafely();
350+
controlService?.disconnectClients();
351+
tray?.update();
331352
},
332-
showWindow: showMainWindow,
333-
openSettings: showSettingsWindow,
334-
disconnectClients: () => {
335-
releaseHeldMouseButtonsSafely();
336-
controlService?.disconnectClients();
337-
tray?.update();
338-
},
339-
quit: quitApp
353+
quit: quitApp
354+
});
355+
356+
app.on('activate', () => {
357+
showMainWindow();
358+
});
340359
});
341360

342-
app.on('activate', () => {
343-
showMainWindow();
361+
app.on('window-all-closed', () => {});
362+
363+
app.on('before-quit', (event) => {
364+
if (isQuitting) return;
365+
366+
event.preventDefault();
367+
isQuitting = true;
368+
void (releaseHeldMouseButtons?.() ?? Promise.resolve())
369+
.catch((error) => {
370+
console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.');
371+
})
372+
.then(() => {
373+
tray?.destroy();
374+
tray = null;
375+
cursorOverlay?.destroy();
376+
cursorOverlay = null;
377+
bluetoothTransport?.stop();
378+
bluetoothTransport = null;
379+
releaseHeldMouseButtons = null;
380+
controlService = null;
381+
})
382+
.finally(() => {
383+
app.quit();
384+
});
344385
});
345-
});
346-
347-
app.on('window-all-closed', () => {});
348-
349-
app.on('before-quit', (event) => {
350-
if (isQuitting) return;
351-
352-
event.preventDefault();
353-
isQuitting = true;
354-
void (releaseHeldMouseButtons?.() ?? Promise.resolve())
355-
.catch((error) => {
356-
console.warn(error instanceof Error ? error.message : 'Could not release held mouse buttons.');
357-
})
358-
.then(() => {
359-
tray?.destroy();
360-
tray = null;
361-
cursorOverlay?.destroy();
362-
cursorOverlay = null;
363-
bluetoothTransport?.stop();
364-
bluetoothTransport = null;
365-
releaseHeldMouseButtons = null;
366-
controlService = null;
367-
})
368-
.finally(() => {
369-
app.quit();
370-
});
371-
});
386+
}

src/main/single-instance.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { secondInstanceAction } from './single-instance';
3+
4+
describe('secondInstanceAction', () => {
5+
it('shows the main window for a normal Windows second launch', () => {
6+
expect(secondInstanceAction(['Switchify PC.exe'], 'win32')).toBe('showMainWindow');
7+
});
8+
9+
it('ignores Windows startup launches so they stay hidden', () => {
10+
expect(secondInstanceAction(['Switchify PC.exe', '--start-hidden'], 'win32')).toBe('ignore');
11+
});
12+
13+
it('does not suppress non-Windows launches that include the Windows startup flag', () => {
14+
expect(secondInstanceAction(['Switchify PC', '--start-hidden'], 'darwin')).toBe('showMainWindow');
15+
});
16+
});

src/main/single-instance.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { shouldStartHidden } from './system-startup';
2+
3+
export type SecondInstanceAction = 'showMainWindow' | 'ignore';
4+
5+
export function secondInstanceAction(argv: string[], platform: NodeJS.Platform): SecondInstanceAction {
6+
return shouldStartHidden(argv, platform) ? 'ignore' : 'showMainWindow';
7+
}

0 commit comments

Comments
 (0)