feat: add launch guard and secure multi-user service ownership (#7569)

* feat(service): isolate macOS launch and owner runtime

* ci(windows): run owner security tests

* chore(deps): use kode-bridge v0.4.1 tag

* fix(macos): classify installed service state

* fix(macos): classify marked unavailable service

* fix(macos): gate core startup on service migration

* fix(macos): scope service init to supported platforms

* feat(macos): prompt before migrating installed service

* fix(macos): shorten sidecar IPC path

* build(service): pin helper protocol and artifacts

* fix(core): require confirmed service and sidecar readiness

* fix(macos): harden launch roots and singleton state

* fix(ui): refresh migration state before restart

* fix(macos): linearize service migration lifecycle

* fix(ui): keep service migration state fresh

* feat(service): track authenticated owner sessions

* fix(service): preserve newer owner sessions

* feat(macos): route service proxy changes through helper

* fix(macos): serialize service proxy guard

* fix(macos): close service proxy owner lifecycle

* fix(macos): close remaining proxy stop races

* fix(app): do not cancel controlled service stop

* fix(lifecycle): keep controlled updates cancellation-safe

* fix(macos): stage dev core for service

* test(macos): exercise staged core cleanup

* fix(dev): await controlled macOS exit

* chore: update changelog

* fix(macos): recover sidecar after service install failure

Allow the existing sidecar to continue when service installation leaves the manager in InstallRequired, then settle the session state as SidecarAllowed. Show neutral status text while the migration dialog waits for its final state.

* fix(macos): stage dev service tools before elevation

* fix(macos): localize launch guard prompts

* fix(service): address cross-platform integration issues

* feat: support seamless service migration

* fix: pass Windows service installer path safely

* fix(prebuild): derive service release from Cargo dependency

* fix(dev): preserve service installation state

* fix: resolve clippy warnings in service lifecycle
This commit is contained in:
wonfen
2026-07-24 09:01:49 +08:00
committed by GitHub
parent c079da6c99
commit 23dda47641
90 changed files with 9711 additions and 900 deletions
+280
View File
@@ -0,0 +1,280 @@
import { constants } from 'node:fs'
import { open } from 'node:fs/promises'
import { request } from 'node:http'
import { connect } from 'node:net'
const DEFAULT_TIMEOUT_MS = 10_000
const DEFAULT_RETRY_DELAY_MS = 100
const TRANSIENT_REQUEST_ERRORS = new Set([
'ECONNREFUSED',
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
])
const SAFE_REQUEST_ERROR_CODES = new Set([
...TRANSIENT_REQUEST_ERRORS,
'EACCES',
'EINVAL',
'ENOTFOUND',
])
function validateInstanceRecord(record) {
if (
!record ||
!Number.isInteger(record.port) ||
record.port < 1 ||
record.port > 65_535
) {
throw new Error('invalid instance record port')
}
if (
typeof record.token !== 'string' ||
!/^[0-9a-f]{64}$/.test(record.token)
) {
throw new Error('invalid instance record token')
}
return { port: record.port, token: record.token }
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function safeRequestError(error) {
const code = SAFE_REQUEST_ERROR_CODES.has(error?.code)
? error.code
: 'UNKNOWN'
const cause = new Error(code)
cause.code = code
return new Error('dev quit request failed', { cause })
}
export async function readPrivateInstanceRecord(path, expectedUid) {
let handle
try {
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
const stat = await handle.stat()
if (!stat.isFile()) {
throw new Error('instance record must be an ordinary file')
}
if (stat.uid !== expectedUid) {
throw new Error('instance record has the wrong owner')
}
if ((stat.mode & 0o777) !== 0o600) {
throw new Error('instance record permissions must be 0600')
}
const contents = await handle.readFile({ encoding: 'utf8' })
let record
try {
record = JSON.parse(contents)
} catch {
throw new Error('instance record is not valid JSON')
}
return validateInstanceRecord(record)
} finally {
await handle?.close()
}
}
function postDevQuit(record, timeoutMs, requestImpl) {
return new Promise((resolve, reject) => {
let settled = false
const settle = (callback, value) => {
if (settled) return
settled = true
callback(value)
}
const outgoing = requestImpl(
{
host: '127.0.0.1',
port: record.port,
path: '/commands/dev/quit',
method: 'POST',
agent: false,
headers: {
'X-Instance-Token': record.token,
},
},
(response) => {
response.on('aborted', () => {})
response.on('error', () => {})
response.on('close', () => {})
settle(resolve, response.statusCode)
response.destroy()
},
)
outgoing.once('error', (error) => settle(reject, error))
outgoing.setTimeout(timeoutMs, () => {
const error = new Error('dev quit request timed out')
error.code = 'ETIMEDOUT'
outgoing.destroy(error)
})
outgoing.end()
})
}
export async function requestDevQuit(
recordOrReader,
{
timeoutMs = DEFAULT_TIMEOUT_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
requestImpl = request,
now = Date.now,
sleep = delay,
} = {},
) {
const deadline = now() + timeoutMs
const readRecord =
typeof recordOrReader === 'function'
? recordOrReader
: async () => recordOrReader
while (now() < deadline) {
let record
try {
record = validateInstanceRecord(await readRecord())
} catch (error) {
if (error?.code !== 'ENOENT') {
throw error
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
continue
}
const remainingMs = Math.max(1, deadline - now())
let status
try {
status = await postDevQuit(record, remainingMs, requestImpl)
} catch (error) {
if (!TRANSIENT_REQUEST_ERRORS.has(error?.code)) {
throw safeRequestError(error)
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
continue
}
if (status === 202) {
return record
}
if (status !== 503) {
throw new Error(
`dev quit request returned HTTP ${status ?? 'without a status'}`,
)
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
}
throw new Error('timed out waiting for the dev quit request to be accepted')
}
function portAcceptsConnections(port, timeoutMs) {
return new Promise((resolve) => {
const socket = connect({ host: '127.0.0.1', port })
let settled = false
const finish = (isOpen) => {
if (settled) return
settled = true
socket.destroy()
resolve(isOpen)
}
socket.once('connect', () => finish(true))
socket.once('error', () => finish(false))
socket.setTimeout(Math.max(1, Math.min(1_000, timeoutMs)), () =>
finish(false),
)
})
}
export async function waitForPortClosed(
port,
{
timeoutMs = DEFAULT_TIMEOUT_MS,
retryDelayMs = DEFAULT_RETRY_DELAY_MS,
probePort = portAcceptsConnections,
now = Date.now,
sleep = delay,
} = {},
) {
if (!Number.isInteger(port) || port < 1 || port > 65_535) {
throw new Error('invalid loopback port')
}
const deadline = now() + timeoutMs
while (now() < deadline) {
const remainingMs = deadline - now()
if (!(await probePort(port, remainingMs))) {
return
}
await sleep(Math.min(retryDelayMs, Math.max(0, deadline - now())))
}
throw new Error('timed out waiting for the dev endpoint to close')
}
export function createMacosShutdownController({
requestQuit,
waitForPortClosed: waitForClosed,
waitForChildExit,
terminateGroup,
finalizeGroup = terminateGroup,
exit,
report = (message) => console.error(message),
timeoutMs = DEFAULT_TIMEOUT_MS,
forceTimeoutMs = 2_000,
now = Date.now,
}) {
let signalCount = 0
let forced = false
let forcedPromise
let groupFinalizationPromise
const remainingBudget = (deadline) => {
const remaining = deadline - now()
if (remaining <= 0) {
throw new Error('timed out waiting for graceful dev shutdown')
}
return remaining
}
const finalizeOnce = (operation, budget) => {
groupFinalizationPromise ??= Promise.resolve().then(() => operation(budget))
return groupFinalizationPromise
}
const gracefulShutdown = async () => {
const deadline = now() + timeoutMs
try {
const record = await requestQuit(remainingBudget(deadline))
if (forced) return false
await waitForClosed(record.port, remainingBudget(deadline))
if (forced) return false
await waitForChildExit(remainingBudget(deadline))
if (forced) return false
await finalizeOnce(finalizeGroup, remainingBudget(deadline))
if (forced) return false
exit(130)
return true
} catch (error) {
report(
`Graceful dev shutdown failed: ${error instanceof Error ? error.message : String(error)}`,
)
return false
}
}
return {
handleSigint() {
signalCount += 1
if (signalCount === 1) {
return gracefulShutdown()
}
if (!forcedPromise) {
forced = true
forcedPromise = finalizeOnce(terminateGroup, forceTimeoutMs).then(
() => {
exit(130)
return true
},
)
}
return forcedPromise
},
}
}
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export const developmentServiceDirectoryEnvironment =
'CLASH_VERGE_DEV_SERVICE_DIR'
const developmentServiceInstallerEnvironment =
'CLASH_VERGE_DEV_SERVICE_INSTALLER'
const scriptDirectory = dirname(fileURLToPath(import.meta.url))
const repositoryRoot = resolve(scriptDirectory, '..')
const serviceRepository = resolve(
repositoryRoot,
'..',
'clash-verge-service-ipc',
)
const serviceManifest = join(serviceRepository, 'Cargo.toml')
const developmentReceipt = join(
repositoryRoot,
'target',
'development-service.json',
)
function executable(name, platform) {
return join(
serviceRepository,
'target',
'debug',
platform === 'win32' ? `${name}.exe` : name,
)
}
function run(command, args, options = {}) {
return new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
env: options.env ?? process.env,
stdio: options.stdio ?? 'inherit',
windowsHide: true,
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) {
resolvePromise()
return
}
const error = new Error(
`${command} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`,
)
error.exitCode = code
reject(error)
})
})
}
async function hashFiles(paths) {
const hash = createHash('sha256')
for (const path of paths) {
hash.update(await readFile(path))
}
return hash.digest('hex')
}
async function readInstalledFingerprint() {
try {
const parsed = JSON.parse(await readFile(developmentReceipt, 'utf8'))
return typeof parsed.fingerprint === 'string' ? parsed.fingerprint : null
} catch {
return null
}
}
async function writeInstalledFingerprint(fingerprint) {
await mkdir(dirname(developmentReceipt), { recursive: true })
await writeFile(
developmentReceipt,
`${JSON.stringify({ fingerprint }, null, 2)}\n`,
'utf8',
)
}
export function windowsElevationInvocation(
installer,
environment = process.env,
) {
const script = [
"$ErrorActionPreference = 'Stop'",
`$installer = [Environment]::GetEnvironmentVariable('${developmentServiceInstallerEnvironment}', 'Process')`,
"if ([string]::IsNullOrWhiteSpace($installer)) { throw 'Development service installer path is missing' }",
'$child = Start-Process -FilePath $installer -Verb RunAs -Wait -PassThru -WindowStyle Hidden',
'exit $child.ExitCode',
].join('; ')
return {
command: 'powershell.exe',
args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
env: {
...environment,
[developmentServiceInstallerEnvironment]: installer,
},
}
}
async function elevateInstaller(installer, platform) {
if (platform === 'win32') {
const invocation = windowsElevationInvocation(installer)
return run(invocation.command, invocation.args, { env: invocation.env })
}
if (platform === 'darwin') {
const script =
'on run argv\nset toolPath to item 1 of argv\nset groupId to item 2 of argv\ndo shell script "CLASH_VERGE_SERVICE_GID=" & quoted form of groupId & " " & quoted form of toolPath with administrator privileges\nend run'
return run('osascript', ['-e', script, installer, String(process.getgid())])
}
if (typeof process.getuid === 'function' && process.getuid() === 0) {
return run(installer, [], {
env: {
...process.env,
CLASH_VERGE_SERVICE_GID: String(process.getgid()),
},
})
}
try {
await run('pkexec', [installer])
} catch (error) {
if (error.code !== 'ENOENT' && error.exitCode !== 127) throw error
await run('sudo', [installer])
}
}
export async function prepareDevelopmentService({
platform = process.platform,
} = {}) {
await access(serviceManifest)
await run('cargo', [
'build',
'--manifest-path',
serviceManifest,
'--features',
'standalone,client,development-channel',
'--bins',
])
const service = executable('clash-verge-service', platform)
await access(service)
return dirname(service)
}
export async function ensureDevelopmentService({
platform = process.platform,
} = {}) {
const serviceDirectory = await prepareDevelopmentService({ platform })
const service = join(
serviceDirectory,
platform === 'win32' ? 'clash-verge-service.exe' : 'clash-verge-service',
)
const installer = join(
serviceDirectory,
platform === 'win32'
? 'clash-verge-service-install.exe'
: 'clash-verge-service-install',
)
const driver = join(
serviceDirectory,
platform === 'win32'
? 'service-integration-driver.exe'
: 'service-integration-driver',
)
const fingerprint = await hashFiles([service, installer])
const installedFingerprint = await readInstalledFingerprint()
if (installedFingerprint === fingerprint) {
try {
await run(driver, ['probe'], { stdio: 'ignore' })
return serviceDirectory
} catch {
// The expected binary is installed but unavailable; the repair tool owns recovery.
}
}
try {
await elevateInstaller(installer, platform)
} catch (error) {
if (error.exitCode !== 75) throw error
}
await run(driver, ['ready'])
await writeInstalledFingerprint(fingerprint)
return serviceDirectory
}
+565
View File
@@ -0,0 +1,565 @@
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
createMacosShutdownController,
readPrivateInstanceRecord,
requestDevQuit,
waitForPortClosed,
} from './dev-control.mjs'
import {
developmentServiceDirectoryEnvironment,
ensureDevelopmentService,
prepareDevelopmentService,
} from './dev-service.mjs'
const FORCE_GRACE_MS = 2_000
const SHUTDOWN_TIMEOUT_MS = 10_000
const FORCE_POLL_MS = 50
const ANCHOR_EXIT_RESERVE_MS = 100
export function buildTauriInvocation(
mode,
environment = process.env,
platform = process.platform,
) {
const env = { ...environment, RUST_BACKTRACE: 'full' }
const features = mode === 'sidecar' ? 'verge-dev,dev-sidecar' : 'verge-dev'
const args = ['exec', 'tauri', 'dev', '-f', features]
if (mode === 'trace') {
env.RUSTFLAGS = '--cfg tokio_unstable'
args.push('tokio-trace')
}
return {
command: 'pnpm',
args,
env,
detached: platform === 'darwin',
}
}
function wait(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function childLifecycle(child) {
const spawned = new Promise((resolve, reject) => {
child.once('spawn', resolve)
child.once('error', reject)
})
spawned.catch(() => {})
const result = new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve({ code, signal }))
})
result.catch(() => {})
return { result, spawned }
}
function waitForPromise(promise, timeoutMs, message) {
if (timeoutMs <= 0) return Promise.reject(new Error(message))
let timeout
return Promise.race([
promise,
new Promise((_, reject) => {
timeout = setTimeout(() => reject(new Error(message)), timeoutMs)
}),
]).finally(() => clearTimeout(timeout))
}
function signalDetachedGroup(childPid, signal, signalGroup) {
if (!Number.isInteger(childPid) || childPid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
try {
signalGroup(-childPid, signal)
return true
} catch (error) {
if (error?.code === 'ESRCH') return false
throw error
}
}
export async function terminateDetachedGroup(
childPid,
{
signalGroup = process.kill.bind(process),
sleep = wait,
now = Date.now,
graceMs = FORCE_GRACE_MS,
pollMs = FORCE_POLL_MS,
} = {},
) {
if (!signalDetachedGroup(childPid, 'SIGTERM', signalGroup)) return
const deadline = now() + graceMs
while (now() < deadline) {
await sleep(Math.min(pollMs, deadline - now()))
if (now() < deadline && !signalDetachedGroup(childPid, 0, signalGroup)) {
return
}
}
signalDetachedGroup(childPid, 'SIGKILL', signalGroup)
}
export function mirrorChildExit(
{ code, signal },
{
exit = (exitCode) => process.exit(exitCode),
signalSelf = (childSignal) => {
process.removeAllListeners(childSignal)
process.kill(process.pid, childSignal)
},
} = {},
) {
if (code !== null) {
exit(code)
return
}
if (signal) {
signalSelf(signal)
return
}
exit(1)
}
function defaultInstallSigint(handler) {
process.on('SIGINT', handler)
return () => process.off('SIGINT', handler)
}
function defaultInstallSigterm(handler) {
process.on('SIGTERM', handler)
return () => process.off('SIGTERM', handler)
}
function defaultInstallMessage(handler) {
process.on('message', handler)
return () => process.off('message', handler)
}
function defaultInstallDisconnect(handler) {
process.on('disconnect', handler)
return () => process.off('disconnect', handler)
}
function sendToParent(message) {
if (process.connected && process.send) process.send(message, () => {})
}
export async function runDevAnchor(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs --anchor <dev|trace|sidecar>')
}
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const send = dependencies.send ?? sendToParent
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const ownPid = dependencies.ownPid ?? process.pid
const terminateGroup =
dependencies.terminateGroup ??
((pid) => terminateDetachedGroup(pid, { graceMs: FORCE_GRACE_MS }))
let released = false
let disconnectCleanup
const installSigterm = dependencies.installSigterm ?? defaultInstallSigterm
installSigterm(() => {})
const installMessage = dependencies.installMessage ?? defaultInstallMessage
installMessage((message) => {
if (message?.type === 'release' && !disconnectCleanup) {
released = true
exit(0)
}
})
const installDisconnect =
dependencies.installDisconnect ?? defaultInstallDisconnect
installDisconnect(() => {
if (released || disconnectCleanup) return
disconnectCleanup = Promise.resolve()
.then(() => terminateGroup(ownPid))
.then(() => exit(1))
.catch((error) => {
report(`Dev anchor cleanup failed: ${error.message}`)
exit(1)
})
})
const invocation = buildTauriInvocation(mode, environment, 'darwin')
let inner
try {
inner = spawnChild(invocation.command, invocation.args, {
env: invocation.env,
detached: false,
stdio: 'inherit',
})
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
if (!inner || typeof inner.once !== 'function') {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
const innerLifecycle = childLifecycle(inner)
try {
await innerLifecycle.spawned
} catch {
send({ type: 'inner-error', code: 'SPAWN_FAILED' })
return
}
send({ type: 'ready' })
innerLifecycle.result.then(
({ code, signal }) => send({ type: 'inner-result', code, signal }),
() => send({ type: 'inner-error', code: 'INNER_FAILED' }),
)
}
function createAnchorChannel(anchor, anchorResult) {
let innerSettled = false
let readySettled = false
let resolveResult
let rejectResult
let resolveReady
let rejectReady
const innerResult = new Promise((resolve, reject) => {
resolveResult = resolve
rejectResult = reject
})
const ready = new Promise((resolve, reject) => {
resolveReady = resolve
rejectReady = reject
})
const settleInner = (callback, value) => {
if (innerSettled) return
innerSettled = true
callback(value)
}
const settleReady = (callback, value) => {
if (readySettled) return
readySettled = true
callback(value)
}
anchor.on('message', (message) => {
if (message?.type === 'ready') {
settleReady(resolveReady)
} else if (message?.type === 'inner-result') {
settleInner(resolveResult, {
code: message.code,
signal: message.signal,
})
} else if (message?.type === 'inner-error') {
const error = new Error(
`inner dev command failed (${message.code ?? 'UNKNOWN'})`,
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
}
})
anchorResult.then(
() => {
const error = new Error(
'dev group anchor exited before the inner command completed',
)
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
() => {
const error = new Error('dev group anchor failed')
settleReady(rejectReady, error)
settleInner(rejectResult, error)
},
)
ready.catch(() => {})
innerResult.catch(() => {})
return { innerResult, ready }
}
function sendRelease(anchor) {
return new Promise((resolve, reject) => {
if (typeof anchor.send !== 'function') {
reject(new Error('dev group anchor IPC is unavailable'))
return
}
anchor.send({ type: 'release' }, (error) => {
if (error) reject(new Error('dev group anchor release failed'))
else resolve()
})
})
}
function unsafeAnchorError() {
return new Error('dev group anchor identity is unsafe')
}
export async function runDevCommand(mode, dependencies = {}) {
if (mode !== 'dev' && mode !== 'trace' && mode !== 'sidecar') {
throw new Error('usage: node scripts/dev.mjs <dev|trace|sidecar>')
}
const platform = dependencies.platform ?? process.platform
const environment = dependencies.environment ?? process.env
const spawnChild = dependencies.spawnChild ?? spawn
const exit = dependencies.exit ?? ((code) => process.exit(code))
const report = dependencies.report ?? ((message) => console.error(message))
const mirrorResult = dependencies.mirrorResult ?? mirrorChildExit
let recordPath
let pendingSigints = 0
let dispatchSigint = () => {
pendingSigints += 1
}
let removeSigint = () => {}
if (platform === 'darwin') {
if (!environment.HOME) {
throw new Error('HOME is required to locate the dev instance record')
}
recordPath = join(
environment.HOME,
'Library/Application Support/io.github.clash-verge-rev.clash-verge-rev.dev/singleton-instance.json',
)
const installSigint = dependencies.installSigint ?? defaultInstallSigint
removeSigint = installSigint(() => dispatchSigint()) ?? (() => {})
}
const tauriInvocation = buildTauriInvocation(mode, environment, platform)
const anchorInvocation = {
command: dependencies.execPath ?? process.execPath,
args: [fileURLToPath(import.meta.url), '--anchor', mode],
env: tauriInvocation.env,
detached: true,
}
const invocation = platform === 'darwin' ? anchorInvocation : tauriInvocation
let child
try {
child = spawnChild(invocation.command, invocation.args, {
env: invocation.env,
detached: invocation.detached,
stdio:
platform === 'darwin'
? ['inherit', 'inherit', 'inherit', 'ipc']
: 'inherit',
})
} catch (error) {
removeSigint()
throw error
}
if (!child || typeof child.once !== 'function') {
removeSigint()
throw new Error('dev command failed to return a child process')
}
const outerLifecycle = childLifecycle(child)
if (platform !== 'darwin') {
mirrorResult(await outerLifecycle.result)
return
}
let groupState = 'safe'
let finalKillSent = false
const anchorChannel = createAnchorChannel(child, outerLifecycle.result)
outerLifecycle.result.then(
() => {
if (groupState !== 'released' && !finalKillSent) groupState = 'unsafe'
},
() => {
groupState = 'unsafe'
},
)
try {
await outerLifecycle.spawned
if (!Number.isInteger(child.pid) || child.pid <= 0) {
throw new Error('dev command did not provide a valid child PID')
}
await waitForPromise(
anchorChannel.ready,
SHUTDOWN_TIMEOUT_MS,
'timed out waiting for the dev group anchor to initialize',
)
} catch (error) {
removeSigint()
throw error
}
const anchorPid = child.pid
const innerResult = anchorChannel.innerResult
let intercepted = false
let observedShutdownOperation
let anchorFinalization
const signalGroup = dependencies.signalGroup ?? process.kill.bind(process)
const guardedSignalGroup = (pid, signal) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
if (signal === 'SIGKILL') finalKillSent = true
try {
signalGroup(pid, signal)
} catch (error) {
if (error?.code === 'ESRCH') {
groupState = 'unsafe'
throw unsafeAnchorError()
}
throw error
}
}
const assertGroupAbsent = () => {
try {
signalGroup(-anchorPid, 0)
} catch (error) {
if (error?.code === 'ESRCH') return
throw new Error('unable to verify that the dev process group exited', {
cause: error,
})
}
throw new Error('dev process group still exists after anchor exit')
}
const forceAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'terminating'
if (dependencies.terminateGroup) {
await dependencies.terminateGroup(anchorPid, budget)
return
}
const deadline = Date.now() + budget
await terminateDetachedGroup(anchorPid, {
graceMs: Math.max(0, budget - ANCHOR_EXIT_RESERVE_MS),
signalGroup: guardedSignalGroup,
})
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the dev group anchor to exit',
)
}
const releaseAnchor = async (budget) => {
if (groupState === 'unsafe') throw unsafeAnchorError()
groupState = 'released'
const deadline = Date.now() + budget
await waitForPromise(
sendRelease(child),
Math.max(1, deadline - Date.now()),
'timed out releasing the dev group anchor',
)
await waitForPromise(
outerLifecycle.result,
Math.max(1, deadline - Date.now()),
'timed out waiting for the released dev group anchor',
)
assertGroupAbsent()
}
const finalizeAnchorOnce = (kind, budget) => {
anchorFinalization ??=
kind === 'release' ? releaseAnchor(budget) : forceAnchor(budget)
return anchorFinalization
}
const getUid = dependencies.getUid ?? (() => process.getuid())
const requestQuit =
dependencies.requestQuit ??
((budget) =>
requestDevQuit(() => readPrivateInstanceRecord(recordPath, getUid()), {
timeoutMs: budget,
}))
const controller = createMacosShutdownController({
requestQuit,
waitForPortClosed: (port, budget) =>
(dependencies.waitForPortClosed ?? waitForPortClosed)(port, {
timeoutMs: budget,
}),
waitForChildExit: (budget) =>
waitForPromise(
innerResult,
budget,
'timed out waiting for the inner dev command to exit',
),
finalizeGroup: (budget) => finalizeAnchorOnce('release', budget),
terminateGroup: (budget) => finalizeAnchorOnce('force', budget),
exit,
report,
timeoutMs: SHUTDOWN_TIMEOUT_MS,
})
dispatchSigint = () => {
intercepted = true
const operation = controller.handleSigint()
if (operation !== observedShutdownOperation) {
observedShutdownOperation = operation
void operation.catch((error) => {
report(`Forced dev shutdown failed: ${error.message}`)
exit(1)
})
}
}
const queuedSigints = pendingSigints
pendingSigints = 0
for (let index = 0; index < queuedSigints; index += 1) {
dispatchSigint()
}
innerResult.then(
async (result) => {
if (intercepted) return
try {
await finalizeAnchorOnce('release', SHUTDOWN_TIMEOUT_MS)
if (!intercepted) mirrorResult(result)
} catch (error) {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
}
},
(error) => {
if (!intercepted) {
report(`Dev command failed: ${error.message}`)
exit(1)
}
},
)
}
export async function prepareDevRuntime(mode, dependencies = {}) {
const ensureService = dependencies.ensureService ?? ensureDevelopmentService
const prepareService =
dependencies.prepareService ?? prepareDevelopmentService
let serviceDirectory
if (mode === 'service') {
serviceDirectory = await ensureService()
} else if (mode === 'dev' || mode === 'trace' || mode === 'sidecar') {
serviceDirectory = await prepareService()
}
return {
mode: mode === 'service' ? 'dev' : mode,
serviceDirectory,
}
}
async function run() {
if (process.argv[2] === '--anchor') {
await runDevAnchor(process.argv[3])
} else {
const { mode, serviceDirectory } = await prepareDevRuntime(process.argv[2])
if (serviceDirectory) {
process.env[developmentServiceDirectoryEnvironment] = serviceDirectory
}
await runDevCommand(mode)
}
}
if (
process.argv[1] &&
pathToFileURL(process.argv[1]).href === import.meta.url
) {
run().catch((error) => {
console.error(`Unable to start dev command: ${error.message}`)
process.exit(1)
})
}
+10 -63
View File
@@ -10,6 +10,7 @@ import { glob } from 'glob'
import { HttpsProxyAgent } from 'https-proxy-agent'
import { extract } from 'tar'
import { resolveServiceRelease } from './service-release.mjs'
import { log_debug, log_error, log_info, log_success } from './utils.mjs'
/**
@@ -577,12 +578,6 @@ const resolveServicePermission = async () => {
// =======================
// Other resource resolvers (service, mmdb, geosite, geoip, enableLoopback)
// =======================
const SERVICE_LATEST_URL =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/latest'
const SERVICE_URL_PREFIX =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
let SERVICE_VERSION
const SERVICE_BINARIES = [
'clash-verge-service',
'clash-verge-service-install',
@@ -598,53 +593,6 @@ function serviceFileInfo(name) {
}
}
function parseServiceVersionFromUrl(url) {
const match = url.match(/\/releases\/tag\/([^/?#]+)/)
return match ? decodeURIComponent(match[1]) : null
}
async function getLatestServiceVersion() {
if (!FORCE) {
const cached = await getCachedVersion('SERVICE_VERSION')
if (cached) {
SERVICE_VERSION = cached
return
}
}
const options = {}
const httpProxy =
process.env.HTTP_PROXY ||
process.env.http_proxy ||
process.env.HTTPS_PROXY ||
process.env.https_proxy
if (httpProxy) options.agent = new HttpsProxyAgent(httpProxy)
try {
const response = await fetch(SERVICE_LATEST_URL, {
...options,
method: 'GET',
redirect: 'follow',
})
if (!response.ok)
throw new Error(
`Failed to fetch ${SERVICE_LATEST_URL}: ${response.status}`,
)
SERVICE_VERSION = parseServiceVersionFromUrl(response.url)
if (!SERVICE_VERSION)
throw new Error(
`Unable to resolve service release tag from ${response.url}`,
)
log_info(`Latest service version: ${SERVICE_VERSION}`)
await setCachedVersion('SERVICE_VERSION', SERVICE_VERSION)
} catch (err) {
log_error('Error fetching latest service version:', err.message)
process.exit(1)
}
}
async function findExtractedFile(dir, fileName) {
const entries = await fsp.readdir(dir, { withFileTypes: true })
for (const entry of entries) {
@@ -667,16 +615,15 @@ async function resolveServiceBundle() {
}
})
if (!FORCE && files.every(({ targetPath }) => fs.existsSync(targetPath))) {
log_success('"clash-verge-service-ipc" already exists, skipping download')
return
}
await getLatestServiceVersion()
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
const archiveFile = `clash-verge-service-ipc-${SERVICE_VERSION}-${SIDECAR_HOST}.${archiveExt}`
const downloadURL = `${SERVICE_URL_PREFIX}/${SERVICE_VERSION}/${archiveFile}`
const cargoManifest = await fsp.readFile(
path.join(cwd, 'src-tauri', 'Cargo.toml'),
'utf8',
)
const { archiveFile, downloadURL } = resolveServiceRelease(
cargoManifest,
SIDECAR_HOST,
platform,
)
const tempDir = path.join(TEMP_DIR, 'clash-verge-service-ipc')
const tempArchive = path.join(tempDir, archiveFile)
+23
View File
@@ -0,0 +1,23 @@
const SERVICE_URL_PREFIX =
'https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download'
export function resolveServiceRelease(cargoManifest, host, platform) {
const dependency = cargoManifest
.split(/\r?\n/)
.find((line) => line.trimStart().startsWith('clash_verge_service_ipc ='))
const packageVersion = dependency?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
if (!packageVersion) {
throw new Error(
'clash_verge_service_ipc dependency must declare an inline version',
)
}
const version = `v${packageVersion}`
const archiveExt = platform === 'win32' ? 'zip' : 'tar.gz'
const archiveFile = `clash-verge-service-ipc-${version}-${host}.${archiveExt}`
return {
version,
archiveFile,
downloadURL: `${SERVICE_URL_PREFIX}/${version}/${archiveFile}`,
}
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { resolveServiceRelease } from './service-release.mjs'
test('service prebuild release follows the Cargo dependency version', async () => {
const cargoManifest = await readFile(
new URL('../src-tauri/Cargo.toml', import.meta.url),
'utf8',
)
const dependencyVersion = cargoManifest
.split(/\r?\n/)
.find((line) => line.startsWith('clash_verge_service_ipc ='))
?.match(/\bversion\s*=\s*"([^"]+)"/)?.[1]
assert.ok(dependencyVersion)
const releaseVersion = `v${dependencyVersion}`
assert.deepEqual(
resolveServiceRelease(cargoManifest, 'x86_64-pc-windows-msvc', 'win32'),
{
version: releaseVersion,
archiveFile: `clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
downloadURL: `https://github.com/clash-verge-rev/clash-verge-service-ipc/releases/download/${releaseVersion}/clash-verge-service-ipc-${releaseVersion}-x86_64-pc-windows-msvc.zip`,
},
)
})
test('service prebuild rejects a dependency without an explicit version', () => {
assert.throws(
() =>
resolveServiceRelease(
'clash_verge_service_ipc = { path = "../service" }',
'x86_64-pc-windows-msvc',
'win32',
),
/must declare an inline version/,
)
})