Compare commits

...

2 Commits

Author SHA1 Message Date
611cd4aad5 Implement single instance management and enhance deep link handling
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Added single instance server functionality to prevent multiple app instances.
- Introduced `ensureSingleInstanceLock` and `closeSingleInstanceServer` methods for managing instance locks.
- Updated main application entry point to handle deep links and focus events.
- Enhanced window management by showing the main window on focus and handling deep links appropriately.
2026-08-02 16:25:09 +01:00
1b77428a8a Fix button order in DashboardWindowButtons component for consistent window management display 2026-08-02 16:24:54 +01:00
4 changed files with 134 additions and 2 deletions

View File

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

View File

@ -54,8 +54,8 @@ const DashboardWindowButtons = () => {
</>
) : (
<>
{maximizeButton}
{minimizeButton}
{maximizeButton}
{closeButton}
</>
)}

View File

@ -0,0 +1,111 @@
const PROTOCOL_PREFIX = 'farmcontrol://'
const SINGLE_INSTANCE_HOST = '127.0.0.1'
// Stable port derived from the app identifier so multiple apps don't collide.
function getSingleInstancePort() {
const identifier = 'com.tombutcher.farmcontrol'
let hash = 0
for (const char of identifier) {
hash = (hash * 31 + char.charCodeAt(0)) | 0
}
return 49152 + (Math.abs(hash) % 16383)
}
function findProtocolUrl(args) {
return args.find(
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
)
}
function buildSecondInstanceMessage() {
const url = findProtocolUrl(process.argv)
return url ? { type: 'deeplink', url } : { type: 'focus' }
}
function parseIncomingMessage(data) {
const text = data.toString().trim()
if (!text) return null
try {
return JSON.parse(text)
} catch {
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)
})
}
let server = null
function startPrimaryInstanceServer({ onDeepLink, onFocus }) {
server = Bun.listen({
hostname: SINGLE_INSTANCE_HOST,
port: getSingleInstancePort(),
socket: {
data(socket, data) {
const message = parseIncomingMessage(data)
socket.end()
if (!message) return
if (message.type === 'deeplink' && message.url) {
onDeepLink?.(message.url)
} else if (message.type === 'focus') {
onFocus?.()
}
}
}
})
}
export async function ensureSingleInstanceLock({ onDeepLink, onFocus }) {
if (process.platform === 'darwin') {
return true
}
try {
startPrimaryInstanceServer({ onDeepLink, onFocus })
return true
} catch (error) {
if (error?.code !== 'EADDRINUSE') {
console.warn('Single instance lock failed:', error)
return true
}
try {
await forwardToPrimaryInstance(buildSecondInstanceMessage())
} catch (forwardError) {
console.warn('Failed to forward to running instance:', forwardError)
}
return false
}
}
export function closeSingleInstanceServer() {
server?.stop?.()
server = null
}

View File

@ -20,6 +20,11 @@ export function getMainWindow() {
return mainWindow
}
export function showMainWindow() {
mainWindow?.show?.()
mainWindow?.activate?.()
}
export async function getMainViewUrl() {
const channel = await Updater.localInfo.channel()
if (channel === 'dev' || process.env.NODE_ENV === 'development') {