farmcontrol-ui/src/desktop/single-instance.js
Tom Butcher 5f061f92eb
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
Enhance deeplink and single instance management for Windows
- Added functions to manage lock files and check if the primary instance is running, improving instance handling.
- Implemented retry logic for forwarding deeplinks via Windows pipes, enhancing reliability in communication between instances.
- Refactored existing functions to streamline the deeplink forwarding process and improve code clarity.
2026-08-02 22:02:17 +01:00

308 lines
6.0 KiB
JavaScript

import {
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
unlinkSync,
watch,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import {
buildDeeplinkPayload,
findProtocolUrl,
forwardDeeplinkToRunningInstance,
getInstanceDir,
getLockPath,
getSignalPath,
isProcessAlive,
SIGNAL_FILE,
WINDOWS_PIPE_NAME,
writeDeeplinkSignal
} from './deeplink-ipc.js'
let lockFd = null
let pollInterval = null
let fsWatcher = null
let pipeServer = null
let handlers = {
onDeepLink: null,
onFocus: null
}
const pendingMessages = []
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 { findProtocolUrl }
export function captureLaunchUrl() {
return findProtocolUrl(process.argv)
}
function buildLaunchPayload(launchUrl = captureLaunchUrl()) {
const commandLine = [...process.argv]
const url = launchUrl || findProtocolUrl(commandLine)
return buildDeeplinkPayload(url, 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 []
}
}
function writeInstanceSignal(payload) {
writeDeeplinkSignal(payload)
}
function resolveIncomingMessage(message) {
if (!message || typeof message !== 'object') {
return null
}
if (message.type === 'deeplink') {
const url = message.url || findProtocolUrl(message.argv || [])
return url ? { type: 'deeplink', url } : { type: 'focus' }
}
if (message.type === 'focus') {
return { type: 'focus' }
}
return null
}
function dispatchMessage(message) {
const resolved = resolveIncomingMessage(message)
if (!resolved) return
if (process.platform === 'win32') {
console.log('[deeplink][dispatch]', message)
console.log('[deeplink][dispatch] resolved:', resolved)
}
if (!handlers.onDeepLink && !handlers.onFocus) {
pendingMessages.push(resolved)
return
}
if (resolved.type === 'deeplink' && resolved.url) {
handlers.onDeepLink?.(resolved.url)
return
}
if (resolved.type === 'focus') {
handlers.onFocus?.()
}
}
function processInstanceSignals() {
for (const message of readSignalQueue({ clear: true })) {
dispatchMessage(message)
}
}
function startInstanceSignalWatcher() {
if (process.platform === 'darwin' || pollInterval) {
return
}
mkdirSync(getInstanceDir(), { recursive: true })
processInstanceSignals()
try {
fsWatcher = watch(getInstanceDir(), (_event, filename) => {
if (!filename || filename === SIGNAL_FILE) {
setTimeout(processInstanceSignals, 25)
}
})
} catch {
// Polling below covers filesystems without reliable watch support.
}
pollInterval = setInterval(processInstanceSignals, 100)
}
function startWindowsPipeServer() {
if (process.platform !== 'win32' || pipeServer) {
return
}
const server = net.createServer((socket) => {
let buffer = ''
socket.on('data', (chunk) => {
buffer += chunk.toString()
})
socket.on('end', () => {
if (!buffer) return
try {
dispatchMessage(JSON.parse(buffer))
} catch {
// Ignore malformed payloads.
}
})
})
server.on('error', (error) => {
if (error?.code !== 'EADDRINUSE') {
console.warn('[single-instance] pipe server error:', error)
}
})
server.listen({ path: WINDOWS_PIPE_NAME })
pipeServer = server
}
export async function ensureSingleInstanceLock({ launchUrl } = {}) {
if (process.platform === 'darwin') {
return true
}
const payload = buildLaunchPayload(launchUrl)
if (process.platform === 'win32') {
if (await forwardDeeplinkToRunningInstance(payload)) {
console.log('[deeplink] forwarded duplicate launcher instance to running app')
return false
}
if (!tryAcquirePrimaryLock()) {
if (await forwardDeeplinkToRunningInstance(payload)) {
return false
}
writeInstanceSignal(payload)
return false
}
startWindowsPipeServer()
startInstanceSignalWatcher()
return true
}
if (tryAcquirePrimaryLock()) {
startInstanceSignalWatcher()
return true
}
writeInstanceSignal(payload)
return false
}
export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
handlers = { onDeepLink, onFocus }
while (pendingMessages.length > 0) {
dispatchMessage(pendingMessages.shift())
}
processInstanceSignals()
}
export function closeSingleInstanceServer() {
if (pipeServer) {
try {
pipeServer.close()
} catch {
// Ignore close failures.
}
pipeServer = null
}
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
if (fsWatcher) {
try {
fsWatcher.close()
} catch {
// Ignore close failures.
}
fsWatcher = null
}
releasePrimaryLock()
}