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 {
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()

View File

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

View File

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

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