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 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;
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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