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 { 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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user