diff --git a/packaging/windows/installer.nsh b/packaging/windows/installer.nsh index aec86f5..d205edd 100644 --- a/packaging/windows/installer.nsh +++ b/packaging/windows/installer.nsh @@ -1,4 +1,4 @@ -!macro quitFarmControl +!macro quitFarmControl DetailPrint "Stopping running Farm Control processes..." ExecWait 'taskkill /F /IM launcher.exe /T' $R0 !macroend @@ -51,10 +51,10 @@ DeleteRegKey HKCR "farmcontrol" WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol" WriteRegStr HKCR "farmcontrol" "URL Protocol" "" - WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\bin\launcher.exe" + WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\bin\deeplink.exe" WriteRegStr HKCR "farmcontrol\shell" "" "" WriteRegStr HKCR "farmcontrol\shell\Open" "" "" - WriteRegStr HKCR "farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\launcher.exe" "%1"' + WriteRegStr HKCR "farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\deeplink.exe" "%1"' !macroend !macro customUnInstall diff --git a/scripts/build-windows-deeplink.mjs b/scripts/build-windows-deeplink.mjs new file mode 100644 index 0000000..d5cf304 --- /dev/null +++ b/scripts/build-windows-deeplink.mjs @@ -0,0 +1,60 @@ +import { existsSync, mkdirSync } from 'node:fs' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const entrypoint = path.join(rootDir, 'src/bun/deeplink.js') + +export function buildWindowsDeeplinkExe(outputPath) { + if (!existsSync(entrypoint)) { + throw new Error(`build-windows-deeplink: entrypoint not found: ${entrypoint}`) + } + + mkdirSync(path.dirname(outputPath), { recursive: true }) + + const target = process.env.ELECTROBUN_ARCH === 'arm64' + ? 'bun-windows-x64' + : 'bun-windows-x64' + + const result = spawnSync( + 'bun', + [ + 'build', + '--compile', + '--minify', + `--target=${target}`, + entrypoint, + '--outfile', + outputPath + ], + { + cwd: rootDir, + stdio: 'inherit', + env: process.env + } + ) + + if (result.status !== 0) { + throw new Error( + `build-windows-deeplink: bun build failed with exit code ${result.status ?? 1}` + ) + } + + if (!existsSync(outputPath)) { + throw new Error(`build-windows-deeplink: output not created: ${outputPath}`) + } + + console.log(`build-windows-deeplink: created ${outputPath}`) + return outputPath +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const outputArg = process.argv[2] + if (!outputArg) { + console.error('Usage: bun scripts/build-windows-deeplink.mjs ') + process.exit(1) + } + + buildWindowsDeeplinkExe(path.resolve(outputArg)) +} diff --git a/scripts/build-windows-nsis.ps1 b/scripts/build-windows-nsis.ps1 index 222345c..6bcfdea 100644 --- a/scripts/build-windows-nsis.ps1 +++ b/scripts/build-windows-nsis.ps1 @@ -68,9 +68,13 @@ if ($stagedAppBytes -lt 5242880) { } $requiredExe = Join-Path $stagingAppDir "bin\launcher.exe" +$requiredDeeplinkExe = Join-Path $stagingAppDir "bin\deeplink.exe" if (-not (Test-Path $requiredExe)) { throw "Staged application is missing bin\launcher.exe" } +if (-not (Test-Path $requiredDeeplinkExe)) { + throw "Staged application is missing bin\deeplink.exe" +} Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi") Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh") diff --git a/scripts/finalize-desktop-artifacts.mjs b/scripts/finalize-desktop-artifacts.mjs index f570f00..1cfd8a3 100644 --- a/scripts/finalize-desktop-artifacts.mjs +++ b/scripts/finalize-desktop-artifacts.mjs @@ -19,7 +19,7 @@ import { cleanExpandedWindowsApp, expandWindowsAppFromArchive } from './expand-windows-installer.mjs' -import { codesignMacAppBundle } from './codesign-macos-app.mjs' +import { buildWindowsDeeplinkExe } from './build-windows-deeplink.mjs' const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const packageJson = JSON.parse( @@ -428,6 +428,12 @@ function buildMacPkg(appBundlePath, arch) { return pkgPath } +function stageWindowsDeeplinkExe(appDir) { + const deeplinkPath = path.join(appDir, 'bin', 'deeplink.exe') + buildWindowsDeeplinkExe(deeplinkPath) + return deeplinkPath +} + function cleanWinBuildDir(arch) { const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`) if (existsSync(platformDir)) { @@ -577,6 +583,7 @@ async function main() { installerFiles.setupArchive, platformDir ) + stageWindowsDeeplinkExe(appDir) let published try { diff --git a/src/bun/deeplink.js b/src/bun/deeplink.js new file mode 100644 index 0000000..9c54fbc --- /dev/null +++ b/src/bun/deeplink.js @@ -0,0 +1,45 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { + buildDeeplinkPayload, + findProtocolUrl, + forwardDeeplinkToRunningInstance, + writeDeeplinkSignal +} from '../desktop/deeplink-ipc.js' + +const url = findProtocolUrl(process.argv) + +console.log('[deeplink] argv:', process.argv) +console.log('[deeplink] url:', url ?? null) + +if (!url) { + process.exit(1) +} + +const payload = buildDeeplinkPayload(url, process.argv) +const forwarded = await forwardDeeplinkToRunningInstance(payload) + +if (forwarded) { + console.log('[deeplink] forwarded to running Farm Control instance') + process.exit(0) +} + +console.log('[deeplink] no running instance found, launching Farm Control') + +writeDeeplinkSignal(payload) + +const launcherPath = join(dirname(process.execPath), 'launcher.exe') +if (!existsSync(launcherPath)) { + console.error('[deeplink] launcher.exe not found at', launcherPath) + process.exit(1) +} + +const child = spawn(launcherPath, [], { + detached: true, + stdio: 'ignore', + windowsHide: true +}) + +child.unref() +process.exit(0) diff --git a/src/bun/index.js b/src/bun/index.js index 3c5b565..0dc587f 100644 --- a/src/bun/index.js +++ b/src/bun/index.js @@ -1,28 +1,16 @@ import { captureLaunchUrl, closeSingleInstanceServer, - ensureSingleInstanceLock, - logWindowsLaunchArgs + ensureSingleInstanceLock } from '../desktop/single-instance.js' -if (process.platform === 'win32') { - logWindowsLaunchArgs('index-before-lock') -} - const launchUrl = captureLaunchUrl() const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl }) if (!gotSingleInstanceLock) { - if (process.platform === 'win32') { - console.log('[launch-args][index] exiting secondary instance') - } process.exit(0) } -if (process.platform === 'win32') { - console.log('[launch-args][index] acquired single instance lock') -} - const { createAppRpc } = await import('../desktop/rpc.js') const { registerGlobalShortcuts, diff --git a/src/desktop/deeplink-ipc.js b/src/desktop/deeplink-ipc.js new file mode 100644 index 0000000..a6f42a3 --- /dev/null +++ b/src/desktop/deeplink-ipc.js @@ -0,0 +1,147 @@ +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import net from 'node:net' +import { join } from 'node:path' + +export const PROTOCOL_PREFIX = 'farmcontrol://' +export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance' +export const SIGNAL_FILE = 'instance-signal.json' +const FORWARD_TIMEOUT_MS = 750 + +export 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') +} + +export function getSignalPath() { + return join(getInstanceDir(), SIGNAL_FILE) +} + +export function findProtocolUrl(args) { + const directMatch = args.find( + (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) + ) + + if (directMatch) { + return directMatch + } + + 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) { + 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 +} + +export function buildDeeplinkPayload(url, argv = process.argv) { + const commandLine = [...argv] + + return url + ? { type: 'deeplink', url, argv: commandLine } + : { type: 'focus', argv: commandLine } +} + +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 [] + } +} + +export function writeDeeplinkSignal(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(queue), 'utf8') + writeFileSync(signalPath, readFileSync(tempPath)) + unlinkSync(tempPath) +} + +export function forwardDeeplinkToRunningInstance(payload) { + return new Promise((resolve) => { + let settled = false + const finish = (forwarded) => { + if (settled) return + settled = true + resolve(forwarded) + } + + const client = net.connect(WINDOWS_PIPE_NAME) + const message = JSON.stringify(payload) + + client.on('connect', () => { + client.write(message) + client.end() + finish(true) + }) + + client.on('error', () => { + finish(false) + }) + + client.setTimeout(FORWARD_TIMEOUT_MS, () => { + client.destroy() + finish(false) + }) + }) +} diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js index a0948a0..6751467 100644 --- a/src/desktop/single-instance.js +++ b/src/desktop/single-instance.js @@ -11,15 +11,17 @@ import { import net from 'node:net' import { join } from 'node:path' import { - getWindowsLaunchSources, - getWindowsLaunchUrl -} from './windows-launch-args.js' + buildDeeplinkPayload, + findProtocolUrl, + forwardDeeplinkToRunningInstance, + getInstanceDir, + getSignalPath, + SIGNAL_FILE, + WINDOWS_PIPE_NAME, + writeDeeplinkSignal +} from './deeplink-ipc.js' -const PROTOCOL_PREFIX = 'farmcontrol://' const LOCK_FILE = 'primary.lock' -const SIGNAL_FILE = 'instance-signal.json' -const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance' -const FORWARD_TIMEOUT_MS = 750 let lockFd = null let pollInterval = null @@ -31,25 +33,10 @@ let handlers = { } const pendingMessages = [] -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 getLockPath() { return join(getInstanceDir(), LOCK_FILE) } -function getSignalPath() { - return join(getInstanceDir(), SIGNAL_FILE) -} - function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) { return false @@ -115,86 +102,17 @@ function releasePrimaryLock() { } } -export function findProtocolUrl(args) { - const directMatch = args.find( - (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) - ) - - if (directMatch) { - return directMatch - } - - 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) { - 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 -} - -function collectLaunchSources() { - const sources = [...process.argv] - - if (process.platform === 'win32') { - sources.push(...getWindowsLaunchSources()) - } - - return sources -} - -export function logWindowsLaunchArgs(context = 'startup') { - if (process.platform !== 'win32') { - return - } - - const launchSources = getWindowsLaunchSources() - const launchUrl = captureLaunchUrl() - - console.log(`[launch-args][${context}] process.argv:`, process.argv) - console.log(`[launch-args][${context}] launch sources:`, launchSources) - console.log(`[launch-args][${context}] captured url:`, launchUrl ?? null) -} +export { findProtocolUrl } export function captureLaunchUrl() { - const argvUrl = findProtocolUrl(process.argv) - if (argvUrl) { - return argvUrl - } - - if (process.platform === 'win32') { - return getWindowsLaunchUrl() || findProtocolUrl(collectLaunchSources()) - } - - return findProtocolUrl(collectLaunchSources()) + return findProtocolUrl(process.argv) } function buildLaunchPayload(launchUrl = captureLaunchUrl()) { - const commandLine = collectLaunchSources() + const commandLine = [...process.argv] const url = launchUrl || findProtocolUrl(commandLine) - return url - ? { type: 'deeplink', url, argv: commandLine } - : { type: 'focus', argv: commandLine } + return buildDeeplinkPayload(url, commandLine) } function readSignalQueue({ clear = true } = {}) { @@ -229,16 +147,7 @@ function readSignalQueue({ clear = true } = {}) { } 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(queue), 'utf8') - writeFileSync(signalPath, readFileSync(tempPath)) - unlinkSync(tempPath) + writeDeeplinkSignal(payload) } function resolveIncomingMessage(message) { @@ -263,8 +172,8 @@ function dispatchMessage(message) { if (!resolved) return if (process.platform === 'win32') { - console.log('[launch-args][dispatch]', message) - console.log('[launch-args][dispatch] resolved:', resolved) + console.log('[deeplink][dispatch]', message) + console.log('[deeplink][dispatch] resolved:', resolved) } if (!handlers.onDeepLink && !handlers.onFocus) { @@ -309,39 +218,6 @@ function startInstanceSignalWatcher() { pollInterval = setInterval(processInstanceSignals, 100) } -function forwardToRunningInstance(payload) { - if (process.platform !== 'win32') { - return Promise.resolve(false) - } - - return new Promise((resolve) => { - let settled = false - const finish = (forwarded) => { - if (settled) return - settled = true - resolve(forwarded) - } - - const client = net.connect(WINDOWS_PIPE_NAME) - const message = JSON.stringify(payload) - - client.on('connect', () => { - client.write(message) - client.end() - finish(true) - }) - - client.on('error', () => { - finish(false) - }) - - client.setTimeout(FORWARD_TIMEOUT_MS, () => { - client.destroy() - finish(false) - }) - }) -} - function startWindowsPipeServer() { if (process.platform !== 'win32' || pipeServer) { return @@ -383,18 +259,13 @@ export async function ensureSingleInstanceLock({ launchUrl } = {}) { const payload = buildLaunchPayload(launchUrl) if (process.platform === 'win32') { - logWindowsLaunchArgs('ensure-single-instance-lock') - console.log('[launch-args][single-instance] payload:', payload) - } - - if (process.platform === 'win32') { - if (await forwardToRunningInstance(payload)) { - console.log('[launch-args][single-instance] forwarded to running instance') + if (await forwardDeeplinkToRunningInstance(payload)) { + console.log('[deeplink] forwarded duplicate launcher instance to running app') return false } if (!tryAcquirePrimaryLock()) { - if (await forwardToRunningInstance(payload)) { + if (await forwardDeeplinkToRunningInstance(payload)) { return false } diff --git a/src/desktop/windows-launch-args.js b/src/desktop/windows-launch-args.js deleted file mode 100644 index 3fab7c1..0000000 --- a/src/desktop/windows-launch-args.js +++ /dev/null @@ -1,187 +0,0 @@ -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', - windowsHide: true - }) - - 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 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) { - $lines.Add([string]$proc.CommandLine) - if ($proc.CommandLine -match 'farmcontrol://') { break } - } - if (-not $proc.ParentProcessId -or $proc.ParentProcessId -eq $pid) { break } - $pid = $proc.ParentProcessId -} -$lines -join [Environment]::NewLine -`) - - if (!output) { - return [] - } - - return output - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) -} - -function getWmicProcessTreeCommandLines() { - const sources = [] - let pid = process.pid - - for (let depth = 0; depth < 12; 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 - } - - 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 -}