Refactor single instance management and deep link handling
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
- 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:
parent
1c8fc51db2
commit
40ccacc9a8
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}).catch(reject)
|
||||
})
|
||||
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' }
|
||||
}
|
||||
|
||||
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 (message.type === 'deeplink' && message.url) {
|
||||
onDeepLink?.(message.url)
|
||||
} else if (message.type === 'focus') {
|
||||
onFocus?.()
|
||||
}
|
||||
if (req.method !== 'POST' || pathname !== SINGLE_INSTANCE_PATH) {
|
||||
return new Response('Not found', { status: 404 })
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user