From 611cd4aad523666d6c5f0cecae5f1050e93ee69c Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 2 Aug 2026 16:25:09 +0100 Subject: [PATCH] Implement single instance management and enhance deep link handling - Added single instance server functionality to prevent multiple app instances. - Introduced `ensureSingleInstanceLock` and `closeSingleInstanceServer` methods for managing instance locks. - Updated main application entry point to handle deep links and focus events. - Enhanced window management by showing the main window on focus and handling deep links appropriately. --- src/bun/index.js | 18 +++++- src/desktop/single-instance.js | 111 +++++++++++++++++++++++++++++++++ src/desktop/window.js | 5 ++ 3 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/desktop/single-instance.js diff --git a/src/bun/index.js b/src/bun/index.js index eb827ca..6bbdf99 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -1,15 +1,30 @@ import { createAppRpc } from '../desktop/rpc.js' +import { + closeSingleInstanceServer, + ensureSingleInstanceLock +} from '../desktop/single-instance.js' import { registerGlobalShortcuts, unregisterGlobalShortcuts } from '../desktop/spotlight.js' import { createMainWindow, + handleDeepLink, handleDeepLinkFromArgv, setupDevAuthServer, - setupNavigationGestures + setupNavigationGestures, + showMainWindow } from '../desktop/window.js' +const gotSingleInstanceLock = await ensureSingleInstanceLock({ + onDeepLink: handleDeepLink, + onFocus: showMainWindow +}) + +if (!gotSingleInstanceLock) { + process.exit(0) +} + const rpc = createAppRpc() const mainWindow = await createMainWindow(rpc) @@ -20,4 +35,5 @@ handleDeepLinkFromArgv() process.on('exit', () => { unregisterGlobalShortcuts() + closeSingleInstanceServer() }) diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js new file mode 100644 index 0000000..13ad9f5 --- /dev/null +++ b/src/desktop/single-instance.js @@ -0,0 +1,111 @@ +const PROTOCOL_PREFIX = 'farmcontrol://' +const SINGLE_INSTANCE_HOST = '127.0.0.1' + +// Stable port derived from the app identifier so multiple apps don't collide. +function getSingleInstancePort() { + const identifier = 'com.tombutcher.farmcontrol' + let hash = 0 + + for (const char of identifier) { + hash = (hash * 31 + char.charCodeAt(0)) | 0 + } + + return 49152 + (Math.abs(hash) % 16383) +} + +function findProtocolUrl(args) { + return args.find( + (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) + ) +} + +function buildSecondInstanceMessage() { + const url = findProtocolUrl(process.argv) + return url ? { type: 'deeplink', url } : { type: 'focus' } +} + +function parseIncomingMessage(data) { + const text = data.toString().trim() + if (!text) return null + + try { + return JSON.parse(text) + } catch { + return null + } +} + +async function forwardToPrimaryInstance(message) { + await new Promise((resolve, reject) => { + Bun.connect({ + hostname: SINGLE_INSTANCE_HOST, + port: getSingleInstancePort(), + socket: { + open(socket) { + socket.write(`${JSON.stringify(message)}\n`) + socket.end() + resolve() + }, + data() {}, + error(_socket, error) { + reject(error) + }, + close() { + resolve() + } + } + }).catch(reject) + }) +} + +let server = null + +function startPrimaryInstanceServer({ onDeepLink, onFocus }) { + server = Bun.listen({ + hostname: SINGLE_INSTANCE_HOST, + port: getSingleInstancePort(), + socket: { + data(socket, data) { + const message = parseIncomingMessage(data) + socket.end() + + if (!message) return + + if (message.type === 'deeplink' && message.url) { + onDeepLink?.(message.url) + } else if (message.type === 'focus') { + onFocus?.() + } + } + } + }) +} + +export async function ensureSingleInstanceLock({ onDeepLink, onFocus }) { + if (process.platform === 'darwin') { + return true + } + + try { + startPrimaryInstanceServer({ onDeepLink, onFocus }) + return true + } catch (error) { + if (error?.code !== 'EADDRINUSE') { + console.warn('Single instance lock failed:', error) + return true + } + + try { + await forwardToPrimaryInstance(buildSecondInstanceMessage()) + } catch (forwardError) { + console.warn('Failed to forward to running instance:', forwardError) + } + + return false + } +} + +export function closeSingleInstanceServer() { + server?.stop?.() + server = null +} diff --git a/src/desktop/window.js b/src/desktop/window.js index 8941bda..ab828b8 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -20,6 +20,11 @@ export function getMainWindow() { return mainWindow } +export function showMainWindow() { + mainWindow?.show?.() + mainWindow?.activate?.() +} + export async function getMainViewUrl() { const channel = await Updater.localInfo.channel() if (channel === 'dev' || process.env.NODE_ENV === 'development') {