Files
clash-verge-rev/scripts/dev-control.mjs
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

281 lines
7.2 KiB
JavaScript

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
},
}
}