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

- Introduced a Windows pipe server to facilitate communication between instances, improving single instance management.
- Updated `ensureSingleInstanceLock` to handle payload forwarding and signal writing more effectively.
- Refactored deep link handling to utilize the new protocol detection logic, enhancing URL parsing and navigation.
- Improved error handling and connection management for the Windows-specific implementation.
This commit is contained in:
Tom Butcher 2026-08-02 20:51:03 +01:00
parent 3ed7945b7c
commit 93df523b7c
2 changed files with 128 additions and 14 deletions

View File

@ -8,18 +8,23 @@ import {
watch,
writeFileSync
} from 'node:fs'
import net from 'node:net'
import { join } from 'node:path'
import {
getWindowsLaunchSources,
getWindowsLaunchUrl
} from './windows-launch-args.js'
const PROTOCOL_PREFIX = 'farmcontrol://'
const LOCK_FILE = 'primary.lock'
const SIGNAL_FILE = 'instance-signal.json'
const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
const FORWARD_TIMEOUT_MS = 750
let lockFd = null
let pollInterval = null
let fsWatcher = null
let pipeServer = null
let handlers = {
onDeepLink: null,
onFocus: null
@ -111,6 +116,14 @@ function releasePrimaryLock() {
}
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) {
@ -126,8 +139,7 @@ export function findProtocolUrl(args) {
}
}
const combined = sources.join(' ')
const combinedMatch = combined.match(/farmcontrol:\/\/\S+/i)
const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i)
if (combinedMatch) {
const rawUrl = combinedMatch[0].replace(/['"]+$/g, '')
try {
@ -151,6 +163,11 @@ function collectLaunchSources() {
}
export function captureLaunchUrl() {
const argvUrl = findProtocolUrl(process.argv)
if (argvUrl) {
return argvUrl
}
if (process.platform === 'win32') {
return getWindowsLaunchUrl() || findProtocolUrl(collectLaunchSources())
}
@ -159,12 +176,12 @@ export function captureLaunchUrl() {
}
function buildLaunchPayload(launchUrl = captureLaunchUrl()) {
const sources = collectLaunchSources()
const url = launchUrl || findProtocolUrl(sources)
const commandLine = collectLaunchSources()
const url = launchUrl || findProtocolUrl(commandLine)
return url
? { type: 'deeplink', url, argv: sources }
: { type: 'focus', argv: sources }
? { type: 'deeplink', url, argv: commandLine }
: { type: 'focus', argv: commandLine }
}
function readSignalQueue({ clear = true } = {}) {
@ -274,17 +291,103 @@ function startInstanceSignalWatcher() {
pollInterval = setInterval(processInstanceSignals, 100)
}
function forwardToRunningInstance(payload) {
if (process.platform !== 'win32') {
return Promise.resolve(false)
}
return new Promise((resolve) => {
let settled = false
const finish = (forwarded) => {
if (settled) return
settled = true
resolve(forwarded)
}
const client = net.connect(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 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(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 forwardToRunningInstance(payload)) {
return false
}
if (!tryAcquirePrimaryLock()) {
if (await forwardToRunningInstance(payload)) {
return false
}
writeInstanceSignal(payload)
return false
}
startWindowsPipeServer()
return true
}
if (tryAcquirePrimaryLock()) {
startInstanceSignalWatcher()
return true
}
writeInstanceSignal(buildLaunchPayload(launchUrl))
writeInstanceSignal(payload)
return false
}
@ -299,6 +402,15 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
}
export function closeSingleInstanceServer() {
if (pipeServer) {
try {
pipeServer.close()
} catch {
// Ignore close failures.
}
pipeServer = null
}
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null

View File

@ -10,7 +10,7 @@ const isMacOS = process.platform === 'darwin'
const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
import { captureLaunchUrl, setSingleInstanceHandlers } from './single-instance.js'
import { findProtocolUrl, setSingleInstanceHandlers } from './single-instance.js'
let mainWindow = null
let webviewDomReady = false
@ -90,6 +90,8 @@ export function openInternalUrl(url) {
return true
}
const PROTOCOL_PREFIX = 'farmcontrol://'
function parseDeepLinkPath(url) {
if (!url || typeof url !== 'string') {
return null
@ -99,12 +101,11 @@ function parseDeepLinkPath(url) {
return url
}
const match = url.match(/^farmcontrol:\/\/app(.*)$/i)
if (!match) {
if (!url.startsWith(`${PROTOCOL_PREFIX}app`)) {
return null
}
const redirectPath = match[1] || '/'
const redirectPath = url.slice(`${PROTOCOL_PREFIX}app`.length) || '/'
const normalizedPath = redirectPath.startsWith('/')
? redirectPath
: `/${redirectPath}`
@ -127,11 +128,12 @@ export function handleDeepLink(url) {
return true
}
export function handleDeepLinkFromArgv(launchUrl = captureLaunchUrl()) {
export function handleDeepLinkFromArgv(launchUrl) {
if (process.platform === 'darwin') return
if (launchUrl) {
handleDeepLink(launchUrl)
const url = launchUrl || findProtocolUrl(process.argv)
if (url) {
handleDeepLink(url)
}
}