From 93df523b7c9409d16bf5f019c57b0a31b3c6e920 Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sun, 2 Aug 2026 20:51:03 +0100 Subject: [PATCH] Enhance single instance and deep link functionality for Windows - 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. --- src/desktop/single-instance.js | 126 +++++++++++++++++++++++++++++++-- src/desktop/window.js | 16 +++-- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/src/desktop/single-instance.js b/src/desktop/single-instance.js index fc30bd7..53c27da 100644 --- a/src/desktop/single-instance.js +++ b/src/desktop/single-instance.js @@ -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 diff --git a/src/desktop/window.js b/src/desktop/window.js index 1fe2733..e400388 100644 --- a/src/desktop/window.js +++ b/src/desktop/window.js @@ -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) } }