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 {
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user