Compare commits

...

2 Commits

Author SHA1 Message Date
6dad9c820c Enhance single instance management and deep link processing
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good
- Refactored single instance handling to support multiple local hosts, improving reliability in message forwarding.
- Introduced functions for managing pending deep links, including staging, consuming, and clearing deeplink files.
- Updated `getSingleInstanceUrl` to accept dynamic host parameters and improved error handling in instance locking.
- Added Windows-specific launch argument retrieval to enhance deep link handling on that platform.
2026-08-02 19:16:44 +01:00
3b28aa4308 Add application menu structure for macOS and Windows
- Implemented functions to build application menus for macOS, including 'About', 'Edit', 'File', and 'Window' menus.
- Enhanced the application menu template to include these new menu structures, improving user navigation and functionality.
- Ensured compatibility with macOS by conditionally adding the app menu based on the platform.
2026-08-02 19:04:14 +01:00
4 changed files with 240 additions and 31 deletions

View File

@ -5,6 +5,63 @@ const SIDEBAR_MENU_ACTION_PREFIX = "sidebar-nav:";
let sidebarViewMenuSections = []; let sidebarViewMenuSections = [];
let navigateHandler = null; let navigateHandler = null;
function buildMacAppMenu() {
return {
submenu: [
{
label: "About Farm Control",
action: `${SIDEBAR_MENU_ACTION_PREFIX}/dashboard/management/about`,
},
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "showAll" },
{ type: "separator" },
{ role: "quit" },
],
};
}
function buildEditMenu() {
return {
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "pasteAndMatchStyle" },
{ role: "delete" },
{ type: "separator" },
{ role: "selectAll" },
{ type: "separator" },
{ role: "startSpeaking" },
{ role: "stopSpeaking" },
],
};
}
function buildFileMenu() {
return {
label: "File",
submenu: [{ role: "close" }],
};
}
function buildWindowMenu() {
return {
label: "Window",
submenu: [
{ role: "minimize" },
{ role: "zoom" },
{ type: "separator" },
{ role: "bringAllToFront" },
],
};
}
function toMenuItems(items = []) { function toMenuItems(items = []) {
return items return items
.map((item) => { .map((item) => {
@ -56,14 +113,14 @@ function buildApplicationMenuTemplate() {
} }
const template = [ const template = [
{ role: "fileMenu" }, buildFileMenu(),
{ role: "editMenu" }, buildEditMenu(),
{ label: "View", submenu: viewSubmenu }, { label: "View", submenu: viewSubmenu },
{ role: "windowMenu" }, buildWindowMenu(),
]; ];
if (process.platform === "darwin") { if (process.platform === "darwin") {
template.unshift({ role: "appMenu" }); template.unshift(buildMacAppMenu());
} }
return template; return template;

View File

@ -1,5 +1,8 @@
const PROTOCOL_PREFIX = 'farmcontrol://' import { mkdirSync, readFileSync, unlinkSync, writeFileSync, existsSync } from 'node:fs'
const SINGLE_INSTANCE_HOST = '127.0.0.1' import { join } from 'node:path'
import { getWindowsLaunchSources } from './windows-launch-args.js'
const SINGLE_INSTANCE_HOSTS = ['127.0.0.1', 'localhost']
const SINGLE_INSTANCE_PATH = '/farmcontrol-instance' const SINGLE_INSTANCE_PATH = '/farmcontrol-instance'
// Keep below Electrobun's RPC server range (50000+). // Keep below Electrobun's RPC server range (50000+).
@ -14,6 +17,21 @@ function getSingleInstancePort() {
return 30000 + (Math.abs(hash) % 10000) return 30000 + (Math.abs(hash) % 10000)
} }
function getInstanceDir() {
if (process.platform === 'win32') {
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
if (localAppData) {
return join(localAppData, 'com.tombutcher.farmcontrol', 'instance')
}
}
return join(process.env.TMPDIR || '/tmp', 'com.tombutcher.farmcontrol', 'instance')
}
function getPendingDeeplinkPath() {
return join(getInstanceDir(), 'pending-deeplink.json')
}
export function findProtocolUrl(args) { export function findProtocolUrl(args) {
const sources = args.filter((arg) => typeof arg === 'string') const sources = args.filter((arg) => typeof arg === 'string')
@ -42,11 +60,75 @@ export function findProtocolUrl(args) {
return undefined return undefined
} }
function collectLaunchSources() {
const sources = [...process.argv]
if (process.platform === 'win32') {
sources.push(...getWindowsLaunchSources())
}
return sources
}
function getLaunchProtocolUrl() {
return findProtocolUrl(collectLaunchSources())
}
function buildSecondInstanceMessage() { function buildSecondInstanceMessage() {
const url = findProtocolUrl(process.argv) const sources = collectLaunchSources()
const url = findProtocolUrl(sources)
return url return url
? { type: 'deeplink', url, argv: process.argv } ? { type: 'deeplink', url, argv: sources }
: { type: 'focus', argv: process.argv } : { type: 'focus', argv: sources }
}
function stagePendingDeeplink(message) {
const url = message?.url || findProtocolUrl(message?.argv || [])
if (!url) return
try {
mkdirSync(getInstanceDir(), { recursive: true })
writeFileSync(
getPendingDeeplinkPath(),
JSON.stringify({ url, timestamp: Date.now() }),
'utf8'
)
} catch (error) {
console.warn('Failed to stage pending deeplink:', error)
}
}
function clearPendingDeeplinkFile() {
const filePath = getPendingDeeplinkPath()
if (!existsSync(filePath)) return
try {
unlinkSync(filePath)
} catch {
// Ignore cleanup failures.
}
}
function consumePendingDeeplinkFile() {
const filePath = getPendingDeeplinkPath()
if (!existsSync(filePath)) return null
try {
const data = JSON.parse(readFileSync(filePath, 'utf8'))
clearPendingDeeplinkFile()
return data?.url || null
} catch {
clearPendingDeeplinkFile()
return null
}
}
function processPendingDeeplinkFile() {
const url = consumePendingDeeplinkFile()
if (url) {
dispatchMessage({ type: 'deeplink', url })
}
} }
function resolveIncomingMessage(message) { function resolveIncomingMessage(message) {
@ -89,25 +171,36 @@ function dispatchMessage(message) {
} }
} }
function getSingleInstanceUrl() { function getSingleInstanceUrl(host) {
return `http://${SINGLE_INSTANCE_HOST}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}` return `http://${host}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}`
} }
async function forwardToPrimaryInstance(message) { async function tryForwardToPrimaryInstance(message) {
const response = await fetch(getSingleInstanceUrl(), { const body = JSON.stringify(message)
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
})
if (!response.ok) { for (const host of SINGLE_INSTANCE_HOSTS) {
throw new Error(`Forward failed with status ${response.status}`) try {
const response = await fetch(getSingleInstanceUrl(host), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(1000)
})
if (response.ok) {
return true
}
} catch {
// Try the next host.
}
} }
return false
} }
function startPrimaryInstanceServer() { function startPrimaryInstanceServer() {
server = Bun.serve({ server = Bun.serve({
hostname: SINGLE_INSTANCE_HOST, hostname: '127.0.0.1',
port: getSingleInstancePort(), port: getSingleInstancePort(),
fetch: async (req) => { fetch: async (req) => {
const { pathname } = new URL(req.url) const { pathname } = new URL(req.url)
@ -124,6 +217,14 @@ function startPrimaryInstanceServer() {
} }
dispatchMessage(message) dispatchMessage(message)
const resolved = resolveIncomingMessage(message)
if (resolved?.type === 'deeplink' && resolved.url) {
clearPendingDeeplinkFile()
} else {
processPendingDeeplinkFile()
}
return new Response('ok') return new Response('ok')
} }
}) })
@ -134,22 +235,25 @@ export async function ensureSingleInstanceLock() {
return true return true
} }
const message = buildSecondInstanceMessage()
stagePendingDeeplink(message)
if (await tryForwardToPrimaryInstance(message)) {
return false
}
try { try {
startPrimaryInstanceServer() startPrimaryInstanceServer()
return true return true
} catch (error) { } catch (error) {
if (error?.code !== 'EADDRINUSE') { if (error?.code === 'EADDRINUSE') {
console.warn('Single instance lock failed:', error) if (await tryForwardToPrimaryInstance(message)) {
return true return false
}
} }
try { console.warn('Single instance lock failed:', error)
await forwardToPrimaryInstance(buildSecondInstanceMessage()) return true
} catch (forwardError) {
console.warn('Failed to forward to running instance:', forwardError)
}
return false
} }
} }
@ -159,6 +263,8 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
while (pendingMessages.length > 0) { while (pendingMessages.length > 0) {
dispatchMessage(pendingMessages.shift()) dispatchMessage(pendingMessages.shift())
} }
processPendingDeeplinkFile()
} }
export function closeSingleInstanceServer() { export function closeSingleInstanceServer() {

View File

@ -11,6 +11,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}`
import { findProtocolUrl } from './single-instance.js' import { findProtocolUrl } from './single-instance.js'
import { getWindowsLaunchSources } from './windows-launch-args.js'
let mainWindow = null let mainWindow = null
let webviewDomReady = false let webviewDomReady = false
@ -84,7 +85,13 @@ export function handleDeepLink(url) {
export function handleDeepLinkFromArgv() { export function handleDeepLinkFromArgv() {
if (process.platform === 'darwin') return if (process.platform === 'darwin') return
const url = findProtocolUrl(process.argv)
const sources = [...process.argv]
if (process.platform === 'win32') {
sources.push(...getWindowsLaunchSources())
}
const url = findProtocolUrl(sources)
if (url) handleDeepLink(url) if (url) handleDeepLink(url)
} }

View File

@ -0,0 +1,39 @@
export function getWindowsLaunchSources() {
if (process.platform !== 'win32') {
return []
}
try {
const proc = Bun.spawnSync({
cmd: [
'powershell.exe',
'-NoProfile',
'-NonInteractive',
'-Command',
`$pid = $PID
for ($i = 0; $i -lt 6; $i++) {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue
if (-not $proc) { break }
if ($proc.CommandLine) { $proc.CommandLine }
if ($proc.CommandLine -match 'farmcontrol://') { break }
if (-not $proc.ParentProcessId) { break }
$pid = $proc.ParentProcessId
}`
],
stdout: 'pipe',
stderr: 'pipe'
})
if (proc.exitCode !== 0) {
return []
}
return proc.stdout
.toString()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}