farmcontrol-ui/src/desktop/deeplink-ipc.js
Tom Butcher de93658df1
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Enhance Windows instance management and path resolution
- Introduced a new function `isPrimaryInstanceReachable` to check the reachability of the primary instance, improving instance handling on Windows.
- Updated `forwardDeeplinkToRunningInstance` to include an option for signal fallback, enhancing flexibility in deeplink forwarding.
- Implemented `tryRecoverUnresponsivePrimaryLock` to handle unresponsive primary instances by removing stale lock files, improving application reliability.
- Refactored `resolveWindowsBinDir` to utilize unique path resolution, ensuring accurate directory handling for Windows applications.
- Enhanced the `ensureWindowsWorkingDirectory` function to streamline directory changes, improving application behavior.
2026-08-03 00:21:04 +01:00

232 lines
4.9 KiB
JavaScript

import {
existsSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import os from 'node:os'
import { join } from 'node:path'
export const PROTOCOL_PREFIX = 'farmcontrol://'
export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
export const SIGNAL_FILE = 'instance-signal.json'
export const LOCK_FILE = 'primary.lock'
const FORWARD_TIMEOUT_MS = 750
const PIPE_RETRY_ATTEMPTS = 3
const PIPE_RETRY_DELAY_MS = 100
export function getInstanceDir() {
if (process.platform === 'win32') {
return join(
os.homedir(),
'AppData',
'Local',
'com.tombutcher.farmcontrol',
'instance'
)
}
return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance')
}
export function getSignalPath() {
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) {
const directMatch = args.find(
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
)
if (directMatch) {
return directMatch
}
const sources = args.filter((arg) => typeof arg === 'string')
for (const arg of sources) {
const trimmed = arg.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
}
export function buildDeeplinkPayload(url, argv = process.argv) {
const commandLine = [...argv]
return url
? { type: 'deeplink', url, argv: commandLine }
: { type: 'focus', argv: 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 []
}
}
export function writeDeeplinkSignal(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(queue), 'utf8')
writeFileSync(signalPath, readFileSync(tempPath))
unlinkSync(tempPath)
}
function tryForwardViaPipe(payload) {
return new Promise((resolve) => {
let settled = false
const finish = (forwarded) => {
if (settled) return
settled = true
resolve(forwarded)
}
const client = net.connect({ path: WINDOWS_PIPE_NAME })
const message = JSON.stringify(payload)
client.on('connect', () => {
client.write(message)
client.end()
finish(true)
})
client.on('error', () => {
finish(false)
})
client.setTimeout(FORWARD_TIMEOUT_MS, () => {
client.destroy()
finish(false)
})
})
}
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 isPrimaryInstanceReachable() {
if (process.platform === 'win32') {
return tryForwardViaPipeWithRetries({ type: 'focus' })
}
return isPrimaryInstanceRunning()
}
export async function forwardDeeplinkToRunningInstance(
payload,
{ allowSignalFallback = true } = {}
) {
if (process.platform === 'win32') {
if (await tryForwardViaPipeWithRetries(payload)) {
return true
}
}
if (allowSignalFallback && isPrimaryInstanceRunning()) {
writeDeeplinkSignal(payload)
return true
}
return false
}