Implement deeplink functionality and update installer for Windows
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

- Introduced a new script to build the Windows deeplink executable, enhancing deep link handling.
- Updated the installer script to replace references from `launcher.exe` to `deeplink.exe` for better integration with the new deeplink functionality.
- Added a new `deeplink.js` file to manage deeplink processing and communication with running instances.
- Refactored single instance management to utilize the new deeplink capabilities, improving instance handling and navigation.
- Removed deprecated Windows launch argument handling to streamline the codebase.
This commit is contained in:
Tom Butcher 2026-08-02 21:24:05 +01:00
parent 37593d73ce
commit 5450473f74
9 changed files with 287 additions and 352 deletions

View File

@ -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

View File

@ -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 <output-path>')
process.exit(1)
}
buildWindowsDeeplinkExe(path.resolve(outputArg))
}

View File

@ -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")

View File

@ -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 {

45
src/bun/deeplink.js Normal file
View File

@ -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)

View File

@ -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,

147
src/desktop/deeplink-ipc.js Normal file
View File

@ -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)
})
})
}

View File

@ -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
}

View File

@ -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
}