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 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 = []) {
return items
.map((item) => {
@ -56,14 +113,14 @@ function buildApplicationMenuTemplate() {
}
const template = [
{ role: "fileMenu" },
{ role: "editMenu" },
buildFileMenu(),
buildEditMenu(),
{ label: "View", submenu: viewSubmenu },
{ role: "windowMenu" },
buildWindowMenu(),
];
if (process.platform === "darwin") {
template.unshift({ role: "appMenu" });
template.unshift(buildMacAppMenu());
}
return template;

View File

@ -1,5 +1,8 @@
const PROTOCOL_PREFIX = 'farmcontrol://'
const SINGLE_INSTANCE_HOST = '127.0.0.1'
import { mkdirSync, readFileSync, unlinkSync, writeFileSync, existsSync } from 'node:fs'
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'
// Keep below Electrobun's RPC server range (50000+).
@ -14,6 +17,21 @@ function getSingleInstancePort() {
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) {
const sources = args.filter((arg) => typeof arg === 'string')
@ -42,11 +60,75 @@ export function findProtocolUrl(args) {
return undefined
}
function collectLaunchSources() {
const sources = [...process.argv]
if (process.platform === 'win32') {
sources.push(...getWindowsLaunchSources())
}
return sources
}
function getLaunchProtocolUrl() {
return findProtocolUrl(collectLaunchSources())
}
function buildSecondInstanceMessage() {
const url = findProtocolUrl(process.argv)
const sources = collectLaunchSources()
const url = findProtocolUrl(sources)
return url
? { type: 'deeplink', url, argv: process.argv }
: { type: 'focus', argv: process.argv }
? { type: 'deeplink', url, argv: sources }
: { 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) {
@ -89,25 +171,36 @@ function dispatchMessage(message) {
}
}
function getSingleInstanceUrl() {
return `http://${SINGLE_INSTANCE_HOST}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}`
function getSingleInstanceUrl(host) {
return `http://${host}:${getSingleInstancePort()}${SINGLE_INSTANCE_PATH}`
}
async function forwardToPrimaryInstance(message) {
const response = await fetch(getSingleInstanceUrl(), {
async function tryForwardToPrimaryInstance(message) {
const body = JSON.stringify(message)
for (const host of SINGLE_INSTANCE_HOSTS) {
try {
const response = await fetch(getSingleInstanceUrl(host), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message)
body,
signal: AbortSignal.timeout(1000)
})
if (!response.ok) {
throw new Error(`Forward failed with status ${response.status}`)
if (response.ok) {
return true
}
} catch {
// Try the next host.
}
}
return false
}
function startPrimaryInstanceServer() {
server = Bun.serve({
hostname: SINGLE_INSTANCE_HOST,
hostname: '127.0.0.1',
port: getSingleInstancePort(),
fetch: async (req) => {
const { pathname } = new URL(req.url)
@ -124,6 +217,14 @@ function startPrimaryInstanceServer() {
}
dispatchMessage(message)
const resolved = resolveIncomingMessage(message)
if (resolved?.type === 'deeplink' && resolved.url) {
clearPendingDeeplinkFile()
} else {
processPendingDeeplinkFile()
}
return new Response('ok')
}
})
@ -134,23 +235,26 @@ export async function ensureSingleInstanceLock() {
return true
}
const message = buildSecondInstanceMessage()
stagePendingDeeplink(message)
if (await tryForwardToPrimaryInstance(message)) {
return false
}
try {
startPrimaryInstanceServer()
return true
} catch (error) {
if (error?.code !== 'EADDRINUSE') {
if (error?.code === 'EADDRINUSE') {
if (await tryForwardToPrimaryInstance(message)) {
return false
}
}
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 setSingleInstanceHandlers({ onDeepLink, onFocus }) {
@ -159,6 +263,8 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
while (pendingMessages.length > 0) {
dispatchMessage(pendingMessages.shift())
}
processPendingDeeplinkFile()
}
export function closeSingleInstanceServer() {

View File

@ -11,6 +11,7 @@ const isMacOS = process.platform === 'darwin'
const DEV_SERVER_PORT = 5780
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`
import { findProtocolUrl } from './single-instance.js'
import { getWindowsLaunchSources } from './windows-launch-args.js'
let mainWindow = null
let webviewDomReady = false
@ -84,7 +85,13 @@ export function handleDeepLink(url) {
export function handleDeepLinkFromArgv() {
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)
}

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 []
}
}