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 { join } from 'node:path'
import { getWindowsLaunchSources } from './windows-launch-args.js' import { getWindowsLaunchSources } from './windows-launch-args.js'
const SINGLE_INSTANCE_HOSTS = ['127.0.0.1', 'localhost'] const LOCK_FILE = 'primary.lock'
const SINGLE_INSTANCE_PATH = '/farmcontrol-instance' const SIGNAL_FILE = 'instance-signal.json'
// Keep below Electrobun's RPC server range (50000+). let lockFd = null
function getSingleInstancePort() { let pollInterval = null
const identifier = 'com.tombutcher.farmcontrol' let fsWatcher = null
let hash = 0 let handlers = {
onDeepLink: null,
for (const char of identifier) { onFocus: null
hash = (hash * 31 + char.charCodeAt(0)) | 0
}
return 30000 + (Math.abs(hash) % 10000)
} }
const pendingMessages = []
function getInstanceDir() { function getInstanceDir() {
if (process.platform === 'win32') { if (process.platform === 'win32') {
@ -28,8 +34,77 @@ function getInstanceDir() {
return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance') return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance')
} }
function getPendingDeeplinkPath() { function getLockPath() {
return join(getInstanceDir(), 'pending-deeplink.json') 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) { export function findProtocolUrl(args) {
@ -70,11 +145,7 @@ function collectLaunchSources() {
return sources return sources
} }
function getLaunchProtocolUrl() { function buildLaunchPayload() {
return findProtocolUrl(collectLaunchSources())
}
function buildSecondInstanceMessage() {
const sources = collectLaunchSources() const sources = collectLaunchSources()
const url = findProtocolUrl(sources) const url = findProtocolUrl(sources)
@ -83,51 +154,33 @@ function buildSecondInstanceMessage() {
: { type: 'focus', argv: sources } : { type: 'focus', argv: sources }
} }
function stagePendingDeeplink(message) { function writeInstanceSignal(payload) {
const url = message?.url || findProtocolUrl(message?.argv || []) const signalPath = getSignalPath()
if (!url) return mkdirSync(getInstanceDir(), { recursive: true })
try { const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp`
mkdirSync(getInstanceDir(), { recursive: true }) writeFileSync(tempPath, JSON.stringify({ ...payload, timestamp: Date.now() }), 'utf8')
writeFileSync( writeFileSync(signalPath, readFileSync(tempPath))
getPendingDeeplinkPath(), unlinkSync(tempPath)
JSON.stringify({ url, timestamp: Date.now() }),
'utf8'
)
} catch (error) {
console.warn('Failed to stage pending deeplink:', error)
}
} }
function clearPendingDeeplinkFile() { function readAndClearInstanceSignal() {
const filePath = getPendingDeeplinkPath() const signalPath = getSignalPath()
if (!existsSync(filePath)) return if (!existsSync(signalPath)) {
try {
unlinkSync(filePath)
} 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 return null
} }
}
function processPendingDeeplinkFile() { try {
const url = consumePendingDeeplinkFile() const payload = JSON.parse(readFileSync(signalPath, 'utf8'))
if (url) { unlinkSync(signalPath)
dispatchMessage({ type: 'deeplink', url }) return payload
} catch {
try {
unlinkSync(signalPath)
} catch {
// Ignore cleanup failures.
}
return null
} }
} }
@ -148,13 +201,6 @@ function resolveIncomingMessage(message) {
return null return null
} }
let server = null
let handlers = {
onDeepLink: null,
onFocus: null
}
const pendingMessages = []
function dispatchMessage(message) { function dispatchMessage(message) {
const resolved = resolveIncomingMessage(message) const resolved = resolveIncomingMessage(message)
if (!resolved) return if (!resolved) return
@ -166,68 +212,38 @@ function dispatchMessage(message) {
if (resolved.type === 'deeplink' && resolved.url) { if (resolved.type === 'deeplink' && resolved.url) {
handlers.onDeepLink?.(resolved.url) handlers.onDeepLink?.(resolved.url)
} else if (resolved.type === 'focus') { handlers.onFocus?.()
return
}
if (resolved.type === 'focus') {
handlers.onFocus?.() handlers.onFocus?.()
} }
} }
function getSingleInstanceUrl(host) { function processInstanceSignal() {
return `http://${host}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}` dispatchMessage(readAndClearInstanceSignal())
} }
async function tryForwardToPrimaryInstance(message) { function startInstanceSignalWatcher() {
const body = JSON.stringify(message) if (process.platform === 'darwin' || pollInterval) {
return
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 mkdirSync(getInstanceDir(), { recursive: true })
} processInstanceSignal()
function startPrimaryInstanceServer() { try {
server = Bun.serve({ fsWatcher = watch(getInstanceDir(), (_event, filename) => {
hostname: '127.0.0.1', if (!filename || filename === SIGNAL_FILE) {
port: getSingleInstancePort(), setTimeout(processInstanceSignal, 25)
fetch: async (req) => {
const { pathname } = new URL(req.url)
if (req.method !== 'POST' || pathname !== SINGLE_INSTANCE_PATH) {
return new Response('Not found', { status: 404 })
} }
})
} catch {
// Polling below covers filesystems without reliable watch support.
}
let message pollInterval = setInterval(processInstanceSignal, 250)
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')
}
})
} }
export async function ensureSingleInstanceLock() { export async function ensureSingleInstanceLock() {
@ -235,39 +251,39 @@ export async function ensureSingleInstanceLock() {
return true return true
} }
const message = buildSecondInstanceMessage() if (tryAcquirePrimaryLock()) {
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)
return true return true
} }
writeInstanceSignal(buildLaunchPayload())
return false
} }
export function setSingleInstanceHandlers({ onDeepLink, onFocus }) { export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
handlers = { onDeepLink, onFocus } handlers = { onDeepLink, onFocus }
startInstanceSignalWatcher()
while (pendingMessages.length > 0) { while (pendingMessages.length > 0) {
dispatchMessage(pendingMessages.shift()) dispatchMessage(pendingMessages.shift())
} }
processPendingDeeplinkFile() processInstanceSignal()
} }
export function closeSingleInstanceServer() { export function closeSingleInstanceServer() {
server?.stop?.() if (pollInterval) {
server = null 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() { export function showMainWindow() {
mainWindow?.show?.() if (!mainWindow) return
mainWindow?.activate?.()
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
}
mainWindow.show?.()
mainWindow.activate?.()
} }
export async function getMainViewUrl() { export async function getMainViewUrl() {
@ -45,8 +51,15 @@ export async function getMainViewUrl() {
function deliverNavigation(redirectPath) { function deliverNavigation(redirectPath) {
sendToRenderer('navigate', redirectPath) sendToRenderer('navigate', redirectPath)
mainWindow?.show?.()
mainWindow?.activate?.() if (!mainWindow) return
if (mainWindow.isMinimized?.()) {
mainWindow.restore?.()
}
mainWindow.show?.()
mainWindow.activate?.()
} }
function flushPendingNavigations() { function flushPendingNavigations() {

View File

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