diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js index 2e6c92d..005894b 100644 --- a/src/desktop/single-instance.js +++ b/src/desktop/single-instance.js @@ -1,5 +1,8 @@ -const PROTOCOL_PREFIX = 'farmcontrol://' -const SINGLE_INSTANCE_HOST = '127.0.0.1' +import { mkdirSync, readFileSync, unlinkSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { getWindowsLaunchSources } from './windows-launch-args.js' + +const SINGLE_INSTANCE_HOSTS = ['127.0.0.1', 'localhost'] const SINGLE_INSTANCE_PATH = '/farmcontrol-instance' // Keep below Electrobun's RPC server range (50000+). @@ -14,6 +17,21 @@ function getSingleInstancePort() { return 30000 + (Math.abs(hash) % 10000) } +function getInstanceDir() { + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA + if (localAppData) { + return join(localAppData, 'com.tombutcher.farmcontrol', 'instance') + } + } + + return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance') +} + +function getPendingDeeplinkPath() { + return join(getInstanceDir(), 'pending-deeplink.json') +} + export function findProtocolUrl(args) { const sources = args.filter((arg) => typeof arg === 'string') @@ -42,11 +60,75 @@ export function findProtocolUrl(args) { return undefined } +function collectLaunchSources() { + const sources = [...process.argv] + + if (process.platform === 'win32') { + sources.push(...getWindowsLaunchSources()) + } + + return sources +} + +function getLaunchProtocolUrl() { + return findProtocolUrl(collectLaunchSources()) +} + function buildSecondInstanceMessage() { - const url = findProtocolUrl(process.argv) + const sources = collectLaunchSources() + const url = findProtocolUrl(sources) + return url - ? { type: 'deeplink', url, argv: process.argv } - : { type: 'focus', argv: process.argv } + ? { type: 'deeplink', url, argv: sources } + : { type: 'focus', argv: sources } +} + +function stagePendingDeeplink(message) { + const url = message?.url || findProtocolUrl(message?.argv || []) + if (!url) return + + try { + mkdirSync(getInstanceDir(), { recursive: true }) + writeFileSync( + getPendingDeeplinkPath(), + JSON.stringify({ url, timestamp: Date.now() }), + 'utf8' + ) + } catch (error) { + console.warn('Failed to stage pending deeplink:', error) + } +} + +function clearPendingDeeplinkFile() { + const filePath = getPendingDeeplinkPath() + if (!existsSync(filePath)) return + + try { + unlinkSync(filePath) + } catch { + // Ignore cleanup failures. + } +} + +function consumePendingDeeplinkFile() { + const filePath = getPendingDeeplinkPath() + if (!existsSync(filePath)) return null + + try { + const data = JSON.parse(readFileSync(filePath, 'utf8')) + clearPendingDeeplinkFile() + return data?.url || null + } catch { + clearPendingDeeplinkFile() + return null + } +} + +function processPendingDeeplinkFile() { + const url = consumePendingDeeplinkFile() + if (url) { + dispatchMessage({ type: 'deeplink', url }) + } } function resolveIncomingMessage(message) { @@ -89,25 +171,36 @@ function dispatchMessage(message) { } } -function getSingleInstanceUrl() { - return `http://${SINGLE_INSTANCE_HOST}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}` +function getSingleInstanceUrl(host) { + return `http://${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) - }) +async function tryForwardToPrimaryInstance(message) { + const body = JSON.stringify(message) - if (!response.ok) { - throw new Error(`Forward failed with status ${response.status}`) + for (const host of SINGLE_INSTANCE_HOSTS) { + try { + const response = await fetch(getSingleInstanceUrl(host), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: AbortSignal.timeout(1000) + }) + + if (response.ok) { + return true + } + } catch { + // Try the next host. + } } + + return false } function startPrimaryInstanceServer() { server = Bun.serve({ - hostname: SINGLE_INSTANCE_HOST, + hostname: '127.0.0.1', port: getSingleInstancePort(), fetch: async (req) => { const { pathname } = new URL(req.url) @@ -124,6 +217,14 @@ function startPrimaryInstanceServer() { } dispatchMessage(message) + + const resolved = resolveIncomingMessage(message) + if (resolved?.type === 'deeplink' && resolved.url) { + clearPendingDeeplinkFile() + } else { + processPendingDeeplinkFile() + } + return new Response('ok') } }) @@ -134,22 +235,25 @@ export async function ensureSingleInstanceLock() { return true } + const message = buildSecondInstanceMessage() + stagePendingDeeplink(message) + + if (await tryForwardToPrimaryInstance(message)) { + return false + } + try { startPrimaryInstanceServer() return true } catch (error) { - if (error?.code !== 'EADDRINUSE') { - console.warn('Single instance lock failed:', error) - return true + if (error?.code === 'EADDRINUSE') { + if (await tryForwardToPrimaryInstance(message)) { + return false + } } - try { - await forwardToPrimaryInstance(buildSecondInstanceMessage()) - } catch (forwardError) { - console.warn('Failed to forward to running instance:', forwardError) - } - - return false + console.warn('Single instance lock failed:', error) + return true } } @@ -159,6 +263,8 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) { while (pendingMessages.length > 0) { dispatchMessage(pendingMessages.shift()) } + + processPendingDeeplinkFile() } export function closeSingleInstanceServer() { diff --git a/src/desktop/window.js b/src/desktop/window.js index 8ce50d0..9ad7888 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -11,6 +11,7 @@ const isMacOS = process.platform === 'darwin' const DEV_SERVER_PORT = 5780 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` import { findProtocolUrl } from './single-instance.js' +import { getWindowsLaunchSources } from './windows-launch-args.js' let mainWindow = null let webviewDomReady = false @@ -84,7 +85,13 @@ export function handleDeepLink(url) { export function handleDeepLinkFromArgv() { if (process.platform === 'darwin') return - const url = findProtocolUrl(process.argv) + + const sources = [...process.argv] + if (process.platform === 'win32') { + sources.push(...getWindowsLaunchSources()) + } + + const url = findProtocolUrl(sources) if (url) handleDeepLink(url) } diff --git a/src/desktop/windows-launch-args.js b/src/desktop/windows-launch-args.js new file mode 100644 index 0000000..424708d --- /dev/null +++ b/src/desktop/windows-launch-args.js @@ -0,0 +1,39 @@ +export function getWindowsLaunchSources() { + if (process.platform !== 'win32') { + return [] + } + + try { + const proc = Bun.spawnSync({ + cmd: [ + 'powershell.exe', + '-NoProfile', + '-NonInteractive', + '-Command', + `$pid = $PID +for ($i = 0; $i -lt 6; $i++) { + $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue + if (-not $proc) { break } + if ($proc.CommandLine) { $proc.CommandLine } + if ($proc.CommandLine -match 'farmcontrol://') { break } + if (-not $proc.ParentProcessId) { break } + $pid = $proc.ParentProcessId +}` + ], + stdout: 'pipe', + stderr: 'pipe' + }) + + if (proc.exitCode !== 0) { + return [] + } + + return proc.stdout + .toString() + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + } catch { + return [] + } +}