diff --git a/src/bun/index.js b/src/bun/index.js index 6bbdf99..d0b4ad3 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -1,30 +1,34 @@ -import { createAppRpc } from '../desktop/rpc.js' import { closeSingleInstanceServer, - ensureSingleInstanceLock + ensureSingleInstanceLock, + setSingleInstanceHandlers } from '../desktop/single-instance.js' -import { + +const gotSingleInstanceLock = await ensureSingleInstanceLock() + +if (!gotSingleInstanceLock) { + process.exit(0) +} + +const { createAppRpc } = await import('../desktop/rpc.js') +const { registerGlobalShortcuts, unregisterGlobalShortcuts -} from '../desktop/spotlight.js' -import { +} = await import('../desktop/spotlight.js') +const { createMainWindow, handleDeepLink, handleDeepLinkFromArgv, setupDevAuthServer, setupNavigationGestures, showMainWindow -} from '../desktop/window.js' +} = await import('../desktop/window.js') -const gotSingleInstanceLock = await ensureSingleInstanceLock({ +setSingleInstanceHandlers({ onDeepLink: handleDeepLink, onFocus: showMainWindow }) -if (!gotSingleInstanceLock) { - process.exit(0) -} - const rpc = createAppRpc() const mainWindow = await createMainWindow(rpc) diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js index 13ad9f5..2e6c92d 100644 --- a/src/desktop/single-instance.js +++ b/src/desktop/single-instance.js @@ -1,7 +1,8 @@ const PROTOCOL_PREFIX = 'farmcontrol://' const SINGLE_INSTANCE_HOST = '127.0.0.1' +const SINGLE_INSTANCE_PATH = '/farmcontrol-instance' -// Stable port derived from the app identifier so multiple apps don't collide. +// Keep below Electrobun's RPC server range (50000+). function getSingleInstancePort() { const identifier = 'com.tombutcher.farmcontrol' let hash = 0 @@ -10,84 +11,131 @@ function getSingleInstancePort() { hash = (hash * 31 + char.charCodeAt(0)) | 0 } - return 49152 + (Math.abs(hash) % 16383) + return 30000 + (Math.abs(hash) % 10000) } -function findProtocolUrl(args) { - return args.find( - (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) - ) +export function findProtocolUrl(args) { + const sources = args.filter((arg) => typeof arg === 'string') + + for (const arg of sources) { + const trimmed = arg.trim().replace(/^['"]+|['"]+$/g, '') + const match = trimmed.match(/farmcontrol:\/\/\S+/i) + if (match) { + try { + return decodeURI(match[0]) + } catch { + return match[0] + } + } + } + + const combined = sources.join(' ') + const combinedMatch = combined.match(/farmcontrol:\/\/\S+/i) + if (combinedMatch) { + try { + return decodeURI(combinedMatch[0]) + } catch { + return combinedMatch[0] + } + } + + return undefined } function buildSecondInstanceMessage() { const url = findProtocolUrl(process.argv) - return url ? { type: 'deeplink', url } : { type: 'focus' } + return url + ? { type: 'deeplink', url, argv: process.argv } + : { type: 'focus', argv: process.argv } } -function parseIncomingMessage(data) { - const text = data.toString().trim() - if (!text) return null - - try { - return JSON.parse(text) - } catch { +function resolveIncomingMessage(message) { + if (!message || typeof message !== 'object') { 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) - }) + if (message.type === 'deeplink') { + const url = message.url || findProtocolUrl(message.argv || []) + return url ? { type: 'deeplink', url } : { type: 'focus' } + } + + if (message.type === 'focus') { + return { type: 'focus' } + } + + return null } let server = null +let handlers = { + onDeepLink: null, + onFocus: null +} +const pendingMessages = [] -function startPrimaryInstanceServer({ onDeepLink, onFocus }) { - server = Bun.listen({ +function dispatchMessage(message) { + const resolved = resolveIncomingMessage(message) + if (!resolved) return + + if (!handlers.onDeepLink && !handlers.onFocus) { + pendingMessages.push(resolved) + return + } + + if (resolved.type === 'deeplink' && resolved.url) { + handlers.onDeepLink?.(resolved.url) + } else if (resolved.type === 'focus') { + handlers.onFocus?.() + } +} + +function getSingleInstanceUrl() { + return `http://${SINGLE_INSTANCE_HOST}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}` +} + +async function forwardToPrimaryInstance(message) { + const response = await fetch(getSingleInstanceUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(message) + }) + + if (!response.ok) { + throw new Error(`Forward failed with status ${response.status}`) + } +} + +function startPrimaryInstanceServer() { + server = Bun.serve({ hostname: SINGLE_INSTANCE_HOST, port: getSingleInstancePort(), - socket: { - data(socket, data) { - const message = parseIncomingMessage(data) - socket.end() + fetch: async (req) => { + const { pathname } = new URL(req.url) - if (!message) return - - if (message.type === 'deeplink' && message.url) { - onDeepLink?.(message.url) - } else if (message.type === 'focus') { - onFocus?.() - } + if (req.method !== 'POST' || pathname !== SINGLE_INSTANCE_PATH) { + return new Response('Not found', { status: 404 }) } + + let message + try { + message = await req.json() + } catch { + return new Response('Bad request', { status: 400 }) + } + + dispatchMessage(message) + return new Response('ok') } }) } -export async function ensureSingleInstanceLock({ onDeepLink, onFocus }) { +export async function ensureSingleInstanceLock() { if (process.platform === 'darwin') { return true } try { - startPrimaryInstanceServer({ onDeepLink, onFocus }) + startPrimaryInstanceServer() return true } catch (error) { if (error?.code !== 'EADDRINUSE') { @@ -105,6 +153,14 @@ export async function ensureSingleInstanceLock({ onDeepLink, onFocus }) { } } +export function setSingleInstanceHandlers({ onDeepLink, onFocus }) { + handlers = { onDeepLink, onFocus } + + while (pendingMessages.length > 0) { + dispatchMessage(pendingMessages.shift()) + } +} + export function closeSingleInstanceServer() { server?.stop?.() server = null diff --git a/src/desktop/window.js b/src/desktop/window.js index 8d00a9b..8ce50d0 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -10,7 +10,7 @@ const isMacOS = process.platform === 'darwin' const DEV_SERVER_PORT = 5780 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` -const PROTOCOL_PREFIX = 'farmcontrol://' +import { findProtocolUrl } from './single-instance.js' let mainWindow = null let webviewDomReady = false @@ -73,15 +73,13 @@ function sendNavigateToRenderer(redirectPath) { } export function handleDeepLink(url) { - if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return - const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/' - sendNavigateToRenderer(redirectPath) -} + if (!url || typeof url !== 'string') return -function findProtocolUrl(args) { - return args.find( - (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) - ) + const match = url.match(/^farmcontrol:\/\/app(.*)$/i) + if (!match) return + + const redirectPath = match[1] || '/' + sendNavigateToRenderer(redirectPath.startsWith('/') ? redirectPath : `/${redirectPath}`) } export function handleDeepLinkFromArgv() {