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

- Refactored single instance handling to improve lock management and signal processing.
- Introduced functions for acquiring and releasing primary locks, along with handling instance signals.
- Updated deep link processing to utilize a signal file for better communication between instances.
- Enhanced Windows-specific launch argument retrieval to improve deep link handling on that platform.
This commit is contained in:
Tom Butcher 2026-08-02 19:37:09 +01:00
parent 813b43595b
commit fb81cfedd2
3 changed files with 241 additions and 146 deletions

View File

@ -1,21 +1,27 @@
import { mkdirSync, readFileSync, unlinkSync, writeFileSync, existsSync } from 'node:fs'
import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
unlinkSync,
watch,
writeFileSync
} from 'node:fs'
import { join } from 'node:path'
import { getWindowsLaunchSources } from './windows-launch-args.js'
const SINGLE_INSTANCE_HOSTS = ['127.0.0.1', 'localhost']
const SINGLE_INSTANCE_PATH = '/farmcontrol-instance'
const LOCK_FILE = 'primary.lock'
const SIGNAL_FILE = 'instance-signal.json'
// Keep below Electrobun's RPC server range (50000+).
function getSingleInstancePort() {
const identifier = 'com.tombutcher.farmcontrol'
let hash = 0
for (const char of identifier) {
hash = (hash * 31 + char.charCodeAt(0)) | 0
}
return 30000 + (Math.abs(hash) % 10000)
let lockFd = null
let pollInterval = null
let fsWatcher = null
let handlers = {
onDeepLink: null,
onFocus: null
}
const pendingMessages = []
function getInstanceDir() {
if (process.platform === 'win32') {
@ -28,8 +34,77 @@ function getInstanceDir() {
return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance')
}
function getPendingDeeplinkPath() {
return join(getInstanceDir(), 'pending-deeplink.json')
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
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function tryAcquirePrimaryLock() {
mkdirSync(getInstanceDir(), { recursive: true })
const lockPath = getLockPath()
if (existsSync(lockPath)) {
try {
const existingPid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
if (existingPid === process.pid) {
return true
}
if (isProcessAlive(existingPid)) {
return false
}
unlinkSync(lockPath)
} catch {
try {
unlinkSync(lockPath)
} catch {
// Ignore cleanup failures.
}
}
}
try {
lockFd = openSync(lockPath, 'wx')
writeFileSync(lockFd, String(process.pid))
return true
} catch {
return false
}
}
function releasePrimaryLock() {
if (lockFd !== null) {
try {
closeSync(lockFd)
} catch {
// Ignore close failures.
}
lockFd = null
}
try {
unlinkSync(getLockPath())
} catch {
// Ignore cleanup failures.
}
}
export function findProtocolUrl(args) {
@ -70,11 +145,7 @@ function collectLaunchSources() {
return sources
}
function getLaunchProtocolUrl() {
return findProtocolUrl(collectLaunchSources())
}
function buildSecondInstanceMessage() {
function buildLaunchPayload() {
const sources = collectLaunchSources()
const url = findProtocolUrl(sources)
@ -83,54 +154,36 @@ function buildSecondInstanceMessage() {
: { type: 'focus', argv: sources }
}
function stagePendingDeeplink(message) {
const url = message?.url || findProtocolUrl(message?.argv || [])
if (!url) return
try {
function writeInstanceSignal(payload) {
const signalPath = getSignalPath()
mkdirSync(getInstanceDir(), { recursive: true })
writeFileSync(
getPendingDeeplinkPath(),
JSON.stringify({ url, timestamp: Date.now() }),
'utf8'
)
} catch (error) {
console.warn('Failed to stage pending deeplink:', error)
}
const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp`
writeFileSync(tempPath, JSON.stringify({ ...payload, timestamp: Date.now() }), 'utf8')
writeFileSync(signalPath, readFileSync(tempPath))
unlinkSync(tempPath)
}
function clearPendingDeeplinkFile() {
const filePath = getPendingDeeplinkPath()
if (!existsSync(filePath)) return
function readAndClearInstanceSignal() {
const signalPath = getSignalPath()
if (!existsSync(signalPath)) {
return null
}
try {
unlinkSync(filePath)
const payload = JSON.parse(readFileSync(signalPath, 'utf8'))
unlinkSync(signalPath)
return payload
} catch {
try {
unlinkSync(signalPath)
} catch {
// Ignore cleanup failures.
}
}
function consumePendingDeeplinkFile() {
const filePath = getPendingDeeplinkPath()
if (!existsSync(filePath)) return null
try {
const data = JSON.parse(readFileSync(filePath, 'utf8'))
clearPendingDeeplinkFile()
return data?.url || null
} catch {
clearPendingDeeplinkFile()
return null
}
}
function processPendingDeeplinkFile() {
const url = consumePendingDeeplinkFile()
if (url) {
dispatchMessage({ type: 'deeplink', url })
}
}
function resolveIncomingMessage(message) {
if (!message || typeof message !== 'object') {
return null
@ -148,13 +201,6 @@ function resolveIncomingMessage(message) {
return null
}
let server = null
let handlers = {
onDeepLink: null,
onFocus: null
}
const pendingMessages = []
function dispatchMessage(message) {
const resolved = resolveIncomingMessage(message)
if (!resolved) return
@ -166,68 +212,38 @@ function dispatchMessage(message) {
if (resolved.type === 'deeplink' && resolved.url) {
handlers.onDeepLink?.(resolved.url)
} else if (resolved.type === 'focus') {
handlers.onFocus?.()
return
}
if (resolved.type === 'focus') {
handlers.onFocus?.()
}
}
function getSingleInstanceUrl(host) {
return `http://${host}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}`
function processInstanceSignal() {
dispatchMessage(readAndClearInstanceSignal())
}
async function tryForwardToPrimaryInstance(message) {
const body = JSON.stringify(message)
function startInstanceSignalWatcher() {
if (process.platform === 'darwin' || pollInterval) {
return
}
mkdirSync(getInstanceDir(), { recursive: true })
processInstanceSignal()
for (const host of SINGLE_INSTANCE_HOSTS) {
try {
const response = await fetch(getSingleInstanceUrl(host), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(1000)
})
if (response.ok) {
return true
}
} catch {
// Try the next host.
}
}
return false
}
function startPrimaryInstanceServer() {
server = Bun.serve({
hostname: '127.0.0.1',
port: getSingleInstancePort(),
fetch: async (req) => {
const { pathname } = new URL(req.url)
if (req.method !== 'POST' || pathname !== SINGLE_INSTANCE_PATH) {
return new Response('Not found', { status: 404 })
}
let message
try {
message = await req.json()
} catch {
return new Response('Bad request', { status: 400 })
}
dispatchMessage(message)
const resolved = resolveIncomingMessage(message)
if (resolved?.type === 'deeplink' && resolved.url) {
clearPendingDeeplinkFile()
} else {
processPendingDeeplinkFile()
}
return new Response('ok')
fsWatcher = watch(getInstanceDir(), (_event, filename) => {
if (!filename || filename === SIGNAL_FILE) {
setTimeout(processInstanceSignal, 25)
}
})
} catch {
// Polling below covers filesystems without reliable watch support.
}
pollInterval = setInterval(processInstanceSignal, 250)
}
export async function ensureSingleInstanceLock() {
@ -235,39 +251,39 @@ export async function ensureSingleInstanceLock() {
return true
}
const message = buildSecondInstanceMessage()
stagePendingDeeplink(message)
if (await tryForwardToPrimaryInstance(message)) {
return false
}
try {
startPrimaryInstanceServer()
return true
} catch (error) {
if (error?.code === 'EADDRINUSE') {
if (await tryForwardToPrimaryInstance(message)) {
return false
}
}
console.warn('Single instance lock failed:', error)
if (tryAcquirePrimaryLock()) {
return true
}
writeInstanceSignal(buildLaunchPayload())
return false
}
export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
handlers = { onDeepLink, onFocus }
startInstanceSignalWatcher()
while (pendingMessages.length > 0) {
dispatchMessage(pendingMessages.shift())
}
processPendingDeeplinkFile()
processInstanceSignal()
}
export function closeSingleInstanceServer() {
server?.stop?.()
server = null
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
if (fsWatcher) {
try {
fsWatcher.close()
} catch {
// Ignore close failures.
}
fsWatcher = null
}
releasePrimaryLock()
}

View File

@ -22,8 +22,14 @@ export function getMainWindow() {
}
export function showMainWindow() {
mainWindow?.show?.()
mainWindow?.activate?.()
if (!mainWindow) return
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
}
mainWindow.show?.()
mainWindow.activate?.()
}
export async function getMainViewUrl() {
@ -45,8 +51,15 @@ export async function getMainViewUrl() {
function deliverNavigation(redirectPath) {
sendToRenderer('navigate', redirectPath)
mainWindow?.show?.()
mainWindow?.activate?.()
if (!mainWindow) return
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
}
mainWindow.show?.()
mainWindow.activate?.()
}
function flushPendingNavigations() {

View File

@ -1,8 +1,42 @@
export function getWindowsLaunchSources() {
if (process.platform !== 'win32') {
return []
function runCommand(command) {
try {
const proc = Bun.spawnSync({
cmd: ['cmd.exe', '/c', command],
stdout: 'pipe',
stderr: 'pipe'
})
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 getPowerShellCommandLines() {
try {
const proc = Bun.spawnSync({
cmd: [
@ -37,3 +71,35 @@ for ($i = 0; $i -lt 6; $i++) {
return []
}
}
export function getWindowsLaunchSources() {
if (process.platform !== 'win32') {
return []
}
const sources = []
let pid = process.pid
for (let depth = 0; depth < 6; 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
}
if (sources.length === 0) {
return getPowerShellCommandLines()
}
return sources
}