Compare commits
2 Commits
40ccacc9a8
...
6dad9c820c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6dad9c820c | |||
| 3b28aa4308 |
@ -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;
|
||||
|
||||
@ -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(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
async function tryForwardToPrimaryInstance(message) {
|
||||
const body = JSON.stringify(message)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Forward failed with status ${response.status}`)
|
||||
for (const host of SINGLE_INSTANCE_HOSTS) {
|
||||
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() {
|
||||
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,22 +235,25 @@ 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') {
|
||||
console.warn('Single instance lock failed:', error)
|
||||
return true
|
||||
if (error?.code === 'EADDRINUSE') {
|
||||
if (await tryForwardToPrimaryInstance(message)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await forwardToPrimaryInstance(buildSecondInstanceMessage())
|
||||
} catch (forwardError) {
|
||||
console.warn('Failed to forward to running instance:', forwardError)
|
||||
}
|
||||
|
||||
return false
|
||||
console.warn('Single instance lock failed:', error)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,6 +263,8 @@ export function setSingleInstanceHandlers({ onDeepLink, onFocus }) {
|
||||
while (pendingMessages.length > 0) {
|
||||
dispatchMessage(pendingMessages.shift())
|
||||
}
|
||||
|
||||
processPendingDeeplinkFile()
|
||||
}
|
||||
|
||||
export function closeSingleInstanceServer() {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
39
src/desktop/windows-launch-args.js
Normal file
39
src/desktop/windows-launch-args.js
Normal 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 []
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user