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 {
closeSingleInstanceServer,
ensureSingleInstanceLock
ensureSingleInstanceLock,
setSingleInstanceHandlers
} from '../desktop/single-instance.js'
import {
const gotSingleInstanceLock = await ensureSingleInstanceLock()
if (!gotSingleInstanceLock) {
process.exit(0)
}
const { createAppRpc } = await import('../desktop/rpc.js')
const {
registerGlobalShortcuts,
unregisterGlobalShortcuts
} from '../desktop/spotlight.js'
import {
} = await import('../desktop/spotlight.js')
const {
createMainWindow,
handleDeepLink,
handleDeepLinkFromArgv,
setupDevAuthServer,
setupNavigationGestures,
showMainWindow
} from '../desktop/window.js'
} = await import('../desktop/window.js')
const gotSingleInstanceLock = await ensureSingleInstanceLock({
setSingleInstanceHandlers({
onDeepLink: handleDeepLink,
onFocus: showMainWindow
})
if (!gotSingleInstanceLock) {
process.exit(0)
}
const rpc = createAppRpc()
const mainWindow = await createMainWindow(rpc)

View File

@ -1,7 +1,8 @@
const PROTOCOL_PREFIX = 'farmcontrol://'
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() {
const identifier = 'com.tombutcher.farmcontrol'
let hash = 0
@ -10,84 +11,131 @@ function getSingleInstancePort() {
hash = (hash * 31 + char.charCodeAt(0)) | 0
}
return 49152 + (Math.abs(hash) % 16383)
return 30000 + (Math.abs(hash) % 10000)
}
function findProtocolUrl(args) {
return args.find(
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
)
export function findProtocolUrl(args) {
const sources = args.filter((arg) => typeof arg === 'string')
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() {
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) {
const text = data.toString().trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
function resolveIncomingMessage(message) {
if (!message || typeof message !== 'object') {
return null
}
}
async function forwardToPrimaryInstance(message) {
await new Promise((resolve, reject) => {
Bun.connect({
hostname: SINGLE_INSTANCE_HOST,
port: getSingleInstancePort(),
socket: {
open(socket) {
socket.write(`${JSON.stringify(message)}\n`)
socket.end()
resolve()
},
data() {},
error(_socket, error) {
reject(error)
},
close() {
resolve()
if (message.type === 'deeplink') {
const url = message.url || findProtocolUrl(message.argv || [])
return url ? { type: 'deeplink', url } : { type: 'focus' }
}
if (message.type === 'focus') {
return { type: 'focus' }
}
}).catch(reject)
})
return null
}
let server = null
let handlers = {
onDeepLink: null,
onFocus: null
}
const pendingMessages = []
function startPrimaryInstanceServer({ onDeepLink, onFocus }) {
server = Bun.listen({
function dispatchMessage(message) {
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,
port: getSingleInstancePort(),
socket: {
data(socket, data) {
const message = parseIncomingMessage(data)
socket.end()
fetch: async (req) => {
const { pathname } = new URL(req.url)
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') {
return true
}
try {
startPrimaryInstanceServer({ onDeepLink, onFocus })
startPrimaryInstanceServer()
return true
} catch (error) {
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() {
server?.stop?.()
server = 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}`
const PROTOCOL_PREFIX = 'farmcontrol://'
import { findProtocolUrl } from './single-instance.js'
let mainWindow = null
let webviewDomReady = false
@ -73,15 +73,13 @@ function sendNavigateToRenderer(redirectPath) {
}
export function handleDeepLink(url) {
if (!url?.startsWith(`${PROTOCOL_PREFIX}app`)) return
const redirectPath = url.replace(`${PROTOCOL_PREFIX}app`, '') || '/'
sendNavigateToRenderer(redirectPath)
}
if (!url || typeof url !== 'string') return
function findProtocolUrl(args) {
return args.find(
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
)
const match = url.match(/^farmcontrol:\/\/app(.*)$/i)
if (!match) return
const redirectPath = match[1] || '/'
sendNavigateToRenderer(redirectPath.startsWith('/') ? redirectPath : `/${redirectPath}`)
}
export function handleDeepLinkFromArgv() {