Implement deeplink functionality and update installer for Windows
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Introduced a new script to build the Windows deeplink executable, enhancing deep link handling. - Updated the installer script to replace references from `launcher.exe` to `deeplink.exe` for better integration with the new deeplink functionality. - Added a new `deeplink.js` file to manage deeplink processing and communication with running instances. - Refactored single instance management to utilize the new deeplink capabilities, improving instance handling and navigation. - Removed deprecated Windows launch argument handling to streamline the codebase.
This commit is contained in:
parent
37593d73ce
commit
5450473f74
@ -1,4 +1,4 @@
|
|||||||
!macro quitFarmControl
|
!macro quitFarmControl
|
||||||
DetailPrint "Stopping running Farm Control processes..."
|
DetailPrint "Stopping running Farm Control processes..."
|
||||||
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
|
ExecWait 'taskkill /F /IM launcher.exe /T' $R0
|
||||||
!macroend
|
!macroend
|
||||||
@ -51,10 +51,10 @@
|
|||||||
DeleteRegKey HKCR "farmcontrol"
|
DeleteRegKey HKCR "farmcontrol"
|
||||||
WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
|
WriteRegStr HKCR "farmcontrol" "" "URL:farmcontrol"
|
||||||
WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
|
WriteRegStr HKCR "farmcontrol" "URL Protocol" ""
|
||||||
WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\bin\launcher.exe"
|
WriteRegStr HKCR "farmcontrol\DefaultIcon" "" "$INSTDIR\bin\deeplink.exe"
|
||||||
WriteRegStr HKCR "farmcontrol\shell" "" ""
|
WriteRegStr HKCR "farmcontrol\shell" "" ""
|
||||||
WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
|
WriteRegStr HKCR "farmcontrol\shell\Open" "" ""
|
||||||
WriteRegStr HKCR "farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\launcher.exe" "%1"'
|
WriteRegStr HKCR "farmcontrol\shell\Open\command" "" '"$INSTDIR\bin\deeplink.exe" "%1"'
|
||||||
!macroend
|
!macroend
|
||||||
|
|
||||||
!macro customUnInstall
|
!macro customUnInstall
|
||||||
|
|||||||
60
scripts/build-windows-deeplink.mjs
Normal file
60
scripts/build-windows-deeplink.mjs
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
|
const entrypoint = path.join(rootDir, 'src/bun/deeplink.js')
|
||||||
|
|
||||||
|
export function buildWindowsDeeplinkExe(outputPath) {
|
||||||
|
if (!existsSync(entrypoint)) {
|
||||||
|
throw new Error(`build-windows-deeplink: entrypoint not found: ${entrypoint}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(path.dirname(outputPath), { recursive: true })
|
||||||
|
|
||||||
|
const target = process.env.ELECTROBUN_ARCH === 'arm64'
|
||||||
|
? 'bun-windows-x64'
|
||||||
|
: 'bun-windows-x64'
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
'bun',
|
||||||
|
[
|
||||||
|
'build',
|
||||||
|
'--compile',
|
||||||
|
'--minify',
|
||||||
|
`--target=${target}`,
|
||||||
|
entrypoint,
|
||||||
|
'--outfile',
|
||||||
|
outputPath
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: process.env
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`build-windows-deeplink: bun build failed with exit code ${result.status ?? 1}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(outputPath)) {
|
||||||
|
throw new Error(`build-windows-deeplink: output not created: ${outputPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`build-windows-deeplink: created ${outputPath}`)
|
||||||
|
return outputPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||||
|
const outputArg = process.argv[2]
|
||||||
|
if (!outputArg) {
|
||||||
|
console.error('Usage: bun scripts/build-windows-deeplink.mjs <output-path>')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
buildWindowsDeeplinkExe(path.resolve(outputArg))
|
||||||
|
}
|
||||||
@ -68,9 +68,13 @@ if ($stagedAppBytes -lt 5242880) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$requiredExe = Join-Path $stagingAppDir "bin\launcher.exe"
|
$requiredExe = Join-Path $stagingAppDir "bin\launcher.exe"
|
||||||
|
$requiredDeeplinkExe = Join-Path $stagingAppDir "bin\deeplink.exe"
|
||||||
if (-not (Test-Path $requiredExe)) {
|
if (-not (Test-Path $requiredExe)) {
|
||||||
throw "Staged application is missing bin\launcher.exe"
|
throw "Staged application is missing bin\launcher.exe"
|
||||||
}
|
}
|
||||||
|
if (-not (Test-Path $requiredDeeplinkExe)) {
|
||||||
|
throw "Staged application is missing bin\deeplink.exe"
|
||||||
|
}
|
||||||
|
|
||||||
Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi")
|
Copy-Item -LiteralPath $nsiPath -Destination (Join-Path $workDir "farmcontrol.nsi")
|
||||||
Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh")
|
Copy-Item -LiteralPath $installerInclude -Destination (Join-Path $workDir "installer.nsh")
|
||||||
|
|||||||
@ -19,7 +19,7 @@ import {
|
|||||||
cleanExpandedWindowsApp,
|
cleanExpandedWindowsApp,
|
||||||
expandWindowsAppFromArchive
|
expandWindowsAppFromArchive
|
||||||
} from './expand-windows-installer.mjs'
|
} from './expand-windows-installer.mjs'
|
||||||
import { codesignMacAppBundle } from './codesign-macos-app.mjs'
|
import { buildWindowsDeeplinkExe } from './build-windows-deeplink.mjs'
|
||||||
|
|
||||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
@ -428,6 +428,12 @@ function buildMacPkg(appBundlePath, arch) {
|
|||||||
return pkgPath
|
return pkgPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stageWindowsDeeplinkExe(appDir) {
|
||||||
|
const deeplinkPath = path.join(appDir, 'bin', 'deeplink.exe')
|
||||||
|
buildWindowsDeeplinkExe(deeplinkPath)
|
||||||
|
return deeplinkPath
|
||||||
|
}
|
||||||
|
|
||||||
function cleanWinBuildDir(arch) {
|
function cleanWinBuildDir(arch) {
|
||||||
const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`)
|
const platformDir = path.join(getBuildRoot(), `stable-win-${arch}`)
|
||||||
if (existsSync(platformDir)) {
|
if (existsSync(platformDir)) {
|
||||||
@ -577,6 +583,7 @@ async function main() {
|
|||||||
installerFiles.setupArchive,
|
installerFiles.setupArchive,
|
||||||
platformDir
|
platformDir
|
||||||
)
|
)
|
||||||
|
stageWindowsDeeplinkExe(appDir)
|
||||||
|
|
||||||
let published
|
let published
|
||||||
try {
|
try {
|
||||||
|
|||||||
45
src/bun/deeplink.js
Normal file
45
src/bun/deeplink.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import {
|
||||||
|
buildDeeplinkPayload,
|
||||||
|
findProtocolUrl,
|
||||||
|
forwardDeeplinkToRunningInstance,
|
||||||
|
writeDeeplinkSignal
|
||||||
|
} from '../desktop/deeplink-ipc.js'
|
||||||
|
|
||||||
|
const url = findProtocolUrl(process.argv)
|
||||||
|
|
||||||
|
console.log('[deeplink] argv:', process.argv)
|
||||||
|
console.log('[deeplink] url:', url ?? null)
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildDeeplinkPayload(url, process.argv)
|
||||||
|
const forwarded = await forwardDeeplinkToRunningInstance(payload)
|
||||||
|
|
||||||
|
if (forwarded) {
|
||||||
|
console.log('[deeplink] forwarded to running Farm Control instance')
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[deeplink] no running instance found, launching Farm Control')
|
||||||
|
|
||||||
|
writeDeeplinkSignal(payload)
|
||||||
|
|
||||||
|
const launcherPath = join(dirname(process.execPath), 'launcher.exe')
|
||||||
|
if (!existsSync(launcherPath)) {
|
||||||
|
console.error('[deeplink] launcher.exe not found at', launcherPath)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(launcherPath, [], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
|
||||||
|
child.unref()
|
||||||
|
process.exit(0)
|
||||||
@ -1,28 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
captureLaunchUrl,
|
captureLaunchUrl,
|
||||||
closeSingleInstanceServer,
|
closeSingleInstanceServer,
|
||||||
ensureSingleInstanceLock,
|
ensureSingleInstanceLock
|
||||||
logWindowsLaunchArgs
|
|
||||||
} from '../desktop/single-instance.js'
|
} from '../desktop/single-instance.js'
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
logWindowsLaunchArgs('index-before-lock')
|
|
||||||
}
|
|
||||||
|
|
||||||
const launchUrl = captureLaunchUrl()
|
const launchUrl = captureLaunchUrl()
|
||||||
const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })
|
const gotSingleInstanceLock = await ensureSingleInstanceLock({ launchUrl })
|
||||||
|
|
||||||
if (!gotSingleInstanceLock) {
|
if (!gotSingleInstanceLock) {
|
||||||
if (process.platform === 'win32') {
|
|
||||||
console.log('[launch-args][index] exiting secondary instance')
|
|
||||||
}
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
console.log('[launch-args][index] acquired single instance lock')
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createAppRpc } = await import('../desktop/rpc.js')
|
const { createAppRpc } = await import('../desktop/rpc.js')
|
||||||
const {
|
const {
|
||||||
registerGlobalShortcuts,
|
registerGlobalShortcuts,
|
||||||
|
|||||||
147
src/desktop/deeplink-ipc.js
Normal file
147
src/desktop/deeplink-ipc.js
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
unlinkSync,
|
||||||
|
writeFileSync
|
||||||
|
} from 'node:fs'
|
||||||
|
import net from 'node:net'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
export const PROTOCOL_PREFIX = 'farmcontrol://'
|
||||||
|
export const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
|
||||||
|
export const SIGNAL_FILE = 'instance-signal.json'
|
||||||
|
const FORWARD_TIMEOUT_MS = 750
|
||||||
|
|
||||||
|
export 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSignalPath() {
|
||||||
|
return join(getInstanceDir(), SIGNAL_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findProtocolUrl(args) {
|
||||||
|
const directMatch = args.find(
|
||||||
|
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (directMatch) {
|
||||||
|
return directMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources = args.filter((arg) => typeof arg === 'string')
|
||||||
|
|
||||||
|
for (const arg of sources) {
|
||||||
|
const trimmed = arg.trim().replace(/^['"]+|['"]+$/g, '')
|
||||||
|
const match = trimmed.match(/farmcontrol:\/\/\S+/i)
|
||||||
|
if (match) {
|
||||||
|
const rawUrl = match[0].replace(/['"]+$/g, '')
|
||||||
|
try {
|
||||||
|
return decodeURI(rawUrl)
|
||||||
|
} catch {
|
||||||
|
return rawUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i)
|
||||||
|
if (combinedMatch) {
|
||||||
|
const rawUrl = combinedMatch[0].replace(/['"]+$/g, '')
|
||||||
|
try {
|
||||||
|
return decodeURI(rawUrl)
|
||||||
|
} catch {
|
||||||
|
return rawUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDeeplinkPayload(url, argv = process.argv) {
|
||||||
|
const commandLine = [...argv]
|
||||||
|
|
||||||
|
return url
|
||||||
|
? { type: 'deeplink', url, argv: commandLine }
|
||||||
|
: { type: 'focus', argv: commandLine }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSignalQueue({ clear = true } = {}) {
|
||||||
|
const signalPath = getSignalPath()
|
||||||
|
if (!existsSync(signalPath)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(signalPath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
|
||||||
|
if (clear) {
|
||||||
|
unlinkSync(signalPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed ? [parsed] : []
|
||||||
|
} catch {
|
||||||
|
if (clear) {
|
||||||
|
try {
|
||||||
|
unlinkSync(signalPath)
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDeeplinkSignal(payload) {
|
||||||
|
const signalPath = getSignalPath()
|
||||||
|
mkdirSync(getInstanceDir(), { recursive: true })
|
||||||
|
|
||||||
|
const queue = readSignalQueue({ clear: false })
|
||||||
|
queue.push({ ...payload, timestamp: Date.now() })
|
||||||
|
|
||||||
|
const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp`
|
||||||
|
writeFileSync(tempPath, JSON.stringify(queue), 'utf8')
|
||||||
|
writeFileSync(signalPath, readFileSync(tempPath))
|
||||||
|
unlinkSync(tempPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function forwardDeeplinkToRunningInstance(payload) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const finish = (forwarded) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
resolve(forwarded)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = net.connect(WINDOWS_PIPE_NAME)
|
||||||
|
const message = JSON.stringify(payload)
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
client.write(message)
|
||||||
|
client.end()
|
||||||
|
finish(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
client.on('error', () => {
|
||||||
|
finish(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
client.setTimeout(FORWARD_TIMEOUT_MS, () => {
|
||||||
|
client.destroy()
|
||||||
|
finish(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -11,15 +11,17 @@ import {
|
|||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import {
|
import {
|
||||||
getWindowsLaunchSources,
|
buildDeeplinkPayload,
|
||||||
getWindowsLaunchUrl
|
findProtocolUrl,
|
||||||
} from './windows-launch-args.js'
|
forwardDeeplinkToRunningInstance,
|
||||||
|
getInstanceDir,
|
||||||
|
getSignalPath,
|
||||||
|
SIGNAL_FILE,
|
||||||
|
WINDOWS_PIPE_NAME,
|
||||||
|
writeDeeplinkSignal
|
||||||
|
} from './deeplink-ipc.js'
|
||||||
|
|
||||||
const PROTOCOL_PREFIX = 'farmcontrol://'
|
|
||||||
const LOCK_FILE = 'primary.lock'
|
const LOCK_FILE = 'primary.lock'
|
||||||
const SIGNAL_FILE = 'instance-signal.json'
|
|
||||||
const WINDOWS_PIPE_NAME = '\\\\.\\pipe\\com.tombutcher.farmcontrol.instance'
|
|
||||||
const FORWARD_TIMEOUT_MS = 750
|
|
||||||
|
|
||||||
let lockFd = null
|
let lockFd = null
|
||||||
let pollInterval = null
|
let pollInterval = null
|
||||||
@ -31,25 +33,10 @@ let handlers = {
|
|||||||
}
|
}
|
||||||
const pendingMessages = []
|
const pendingMessages = []
|
||||||
|
|
||||||
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 getLockPath() {
|
function getLockPath() {
|
||||||
return join(getInstanceDir(), LOCK_FILE)
|
return join(getInstanceDir(), LOCK_FILE)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSignalPath() {
|
|
||||||
return join(getInstanceDir(), SIGNAL_FILE)
|
|
||||||
}
|
|
||||||
|
|
||||||
function isProcessAlive(pid) {
|
function isProcessAlive(pid) {
|
||||||
if (!Number.isInteger(pid) || pid <= 0) {
|
if (!Number.isInteger(pid) || pid <= 0) {
|
||||||
return false
|
return false
|
||||||
@ -115,86 +102,17 @@ function releasePrimaryLock() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findProtocolUrl(args) {
|
export { findProtocolUrl }
|
||||||
const directMatch = args.find(
|
|
||||||
(arg) => typeof arg === 'string' && arg.startsWith(PROTOCOL_PREFIX)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (directMatch) {
|
|
||||||
return directMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = args.filter((arg) => typeof arg === 'string')
|
|
||||||
|
|
||||||
for (const arg of sources) {
|
|
||||||
const trimmed = arg.trim().replace(/^['"]+|['"]+$/g, '')
|
|
||||||
const match = trimmed.match(/farmcontrol:\/\/\S+/i)
|
|
||||||
if (match) {
|
|
||||||
const rawUrl = match[0].replace(/['"]+$/g, '')
|
|
||||||
try {
|
|
||||||
return decodeURI(rawUrl)
|
|
||||||
} catch {
|
|
||||||
return rawUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i)
|
|
||||||
if (combinedMatch) {
|
|
||||||
const rawUrl = combinedMatch[0].replace(/['"]+$/g, '')
|
|
||||||
try {
|
|
||||||
return decodeURI(rawUrl)
|
|
||||||
} catch {
|
|
||||||
return rawUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectLaunchSources() {
|
|
||||||
const sources = [...process.argv]
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
sources.push(...getWindowsLaunchSources())
|
|
||||||
}
|
|
||||||
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logWindowsLaunchArgs(context = 'startup') {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const launchSources = getWindowsLaunchSources()
|
|
||||||
const launchUrl = captureLaunchUrl()
|
|
||||||
|
|
||||||
console.log(`[launch-args][${context}] process.argv:`, process.argv)
|
|
||||||
console.log(`[launch-args][${context}] launch sources:`, launchSources)
|
|
||||||
console.log(`[launch-args][${context}] captured url:`, launchUrl ?? null)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function captureLaunchUrl() {
|
export function captureLaunchUrl() {
|
||||||
const argvUrl = findProtocolUrl(process.argv)
|
return findProtocolUrl(process.argv)
|
||||||
if (argvUrl) {
|
|
||||||
return argvUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return getWindowsLaunchUrl() || findProtocolUrl(collectLaunchSources())
|
|
||||||
}
|
|
||||||
|
|
||||||
return findProtocolUrl(collectLaunchSources())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLaunchPayload(launchUrl = captureLaunchUrl()) {
|
function buildLaunchPayload(launchUrl = captureLaunchUrl()) {
|
||||||
const commandLine = collectLaunchSources()
|
const commandLine = [...process.argv]
|
||||||
const url = launchUrl || findProtocolUrl(commandLine)
|
const url = launchUrl || findProtocolUrl(commandLine)
|
||||||
|
|
||||||
return url
|
return buildDeeplinkPayload(url, commandLine)
|
||||||
? { type: 'deeplink', url, argv: commandLine }
|
|
||||||
: { type: 'focus', argv: commandLine }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function readSignalQueue({ clear = true } = {}) {
|
function readSignalQueue({ clear = true } = {}) {
|
||||||
@ -229,16 +147,7 @@ function readSignalQueue({ clear = true } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeInstanceSignal(payload) {
|
function writeInstanceSignal(payload) {
|
||||||
const signalPath = getSignalPath()
|
writeDeeplinkSignal(payload)
|
||||||
mkdirSync(getInstanceDir(), { recursive: true })
|
|
||||||
|
|
||||||
const queue = readSignalQueue({ clear: false })
|
|
||||||
queue.push({ ...payload, timestamp: Date.now() })
|
|
||||||
|
|
||||||
const tempPath = `${signalPath}.${process.pid}.${Date.now()}.tmp`
|
|
||||||
writeFileSync(tempPath, JSON.stringify(queue), 'utf8')
|
|
||||||
writeFileSync(signalPath, readFileSync(tempPath))
|
|
||||||
unlinkSync(tempPath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveIncomingMessage(message) {
|
function resolveIncomingMessage(message) {
|
||||||
@ -263,8 +172,8 @@ function dispatchMessage(message) {
|
|||||||
if (!resolved) return
|
if (!resolved) return
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
console.log('[launch-args][dispatch]', message)
|
console.log('[deeplink][dispatch]', message)
|
||||||
console.log('[launch-args][dispatch] resolved:', resolved)
|
console.log('[deeplink][dispatch] resolved:', resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!handlers.onDeepLink && !handlers.onFocus) {
|
if (!handlers.onDeepLink && !handlers.onFocus) {
|
||||||
@ -309,39 +218,6 @@ function startInstanceSignalWatcher() {
|
|||||||
pollInterval = setInterval(processInstanceSignals, 100)
|
pollInterval = setInterval(processInstanceSignals, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
function forwardToRunningInstance(payload) {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return Promise.resolve(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let settled = false
|
|
||||||
const finish = (forwarded) => {
|
|
||||||
if (settled) return
|
|
||||||
settled = true
|
|
||||||
resolve(forwarded)
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = net.connect(WINDOWS_PIPE_NAME)
|
|
||||||
const message = JSON.stringify(payload)
|
|
||||||
|
|
||||||
client.on('connect', () => {
|
|
||||||
client.write(message)
|
|
||||||
client.end()
|
|
||||||
finish(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
client.on('error', () => {
|
|
||||||
finish(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
client.setTimeout(FORWARD_TIMEOUT_MS, () => {
|
|
||||||
client.destroy()
|
|
||||||
finish(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function startWindowsPipeServer() {
|
function startWindowsPipeServer() {
|
||||||
if (process.platform !== 'win32' || pipeServer) {
|
if (process.platform !== 'win32' || pipeServer) {
|
||||||
return
|
return
|
||||||
@ -383,18 +259,13 @@ export async function ensureSingleInstanceLock({ launchUrl } = {}) {
|
|||||||
const payload = buildLaunchPayload(launchUrl)
|
const payload = buildLaunchPayload(launchUrl)
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
logWindowsLaunchArgs('ensure-single-instance-lock')
|
if (await forwardDeeplinkToRunningInstance(payload)) {
|
||||||
console.log('[launch-args][single-instance] payload:', payload)
|
console.log('[deeplink] forwarded duplicate launcher instance to running app')
|
||||||
}
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
if (await forwardToRunningInstance(payload)) {
|
|
||||||
console.log('[launch-args][single-instance] forwarded to running instance')
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tryAcquirePrimaryLock()) {
|
if (!tryAcquirePrimaryLock()) {
|
||||||
if (await forwardToRunningInstance(payload)) {
|
if (await forwardDeeplinkToRunningInstance(payload)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,187 +0,0 @@
|
|||||||
function hasProtocolUrl(sources) {
|
|
||||||
return sources.some(
|
|
||||||
(source) =>
|
|
||||||
typeof source === 'string' && /farmcontrol:\/\//i.test(source)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function runPowerShell(script) {
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawnSync({
|
|
||||||
cmd: [
|
|
||||||
'powershell.exe',
|
|
||||||
'-NoProfile',
|
|
||||||
'-NonInteractive',
|
|
||||||
'-ExecutionPolicy',
|
|
||||||
'Bypass',
|
|
||||||
'-Command',
|
|
||||||
script
|
|
||||||
],
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
windowsHide: true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (proc.exitCode !== 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return proc.stdout.toString().trim()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommand(command) {
|
|
||||||
try {
|
|
||||||
const proc = Bun.spawnSync({
|
|
||||||
cmd: ['cmd.exe', '/c', command],
|
|
||||||
stdout: 'pipe',
|
|
||||||
stderr: 'pipe',
|
|
||||||
windowsHide: true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (proc.exitCode !== 0) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return proc.stdout.toString()
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWmicValue(output, key) {
|
|
||||||
const match = output.match(new RegExp(`${key}=(.+?)(?:\\r?\\n|$)`, 'i'))
|
|
||||||
return match?.[1]?.trim() ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProcessCommandLine(pid) {
|
|
||||||
const output = runCommand(
|
|
||||||
`wmic process where "ProcessId=${pid}" get CommandLine /value`
|
|
||||||
)
|
|
||||||
return getWmicValue(output, 'CommandLine')
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParentProcessId(pid) {
|
|
||||||
const output = runCommand(
|
|
||||||
`wmic process where "ProcessId=${pid}" get ParentProcessId /value`
|
|
||||||
)
|
|
||||||
const parentPid = getWmicValue(output, 'ParentProcessId')
|
|
||||||
return parentPid ? Number.parseInt(parentPid, 10) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPowerShellProcessTreeCommandLines() {
|
|
||||||
const output = runPowerShell(`
|
|
||||||
$pid = $PID
|
|
||||||
$lines = New-Object System.Collections.Generic.List[string]
|
|
||||||
for ($i = 0; $i -lt 12; $i++) {
|
|
||||||
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue
|
|
||||||
if (-not $proc) { break }
|
|
||||||
if ($proc.CommandLine) {
|
|
||||||
$lines.Add([string]$proc.CommandLine)
|
|
||||||
if ($proc.CommandLine -match 'farmcontrol://') { break }
|
|
||||||
}
|
|
||||||
if (-not $proc.ParentProcessId -or $proc.ParentProcessId -eq $pid) { break }
|
|
||||||
$pid = $proc.ParentProcessId
|
|
||||||
}
|
|
||||||
$lines -join [Environment]::NewLine
|
|
||||||
`)
|
|
||||||
|
|
||||||
if (!output) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWmicProcessTreeCommandLines() {
|
|
||||||
const sources = []
|
|
||||||
let pid = process.pid
|
|
||||||
|
|
||||||
for (let depth = 0; depth < 12; depth += 1) {
|
|
||||||
const commandLine = getProcessCommandLine(pid)
|
|
||||||
if (commandLine) {
|
|
||||||
sources.push(commandLine)
|
|
||||||
if (/farmcontrol:\/\//i.test(commandLine)) {
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentPid = getParentProcessId(pid)
|
|
||||||
if (!parentPid || parentPid === pid) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
pid = parentPid
|
|
||||||
}
|
|
||||||
|
|
||||||
return sources
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEnvironmentLaunchSources() {
|
|
||||||
return Object.values(process.env).filter(
|
|
||||||
(value) => typeof value === 'string' && /farmcontrol:\/\//i.test(value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWindowsLaunchSources() {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = new Set([
|
|
||||||
...process.argv,
|
|
||||||
...getEnvironmentLaunchSources()
|
|
||||||
])
|
|
||||||
|
|
||||||
for (const commandLine of getPowerShellProcessTreeCommandLines()) {
|
|
||||||
sources.add(commandLine)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasProtocolUrl([...sources])) {
|
|
||||||
for (const commandLine of getWmicProcessTreeCommandLines()) {
|
|
||||||
sources.add(commandLine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...sources]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWindowsLaunchUrl() {
|
|
||||||
if (process.platform !== 'win32') {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const sources = getWindowsLaunchSources()
|
|
||||||
|
|
||||||
for (const source of sources) {
|
|
||||||
if (typeof source !== 'string') continue
|
|
||||||
|
|
||||||
const trimmed = source.trim().replace(/^['"]+|['"]+$/g, '')
|
|
||||||
const match = trimmed.match(/farmcontrol:\/\/\S+/i)
|
|
||||||
if (match) {
|
|
||||||
const rawUrl = match[0].replace(/['"]+$/g, '')
|
|
||||||
try {
|
|
||||||
return decodeURI(rawUrl)
|
|
||||||
} catch {
|
|
||||||
return rawUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const combinedMatch = sources.join(' ').match(/farmcontrol:\/\/\S+/i)
|
|
||||||
if (combinedMatch) {
|
|
||||||
const rawUrl = combinedMatch[0].replace(/['"]+$/g, '')
|
|
||||||
try {
|
|
||||||
return decodeURI(rawUrl)
|
|
||||||
} catch {
|
|
||||||
return rawUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user