Files
clash-verge-rev/scripts/dev-service.mjs
T
wonfen 23dda47641 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
2026-07-24 09:01:49 +08:00

191 lines
5.4 KiB
JavaScript

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
}