diff --git a/src/bun/index.js b/src/bun/index.js index 215d209..0dc587f 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -1,9 +1,11 @@ import { + captureLaunchUrl, closeSingleInstanceServer, ensureSingleInstanceLock } from '../desktop/single-instance.js' -const gotSingleInstanceLock = await ensureSingleInstanceLock() +const launchUrl = captureLaunchUrl() +const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl }) if (!gotSingleInstanceLock) { process.exit(0) @@ -22,14 +24,15 @@ const { setupWindowsDeepLinkHandling } = await import('../desktop/window.js') +setupWindowsDeepLinkHandling() + const rpc = createAppRpc() const mainWindow = await createMainWindow(rpc) -setupWindowsDeepLinkHandling() setupNavigationGestures(mainWindow) registerGlobalShortcuts(rpc) setupDevAuthServer() -handleDeepLinkFromArgv() +handleDeepLinkFromArgv(launchUrl) process.on('exit', () => { unregisterGlobalShortcuts() diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js index e890314..fc30bd7 100644 --- a/src/desktop/single-instance.js +++ b/src/desktop/single-instance.js @@ -9,7 +9,10 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' -import { getWindowsLaunchSources } from './windows-launch-args.js' +import { + getWindowsLaunchSources, + getWindowsLaunchUrl +} from './windows-launch-args.js' const LOCK_FILE = 'primary.lock' const SIGNAL_FILE = 'instance-signal.json' @@ -147,45 +150,67 @@ function collectLaunchSources() { return sources } -function buildLaunchPayload() { +export function captureLaunchUrl() { + if (process.platform === 'win32') { + return getWindowsLaunchUrl() || findProtocolUrl(collectLaunchSources()) + } + + return findProtocolUrl(collectLaunchSources()) +} + +function buildLaunchPayload(launchUrl = captureLaunchUrl()) { const sources = collectLaunchSources() - const url = findProtocolUrl(sources) + const url = launchUrl || findProtocolUrl(sources) return url ? { type: 'deeplink', url, argv: sources } : { type: 'focus', argv: sources } } +function readSignalQueue({ clear = true } = {}) { + const signalPath = getSignalPath() + if (!existsSync(signalPath)) { + return [] + } + + try { + const raw = readFileSync(signalPath, 'utf8') + const parsed = JSON.parse(raw) + + if (clear) { + unlinkSync(signalPath) + } + + if (Array.isArray(parsed)) { + return parsed + } + + return parsed ? [parsed] : [] + } catch { + if (clear) { + try { + unlinkSync(signalPath) + } catch { + // Ignore cleanup failures. + } + } + return [] + } +} + function writeInstanceSignal(payload) { const signalPath = getSignalPath() mkdirSync(getInstanceDir(), { recursive: true }) + const queue = readSignalQueue({ clear: false }) + queue.push({ ...payload, timestamp: Date.now() }) + const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp` - writeFileSync(tempPath, JSON.stringify({ ...payload, timestamp: Date.now() }), 'utf8') + writeFileSync(tempPath, JSON.stringify(queue), 'utf8') writeFileSync(signalPath, readFileSync(tempPath)) unlinkSync(tempPath) } -function readAndClearInstanceSignal() { - const signalPath = getSignalPath() - if (!existsSync(signalPath)) { - return null - } - - try { - const payload = JSON.parse(readFileSync(signalPath, 'utf8')) - unlinkSync(signalPath) - return payload - } catch { - try { - unlinkSync(signalPath) - } catch { - // Ignore cleanup failures. - } - return null - } -} - function resolveIncomingMessage(message) { if (!message || typeof message !== 'object') { return null @@ -222,8 +247,10 @@ function dispatchMessage(message) { } } -function processInstanceSignal() { - dispatchMessage(readAndClearInstanceSignal()) +function processInstanceSignals() { + for (const message of readSignalQueue({ clear: true })) { + dispatchMessage(message) + } } function startInstanceSignalWatcher() { @@ -232,22 +259,22 @@ function startInstanceSignalWatcher() { } mkdirSync(getInstanceDir(), { recursive: true }) - processInstanceSignal() + processInstanceSignals() try { fsWatcher = watch(getInstanceDir(), (_event, filename) => { if (!filename || filename === SIGNAL_FILE) { - setTimeout(processInstanceSignal, 25) + setTimeout(processInstanceSignals, 25) } }) } catch { // Polling below covers filesystems without reliable watch support. } - pollInterval = setInterval(processInstanceSignal, 100) + pollInterval = setInterval(processInstanceSignals, 100) } -export async function ensureSingleInstanceLock() { +export async function ensureSingleInstanceLock({ launchUrl } = {}) { if (process.platform === 'darwin') { return true } @@ -257,7 +284,7 @@ export async function ensureSingleInstanceLock() { return true } - writeInstanceSignal(buildLaunchPayload()) + writeInstanceSignal(buildLaunchPayload(launchUrl)) return false } @@ -268,7 +295,7 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) { dispatchMessage(pendingMessages.shift()) } - processInstanceSignal() + processInstanceSignals() } export function closeSingleInstanceServer() { diff --git a/src/desktop/window.js b/src/desktop/window.js index 59b5009..1fe2733 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -10,8 +10,7 @@ const isMacOS = process.platform === 'darwin' const DEV_SERVER_PORT = 5780 const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` -import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js' -import { getWindowsLaunchSources } from './windows-launch-args.js' +import { captureLaunchUrl, setSingleInstanceHandlers } from './single-instance.js' let mainWindow = null let webviewDomReady = false @@ -128,16 +127,12 @@ export function handleDeepLink(url) { return true } -export function handleDeepLinkFromArgv() { +export function handleDeepLinkFromArgv(launchUrl = captureLaunchUrl()) { if (process.platform === 'darwin') return - const sources = [...process.argv] - if (process.platform === 'win32') { - sources.push(...getWindowsLaunchSources()) + if (launchUrl) { + handleDeepLink(launchUrl) } - - const url = findProtocolUrl(sources) - if (url) handleDeepLink(url) } function broadcastWindowState() { diff --git a/src/desktop/windows-launch-args.js b/src/desktop/windows-launch-args.js index 2f45a6c..3fab7c1 100644 --- a/src/desktop/windows-launch-args.js +++ b/src/desktop/windows-launch-args.js @@ -1,9 +1,44 @@ +function hasProtocolUrl(sources) { + return sources.some( + (source) => + typeof source === 'string' && /farmcontrol:\/\//i.test(source) + ) +} + +function runPowerShell(script) { + try { + const proc = Bun.spawnSync({ + cmd: [ + 'powershell.exe', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', + 'Bypass', + '-Command', + script + ], + stdout: 'pipe', + stderr: 'pipe', + windowsHide: true + }) + + if (proc.exitCode !== 0) { + return '' + } + + return proc.stdout.toString().trim() + } catch { + return '' + } +} + function runCommand(command) { try { const proc = Bun.spawnSync({ cmd: ['cmd.exe', '/c', command], stdout: 'pipe', - stderr: 'pipe' + stderr: 'pipe', + windowsHide: true }) if (proc.exitCode !== 0) { @@ -36,51 +71,38 @@ function getParentProcessId(pid) { return parentPid ? Number.parseInt(parentPid, 10) : null } -function getPowerShellCommandLines() { - try { - const proc = Bun.spawnSync({ - cmd: [ - 'powershell.exe', - '-NoProfile', - '-NonInteractive', - '-Command', - `$pid = $PID -for ($i = 0; $i -lt 6; $i++) { +function getPowerShellProcessTreeCommandLines() { + const output = runPowerShell(` +$pid = $PID +$lines = New-Object System.Collections.Generic.List[string] +for ($i = 0; $i -lt 12; $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 } + if ($proc.CommandLine) { + $lines.Add([string]$proc.CommandLine) + if ($proc.CommandLine -match 'farmcontrol://') { break } + } + if (-not $proc.ParentProcessId -or $proc.ParentProcessId -eq $pid) { break } $pid = $proc.ParentProcessId -}` - ], - stdout: 'pipe', - stderr: 'pipe' - }) +} +$lines -join [Environment]::NewLine +`) - if (proc.exitCode !== 0) { - return [] - } - - return proc.stdout - .toString() - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - } catch { + if (!output) { return [] } + + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) } -export function getWindowsLaunchSources() { - if (process.platform !== 'win32') { - return [] - } - +function getWmicProcessTreeCommandLines() { const sources = [] let pid = process.pid - for (let depth = 0; depth < 6; depth += 1) { + for (let depth = 0; depth < 12; depth += 1) { const commandLine = getProcessCommandLine(pid) if (commandLine) { sources.push(commandLine) @@ -97,9 +119,69 @@ export function getWindowsLaunchSources() { pid = parentPid } - if (sources.length === 0) { - return getPowerShellCommandLines() - } - return sources } + +function getEnvironmentLaunchSources() { + return Object.values(process.env).filter( + (value) => typeof value === 'string' && /farmcontrol:\/\//i.test(value) + ) +} + +export function getWindowsLaunchSources() { + if (process.platform !== 'win32') { + return [] + } + + const sources = new Set([ + ...process.argv, + ...getEnvironmentLaunchSources() + ]) + + for (const commandLine of getPowerShellProcessTreeCommandLines()) { + sources.add(commandLine) + } + + if (!hasProtocolUrl([...sources])) { + for (const commandLine of getWmicProcessTreeCommandLines()) { + sources.add(commandLine) + } + } + + return [...sources] +} + +export function getWindowsLaunchUrl() { + if (process.platform !== 'win32') { + return undefined + } + + const sources = getWindowsLaunchSources() + + for (const source of sources) { + if (typeof source !== 'string') continue + + const trimmed = source.trim().replace(/^['"]+|['"]+$/g, '') + const match = trimmed.match(/farmcontrol:\/\/\S+/i) + if (match) { + const rawUrl = match[0].replace(/['"]+$/g, '') + try { + return decodeURI(rawUrl) + } catch { + return rawUrl + } + } + } + + const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i) + if (combinedMatch) { + const rawUrl = combinedMatch[0].replace(/['"]+$/g, '') + try { + return decodeURI(rawUrl) + } catch { + return rawUrl + } + } + + return undefined +}