Refactor single instance management and deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Updated single instance server functionality to improve message dispatching and handling of deep links.
- Introduced `setSingleInstanceHandlers` to manage event callbacks for deep link and focus events.
- Enhanced `findProtocolUrl` to support better URL parsing and handling of incoming messages.
- Refactored `startPrimaryInstanceServer` to utilize fetch for message forwarding, improving reliability.
This commit is contained in:
Tom Butcher 2026-08-02 18:43:33 +01:00
parent 1c8fc51db2
commit 40ccacc9a8
3 changed files with 129 additions and 71 deletions

View File

@ -1,30 +1,34 @@
import { createAppRpc } from '../desktop/rpc.js'
import { import {
closeSingleInstanceServer, closeSingleInstanceServer,
ensureSingleInstanceLock ensureSingleInstanceLock,
setSingleInstanceHandlers
} from '../desktop/single-instance.js' } from '../desktop/single-instance.js'
import {
const gotSingleInstanceLock = await ensureSingleInstanceLock()
if (!gotSingleInstanceLock) {
process.exit(0)
}
const { createAppRpc } = await import('../desktop/rpc.js')
const {
registerGlobalShortcuts, registerGlobalShortcuts,
unregisterGlobalShortcuts unregisterGlobalShortcuts
} from '../desktop/spotlight.js' } = await import('../desktop/spotlight.js')
import { const {
createMainWindow, createMainWindow,
handleDeepLink, handleDeepLink,
handleDeepLinkFromArgv, handleDeepLinkFromArgv,
setupDevAuthServer, setupDevAuthServer,
setupNavigationGestures, setupNavigationGestures,
showMainWindow showMainWindow
} from '../desktop/window.js' } = await import('../desktop/window.js')
const gotSingleInstanceLock = await ensureSingleInstanceLock({ setSingleInstanceHandlers({
onDeepLink: handleDeepLink, onDeepLink: handleDeepLink,
onFocus: showMainWindow onFocus: showMainWindow
}) })
if (!gotSingleInstanceLock) {
process.exit(0)
}
const rpc = createAppRpc() const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc) const mainWindow = await createMainWindow(rpc)

View File

@ -1,7 +1,8 @@
const PROTOCOL_PREFIX = 'farmcontrol://' const PROTOCOL_PREFIX = 'farmcontrol://'
const SINGLE_INSTANCE_HOST = '127.0.0.1' const SINGLE_INSTANCE_HOST = '127.0.0.1'
const SINGLE_INSTANCE_PATH = '/farmcontrol-instance'
// Stable port derived from the app identifier so multiple apps don't collide. // Keep below Electrobun's RPC server range (50000+).
function getSingleInstancePort() { function getSingleInstancePort() {
const identifier = 'com.tombutcher.farmcontrol' const identifier = 'com.tombutcher.farmcontrol'
let hash = 0 let hash = 0
@ -10,84 +11,131 @@ function getSingleInstancePort() {
hash = (hash * 31 + char.charCodeAt(0)) | 0 hash = (hash * 31 + char.charCodeAt(0)) | 0
} }
return 49152 + (Math.abs(hash) % 16383) return 30000 + (Math.abs(hash) % 10000)
} }
function findProtocolUrl(args) { export function findProtocolUrl(args) {
return args.find( const sources = args.filter((arg) => typeof arg === 'string')
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
) for (const arg of sources) {
const trimmed = arg.trim().replace(/^['"]+|['"]+$/g, '')
const match = trimmed.match(/farmcontrol:\/\/\S+/i)
if (match) {
try {
return decodeURI(match[0])
} catch {
return match[0]
}
}
}
const combined = sources.join(' ')
const combinedMatch = combined.match(/farmcontrol:\/\/\S+/i)
if (combinedMatch) {
try {
return decodeURI(combinedMatch[0])
} catch {
return combinedMatch[0]
}
}
return undefined
} }
function buildSecondInstanceMessage() { function buildSecondInstanceMessage() {
const url = findProtocolUrl(process.argv) const url = findProtocolUrl(process.argv)
return url ? { type: 'deeplink', url } : { type: 'focus' } return url
? { type: 'deeplink', url, argv: process.argv }
: { type: 'focus', argv: process.argv }
} }
function parseIncomingMessage(data) { function resolveIncomingMessage(message) {
const text = data.toString().trim() if (!message || typeof message !== 'object') {
if (!text) return null
try {
return JSON.parse(text)
} catch {
return null return null
} }
}
async function forwardToPrimaryInstance(message) { if (message.type === 'deeplink') {
await new Promise((resolve, reject) => { const url = message.url || findProtocolUrl(message.argv || [])
Bun.connect({ return url ? { type: 'deeplink', url } : { type: 'focus' }
hostname: SINGLE_INSTANCE_HOST, }
port: getSingleInstancePort(),
socket: { if (message.type === 'focus') {
open(socket) { return { type: 'focus' }
socket.write(`${JSON.stringify(message)}\n`) }
socket.end()
resolve() return null
},
data() {},
error(_socket, error) {
reject(error)
},
close() {
resolve()
}
}
}).catch(reject)
})
} }
let server = null let server = null
let handlers = {
onDeepLink: null,
onFocus: null
}
const pendingMessages = []
function startPrimaryInstanceServer({ onDeepLink, onFocus }) { function dispatchMessage(message) {
server = Bun.listen({ const resolved = resolveIncomingMessage(message)
if (!resolved) return
if (!handlers.onDeepLink && !handlers.onFocus) {
pendingMessages.push(resolved)
return
}
if (resolved.type === 'deeplink' && resolved.url) {
handlers.onDeepLink?.(resolved.url)
} else if (resolved.type === 'focus') {
handlers.onFocus?.()
}
}
function getSingleInstanceUrl() {
return `http://${SINGLE_INSTANCE_HOST}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}`
}
async function forwardToPrimaryInstance(message) {
const response = await fetch(getSingleInstanceUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
if (!response.ok) {
throw new Error(`Forward failed with status ${response.status}`)
}
}
function startPrimaryInstanceServer() {
server = Bun.serve({
hostname: SINGLE_INSTANCE_HOST, hostname: SINGLE_INSTANCE_HOST,
port: getSingleInstancePort(), port: getSingleInstancePort(),
socket: { fetch: async (req) => {
data(socket, data) { const { pathname } = new URL(req.url)
const message = parseIncomingMessage(data)
socket.end()
if (!message) return if (req.method !== 'POST' || pathname !== SINGLE_INSTANCE_PATH) {
return new Response('Not found', { status: 404 })
if (message.type === 'deeplink' && message.url) {
onDeepLink?.(message.url)
} else if (message.type === 'focus') {
onFocus?.()
}
} }
let message
try {
message = await req.json()
} catch {
return new Response('Bad request', { status: 400 })
}
dispatchMessage(message)
return new Response('ok')
} }
}) })
} }
export async function ensureSingleInstanceLock({ onDeepLink, onFocus }) { export async function ensureSingleInstanceLock() {
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
return true return true
} }
try { try {
startPrimaryInstanceServer({ onDeepLink, onFocus }) startPrimaryInstanceServer()
return true return true
} catch (error) { } catch (error) {
if (error?.code !== 'EADDRINUSE') { if (error?.code !== 'EADDRINUSE') {
@ -105,6 +153,14 @@ export async function ensureSingleInstanceLock({ onDeepLink, onFocus }) {
} }
} }
export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
handlers = { onDeepLink, onFocus }
while (pendingMessages.length > 0) {
dispatchMessage(pendingMessages.shift())
}
}
export function closeSingleInstanceServer() { export function closeSingleInstanceServer() {
server?.stop?.() server?.stop?.()
server = null server = null

View File

@ -10,7 +10,7 @@ const isMacOS = process.platform === 'darwin'
const DEV_SERVER_PORT = 5780 const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}` const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
const PROTOCOL_PREFIX = 'farmcontrol://' import { findProtocolUrl } from './single-instance.js'
let mainWindow = null let mainWindow = null
let webviewDomReady = false let webviewDomReady = false
@ -73,15 +73,13 @@ function sendNavigateToRenderer(redirectPath) {
} }
export function handleDeepLink(url) { export function handleDeepLink(url) {
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return if (!url || typeof url !== 'string') return
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/'
sendNavigateToRenderer(redirectPath)
}
function findProtocolUrl(args) { const match = url.match(/^farmcontrol:\/\/app(.*)$/i)
return args.find( if (!match) return
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
) const redirectPath = match[1] || '/'
sendNavigateToRenderer(redirectPath.startsWith('/') ? redirectPath : `/${redirectPath}`)
} }
export function handleDeepLinkFromArgv() { export function handleDeepLinkFromArgv() {