Enhance deeplink and single instance management for Windows
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- 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.
This commit is contained in:
Tom Butcher 2026-08-02 22:02:17 +01:00
parent 35a50fa97d
commit 5f061f92eb
2 changed files with 75 additions and 23 deletions

View File

@ -11,7 +11,10 @@ import { join } from 'node:path'
export const PROTOCOL_PREFIX = 'farmcontrol://' export const PROTOCOL_PREFIX = 'farmcontrol://'
export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance' export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
export const SIGNAL_FILE = 'instance-signal.json' export const SIGNAL_FILE = 'instance-signal.json'
export const LOCK_FILE = 'primary.lock'
const FORWARD_TIMEOUT_MS = 750 const FORWARD_TIMEOUT_MS = 750
const PIPE_RETRY_ATTEMPTS = 3
const PIPE_RETRY_DELAY_MS = 100
export function getInstanceDir() { export function getInstanceDir() {
if (process.platform === 'win32') { if (process.platform === 'win32') {
@ -28,6 +31,37 @@ export function getSignalPath() {
return join(getInstanceDir(), SIGNAL_FILE) return join(getInstanceDir(), SIGNAL_FILE)
} }
export function getLockPath() {
return join(getInstanceDir(), LOCK_FILE)
}
export function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
export function isPrimaryInstanceRunning() {
const lockPath = getLockPath()
if (!existsSync(lockPath)) {
return false
}
try {
const existingPid = Number.parseInt(readFileSync(lockPath, 'utf8').trim(), 10)
return isProcessAlive(existingPid)
} catch {
return false
}
}
export function findProtocolUrl(args) { export function findProtocolUrl(args) {
const directMatch = args.find( const directMatch = args.find(
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX) (arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
@ -117,7 +151,7 @@ export function writeDeeplinkSignal(payload) {
unlinkSync(tempPath) unlinkSync(tempPath)
} }
export function forwardDeeplinkToRunningInstance(payload) { function tryForwardViaPipe(payload) {
return new Promise((resolve) => { return new Promise((resolve) => {
let settled = false let settled = false
const finish = (forwarded) => { const finish = (forwarded) => {
@ -126,7 +160,7 @@ export function forwardDeeplinkToRunningInstance(payload) {
resolve(forwarded) resolve(forwarded)
} }
const client = net.connect(WINDOWS_PIPE_NAME) const client = net.connect({ path: WINDOWS_PIPE_NAME })
const message = JSON.stringify(payload) const message = JSON.stringify(payload)
client.on('connect', () => { client.on('connect', () => {
@ -145,3 +179,38 @@ export function forwardDeeplinkToRunningInstance(payload) {
}) })
}) })
} }
function delay(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function tryForwardViaPipeWithRetries(payload) {
for (let attempt = 0; attempt < PIPE_RETRY_ATTEMPTS; attempt += 1) {
if (await tryForwardViaPipe(payload)) {
return true
}
if (attempt < PIPE_RETRY_ATTEMPTS - 1) {
await delay(PIPE_RETRY_DELAY_MS)
}
}
return false
}
export async function forwardDeeplinkToRunningInstance(payload) {
if (process.platform === 'win32') {
if (await tryForwardViaPipeWithRetries(payload)) {
return true
}
}
if (isPrimaryInstanceRunning()) {
writeDeeplinkSignal(payload)
return true
}
return false
}

View File

@ -9,20 +9,19 @@ import {
writeFileSync writeFileSync
} from 'node:fs' } from 'node:fs'
import net from 'node:net' import net from 'node:net'
import { join } from 'node:path'
import { import {
buildDeeplinkPayload, buildDeeplinkPayload,
findProtocolUrl, findProtocolUrl,
forwardDeeplinkToRunningInstance, forwardDeeplinkToRunningInstance,
getInstanceDir, getInstanceDir,
getLockPath,
getSignalPath, getSignalPath,
isProcessAlive,
SIGNAL_FILE, SIGNAL_FILE,
WINDOWS_PIPE_NAME, WINDOWS_PIPE_NAME,
writeDeeplinkSignal writeDeeplinkSignal
} from './deeplink-ipc.js' } from './deeplink-ipc.js'
const LOCK_FILE = 'primary.lock'
let lockFd = null let lockFd = null
let pollInterval = null let pollInterval = null
let fsWatcher = null let fsWatcher = null
@ -33,23 +32,6 @@ let handlers = {
} }
const pendingMessages = [] const pendingMessages = []
function getLockPath() {
return join(getInstanceDir(), LOCK_FILE)
}
function isProcessAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
return false
}
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
function tryAcquirePrimaryLock() { function tryAcquirePrimaryLock() {
mkdirSync(getInstanceDir(), { recursive: true }) mkdirSync(getInstanceDir(), { recursive: true })
const lockPath = getLockPath() const lockPath = getLockPath()
@ -247,7 +229,7 @@ function startWindowsPipeServer() {
} }
}) })
server.listen(WINDOWS_PIPE_NAME) server.listen({ path: WINDOWS_PIPE_NAME })
pipeServer = server pipeServer = server
} }
@ -274,6 +256,7 @@ export async function ensureSingleInstanceLock({ launchUrl } = {}) {
} }
startWindowsPipeServer() startWindowsPipeServer()
startInstanceSignalWatcher()
return true return true
} }