Enhance single instance management and deep link processing
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
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:
parent
813b43595b
commit
fb81cfedd2
@ -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,51 +154,33 @@ function buildSecondInstanceMessage() {
|
||||
: { type: 'focus', argv: sources }
|
||||
}
|
||||
|
||||
function stagePendingDeeplink(message) {
|
||||
const url = message?.url || findProtocolUrl(message?.argv || [])
|
||||
if (!url) return
|
||||
function writeInstanceSignal(payload) {
|
||||
const signalPath = getSignalPath()
|
||||
mkdirSync(getInstanceDir(), { recursive: true })
|
||||
|
||||
try {
|
||||
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
|
||||
|
||||
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()
|
||||
function readAndClearInstanceSignal() {
|
||||
const signalPath = getSignalPath()
|
||||
if (!existsSync(signalPath)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function processPendingDeeplinkFile() {
|
||||
const url = consumePendingDeeplinkFile()
|
||||
if (url) {
|
||||
dispatchMessage({ type: 'deeplink', url })
|
||||
try {
|
||||
const payload = JSON.parse(readFileSync(signalPath, 'utf8'))
|
||||
unlinkSync(signalPath)
|
||||
return payload
|
||||
} catch {
|
||||
try {
|
||||
unlinkSync(signalPath)
|
||||
} catch {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
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)
|
||||
|
||||
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.
|
||||
}
|
||||
function startInstanceSignalWatcher() {
|
||||
if (process.platform === 'darwin' || pollInterval) {
|
||||
return
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
mkdirSync(getInstanceDir(), { recursive: true })
|
||||
processInstanceSignal()
|
||||
|
||||
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 })
|
||||
try {
|
||||
fsWatcher = watch(getInstanceDir(), (_event, filename) => {
|
||||
if (!filename || filename === SIGNAL_FILE) {
|
||||
setTimeout(processInstanceSignal, 25)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// Polling below covers filesystems without reliable watch support.
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
})
|
||||
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()
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user