Enhance single instance and deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Introduced `captureLaunchUrl` to retrieve launch URLs for Windows, improving deep link processing.
- Updated `ensureSingleInstanceLock` to accept a launch URL, enhancing instance management.
- Refactored deep link handling functions to utilize the captured launch URL, ensuring better navigation and focus management.
- Improved signal processing by consolidating instance signal handling functions for better clarity and efficiency.
This commit is contained in:
Tom Butcher 2026-08-02 20:38:06 +01:00
parent 76ec8389c2
commit 3ed7945b7c
4 changed files with 190 additions and 83 deletions

View File

@ -1,9 +1,11 @@
import { import {
captureLaunchUrl,
closeSingleInstanceServer, closeSingleInstanceServer,
ensureSingleInstanceLock ensureSingleInstanceLock
} from '../desktop/single-instance.js' } from '../desktop/single-instance.js'
const gotSingleInstanceLock = await ensureSingleInstanceLock() const launchUrl = captureLaunchUrl()
const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })
if (!gotSingleInstanceLock) { if (!gotSingleInstanceLock) {
process.exit(0) process.exit(0)
@ -22,14 +24,15 @@ const {
setupWindowsDeepLinkHandling setupWindowsDeepLinkHandling
} = await import('../desktop/window.js') } = await import('../desktop/window.js')
setupWindowsDeepLinkHandling()
const rpc = createAppRpc() const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc) const mainWindow = await createMainWindow(rpc)
setupWindowsDeepLinkHandling()
setupNavigationGestures(mainWindow) setupNavigationGestures(mainWindow)
registerGlobalShortcuts(rpc) registerGlobalShortcuts(rpc)
setupDevAuthServer() setupDevAuthServer()
handleDeepLinkFromArgv() handleDeepLinkFromArgv(launchUrl)
process.on('exit', () => { process.on('exit', () => {
unregisterGlobalShortcuts() unregisterGlobalShortcuts()

View File

@ -9,7 +9,10 @@ import {
writeFileSync writeFileSync
} from 'node:fs' } from 'node:fs'
import { join } from 'node:path' 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 LOCK_FILE = 'primary.lock'
const SIGNAL_FILE = 'instance-signal.json' const SIGNAL_FILE = 'instance-signal.json'
@ -147,45 +150,67 @@ function collectLaunchSources() {
return sources 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 sources = collectLaunchSources()
const url = findProtocolUrl(sources) const url = launchUrl || findProtocolUrl(sources)
return url return url
? { type: 'deeplink', url, argv: sources } ? { type: 'deeplink', url, argv: sources }
: { type: 'focus', 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) { function writeInstanceSignal(payload) {
const signalPath = getSignalPath() const signalPath = getSignalPath()
mkdirSync(getInstanceDir(), { recursive: true }) mkdirSync(getInstanceDir(), { recursive: true })
const queue = readSignalQueue({ clear: false })
queue.push({ ...payload, timestamp: Date.now() })
const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp` 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)) writeFileSync(signalPath, readFileSync(tempPath))
unlinkSync(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) { function resolveIncomingMessage(message) {
if (!message || typeof message !== 'object') { if (!message || typeof message !== 'object') {
return null return null
@ -222,8 +247,10 @@ function dispatchMessage(message) {
} }
} }
function processInstanceSignal() { function processInstanceSignals() {
dispatchMessage(readAndClearInstanceSignal()) for (const message of readSignalQueue({ clear: true })) {
dispatchMessage(message)
}
} }
function startInstanceSignalWatcher() { function startInstanceSignalWatcher() {
@ -232,22 +259,22 @@ function startInstanceSignalWatcher() {
} }
mkdirSync(getInstanceDir(), { recursive: true }) mkdirSync(getInstanceDir(), { recursive: true })
processInstanceSignal() processInstanceSignals()
try { try {
fsWatcher = watch(getInstanceDir(), (_event, filename) => { fsWatcher = watch(getInstanceDir(), (_event, filename) => {
if (!filename || filename === SIGNAL_FILE) { if (!filename || filename === SIGNAL_FILE) {
setTimeout(processInstanceSignal, 25) setTimeout(processInstanceSignals, 25)
} }
}) })
} catch { } catch {
// Polling below covers filesystems without reliable watch support. // 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') { if (process.platform === 'darwin') {
return true return true
} }
@ -257,7 +284,7 @@ export async function ensureSingleInstanceLock() {
return true return true
} }
writeInstanceSignal(buildLaunchPayload()) writeInstanceSignal(buildLaunchPayload(launchUrl))
return false return false
} }
@ -268,7 +295,7 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
dispatchMessage(pendingMessages.shift()) dispatchMessage(pendingMessages.shift())
} }
processInstanceSignal() processInstanceSignals()
} }
export function closeSingleInstanceServer() { export function closeSingleInstanceServer() {

View File

@ -10,8 +10,7 @@ const isMacOS = process.platform === 'darwin'
const DEV_SERVER_PORT = 5780 const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js' import { captureLaunchUrl, setSingleInstanceHandlers } from './single-instance.js'
import { getWindowsLaunchSources } from './windows-launch-args.js'
let mainWindow = null let mainWindow = null
let webviewDomReady = false let webviewDomReady = false
@ -128,16 +127,12 @@ export function handleDeepLink(url) {
return true return true
} }
export function handleDeepLinkFromArgv() { export function handleDeepLinkFromArgv(launchUrl = captureLaunchUrl()) {
if (process.platform === 'darwin') return if (process.platform === 'darwin') return
const sources = [...process.argv] if (launchUrl) {
if (process.platform === 'win32') { handleDeepLink(launchUrl)
sources.push(...getWindowsLaunchSources())
} }
const url = findProtocolUrl(sources)
if (url) handleDeepLink(url)
} }
function broadcastWindowState() { function broadcastWindowState() {

View File

@ -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) { function runCommand(command) {
try { try {
const proc = Bun.spawnSync({ const proc = Bun.spawnSync({
cmd: ['cmd.exe', '/c', command], cmd: ['cmd.exe', '/c', command],
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe' stderr: 'pipe',
windowsHide: true
}) })
if (proc.exitCode !== 0) { if (proc.exitCode !== 0) {
@ -36,51 +71,38 @@ function getParentProcessId(pid) {
return parentPid ? Number.parseInt(parentPid, 10) : null return parentPid ? Number.parseInt(parentPid, 10) : null
} }
function getPowerShellCommandLines() { function getPowerShellProcessTreeCommandLines() {
try { const output = runPowerShell(`
const proc = Bun.spawnSync({ $pid = $PID
cmd: [ $lines = New-Object System.Collections.Generic.List[string]
'powershell.exe', for ($i = 0; $i -lt 12; $i++) {
'-NoProfile',
'-NonInteractive',
'-Command',
`$pid = $PID
for ($i = 0; $i -lt 6; $i++) {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue
if (-not $proc) { break } if (-not $proc) { break }
if ($proc.CommandLine) { $proc.CommandLine } if ($proc.CommandLine) {
if ($proc.CommandLine -match 'farmcontrol://') { break } $lines.Add([string]$proc.CommandLine)
if (-not $proc.ParentProcessId) { break } if ($proc.CommandLine -match 'farmcontrol://') { break }
}
if (-not $proc.ParentProcessId -or $proc.ParentProcessId -eq $pid) { break }
$pid = $proc.ParentProcessId $pid = $proc.ParentProcessId
}` }
], $lines -join [Environment]::NewLine
stdout: 'pipe', `)
stderr: 'pipe'
})
if (proc.exitCode !== 0) { if (!output) {
return []
}
return proc.stdout
.toString()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
return [] return []
} }
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} }
export function getWindowsLaunchSources() { function getWmicProcessTreeCommandLines() {
if (process.platform !== 'win32') {
return []
}
const sources = [] const sources = []
let pid = process.pid let pid = process.pid
for (let depth = 0; depth < 6; depth += 1) { for (let depth = 0; depth < 12; depth += 1) {
const commandLine = getProcessCommandLine(pid) const commandLine = getProcessCommandLine(pid)
if (commandLine) { if (commandLine) {
sources.push(commandLine) sources.push(commandLine)
@ -97,9 +119,69 @@ export function getWindowsLaunchSources() {
pid = parentPid pid = parentPid
} }
if (sources.length === 0) {
return getPowerShellCommandLines()
}
return sources 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
}