mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-06 09:02:08 +08:00
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:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
@@ -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/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user