diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js index 005894b..fedbaee 100644 --- a/src/desktop/single-instance.js +++ b/src/desktop/single-instance.js @@ -1,21 +1,27 @@ -import { mkdirSync, readFileSync, unlinkSync, writeFileSync, existsSync } from 'node:fs' +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + watch, + writeFileSync +} 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' +const LOCK_FILE = 'primary.lock' +const SIGNAL_FILE = 'instance-signal.json' -// Keep below Electrobun's RPC server range (50000+). -function getSingleInstancePort() { - const identifier = 'com.tombutcher.farmcontrol' - let hash = 0 - - for (const char of identifier) { - hash = (hash * 31 + char.charCodeAt(0)) | 0 - } - - return 30000 + (Math.abs(hash) % 10000) +let lockFd = null +let pollInterval = null +let fsWatcher = null +let handlers = { + onDeepLink: null, + onFocus: null } +const pendingMessages = [] function getInstanceDir() { if (process.platform === 'win32') { @@ -28,8 +34,77 @@ function getInstanceDir() { return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance') } -function getPendingDeeplinkPath() { - return join(getInstanceDir(), 'pending-deeplink.json') +function getLockPath() { + return join(getInstanceDir(), LOCK_FILE) +} + +function getSignalPath() { + return join(getInstanceDir(), SIGNAL_FILE) +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + + try { + process.kill(pid, 0) + return true + } catch { + return false + } +} + +function tryAcquirePrimaryLock() { + mkdirSync(getInstanceDir(), { recursive: true }) + const lockPath = getLockPath() + + if (existsSync(lockPath)) { + try { + const existingPid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10) + + if (existingPid === process.pid) { + return true + } + + if (isProcessAlive(existingPid)) { + return false + } + + unlinkSync(lockPath) + } catch { + try { + unlinkSync(lockPath) + } catch { + // Ignore cleanup failures. + } + } + } + + try { + lockFd = openSync(lockPath, 'wx') + writeFileSync(lockFd, String(process.pid)) + return true + } catch { + return false + } +} + +function releasePrimaryLock() { + if (lockFd !== null) { + try { + closeSync(lockFd) + } catch { + // Ignore close failures. + } + lockFd = null + } + + try { + unlinkSync(getLockPath()) + } catch { + // Ignore cleanup failures. + } } export function findProtocolUrl(args) { @@ -70,11 +145,7 @@ function collectLaunchSources() { return sources } -function getLaunchProtocolUrl() { - return findProtocolUrl(collectLaunchSources()) -} - -function buildSecondInstanceMessage() { +function buildLaunchPayload() { const sources = collectLaunchSources() const url = findProtocolUrl(sources) @@ -83,51 +154,33 @@ function buildSecondInstanceMessage() { : { type: 'focus', argv: sources } } -function stagePendingDeeplink(message) { - const url = message?.url || findProtocolUrl(message?.argv || []) - if (!url) return +function writeInstanceSignal(payload) { + const signalPath = getSignalPath() + mkdirSync(getInstanceDir(), { recursive: true }) - try { - mkdirSync(getInstanceDir(), { recursive: true }) - writeFileSync( - getPendingDeeplinkPath(), - JSON.stringify({ url, timestamp: Date.now() }), - 'utf8' - ) - } catch (error) { - console.warn('Failed to stage pending deeplink:', error) - } + const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp` + writeFileSync(tempPath, JSON.stringify({ ...payload, timestamp: Date.now() }), 'utf8') + writeFileSync(signalPath, readFileSync(tempPath)) + unlinkSync(tempPath) } -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() +function readAndClearInstanceSignal() { + const signalPath = getSignalPath() + if (!existsSync(signalPath)) { return null } -} -function processPendingDeeplinkFile() { - const url = consumePendingDeeplinkFile() - if (url) { - dispatchMessage({ type: 'deeplink', url }) + try { + const payload = JSON.parse(readFileSync(signalPath, 'utf8')) + unlinkSync(signalPath) + return payload + } catch { + try { + unlinkSync(signalPath) + } catch { + // Ignore cleanup failures. + } + return null } } @@ -148,13 +201,6 @@ function resolveIncomingMessage(message) { return null } -let server = null -let handlers = { - onDeepLink: null, - onFocus: null -} -const pendingMessages = [] - function dispatchMessage(message) { const resolved = resolveIncomingMessage(message) if (!resolved) return @@ -166,68 +212,38 @@ function dispatchMessage(message) { if (resolved.type === 'deeplink' && resolved.url) { handlers.onDeepLink?.(resolved.url) - } else if (resolved.type === 'focus') { + handlers.onFocus?.() + return + } + + if (resolved.type === 'focus') { handlers.onFocus?.() } } -function getSingleInstanceUrl(host) { - return `http://${host}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}` +function processInstanceSignal() { + dispatchMessage(readAndClearInstanceSignal()) } -async function tryForwardToPrimaryInstance(message) { - const body = JSON.stringify(message) - - 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. - } +function startInstanceSignalWatcher() { + if (process.platform === 'darwin' || pollInterval) { + return } - return false -} + mkdirSync(getInstanceDir(), { recursive: true }) + processInstanceSignal() -function startPrimaryInstanceServer() { - server = Bun.serve({ - hostname: '127.0.0.1', - port: getSingleInstancePort(), - fetch: async (req) => { - const { pathname } = new URL(req.url) - - if (req.method !== 'POST' || pathname !== SINGLE_INSTANCE_PATH) { - return new Response('Not found', { status: 404 }) + try { + fsWatcher = watch(getInstanceDir(), (_event, filename) => { + if (!filename || filename === SIGNAL_FILE) { + setTimeout(processInstanceSignal, 25) } + }) + } catch { + // Polling below covers filesystems without reliable watch support. + } - let message - try { - message = await req.json() - } catch { - return new Response('Bad request', { status: 400 }) - } - - dispatchMessage(message) - - const resolved = resolveIncomingMessage(message) - if (resolved?.type === 'deeplink' && resolved.url) { - clearPendingDeeplinkFile() - } else { - processPendingDeeplinkFile() - } - - return new Response('ok') - } - }) + pollInterval = setInterval(processInstanceSignal, 250) } export async function ensureSingleInstanceLock() { @@ -235,39 +251,39 @@ 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') { - if (await tryForwardToPrimaryInstance(message)) { - return false - } - } - - console.warn('Single instance lock failed:', error) + if (tryAcquirePrimaryLock()) { return true } + + writeInstanceSignal(buildLaunchPayload()) + return false } export function setSingleInstanceHandlers({ onDeepLink, onFocus }) { handlers = { onDeepLink, onFocus } + startInstanceSignalWatcher() while (pendingMessages.length > 0) { dispatchMessage(pendingMessages.shift()) } - processPendingDeeplinkFile() + processInstanceSignal() } export function closeSingleInstanceServer() { - server?.stop?.() - server = null + if (pollInterval) { + clearInterval(pollInterval) + pollInterval = null + } + + if (fsWatcher) { + try { + fsWatcher.close() + } catch { + // Ignore close failures. + } + fsWatcher = null + } + + releasePrimaryLock() } diff --git a/src/desktop/window.js b/src/desktop/window.js index 9ad7888..ebdedaa 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -22,8 +22,14 @@ export function getMainWindow() { } export function showMainWindow() { - mainWindow?.show?.() - mainWindow?.activate?.() + if (!mainWindow) return + + if (mainWindow.isMinimized?.()) { + mainWindow.restore?.() + } + + mainWindow.show?.() + mainWindow.activate?.() } export async function getMainViewUrl() { @@ -45,8 +51,15 @@ export async function getMainViewUrl() { function deliverNavigation(redirectPath) { sendToRenderer('navigate', redirectPath) - mainWindow?.show?.() - mainWindow?.activate?.() + + if (!mainWindow) return + + if (mainWindow.isMinimized?.()) { + mainWindow.restore?.() + } + + mainWindow.show?.() + mainWindow.activate?.() } function flushPendingNavigations() { diff --git a/src/desktop/windows-launch-args.js b/src/desktop/windows-launch-args.js index 424708d..2f45a6c 100644 --- a/src/desktop/windows-launch-args.js +++ b/src/desktop/windows-launch-args.js @@ -1,8 +1,42 @@ -export function getWindowsLaunchSources() { - if (process.platform !== 'win32') { - return [] - } +function runCommand(command) { + try { + const proc = Bun.spawnSync({ + cmd: ['cmd.exe', '/c', command], + stdout: 'pipe', + stderr: 'pipe' + }) + if (proc.exitCode !== 0) { + return '' + } + + return proc.stdout.toString() + } catch { + return '' + } +} + +function getWmicValue(output, key) { + const match = output.match(new RegExp(`${key}=(.+?)(?:\\r?\\n|$)`, 'i')) + return match?.[1]?.trim() ?? '' +} + +function getProcessCommandLine(pid) { + const output = runCommand( + `wmic process where "ProcessId=${pid}" get CommandLine /value` + ) + return getWmicValue(output, 'CommandLine') +} + +function getParentProcessId(pid) { + const output = runCommand( + `wmic process where "ProcessId=${pid}" get ParentProcessId /value` + ) + const parentPid = getWmicValue(output, 'ParentProcessId') + return parentPid ? Number.parseInt(parentPid, 10) : null +} + +function getPowerShellCommandLines() { try { const proc = Bun.spawnSync({ cmd: [ @@ -37,3 +71,35 @@ for ($i = 0; $i -lt 6; $i++) { return [] } } + +export function getWindowsLaunchSources() { + if (process.platform !== 'win32') { + return [] + } + + const sources = [] + let pid = process.pid + + for (let depth = 0; depth < 6; depth += 1) { + const commandLine = getProcessCommandLine(pid) + if (commandLine) { + sources.push(commandLine) + if (/farmcontrol:\/\//i.test(commandLine)) { + return sources + } + } + + const parentPid = getParentProcessId(pid) + if (!parentPid || parentPid === pid) { + break + } + + pid = parentPid + } + + if (sources.length === 0) { + return getPowerShellCommandLines() + } + + return sources +}