mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-06 09:02:08 +08:00
refactor(backend): specta ipc bindings (#8195)
* refactor(ipc): generate bindings with Specta * fix(profile): project IPC inputs to consumed fields * fix(events): remove unused refresh payloads * fix(webdav): return only consumed backup metadata * fix(network): trim unused interface metadata * fix(sysinfo): return only displayed system fields * chore(knip): check generated runtime exports * fix(proxy): confirm runtime chain updates before UI changes * fix(clipboard): propagate proxy environment copy failures * refactor(dns): remove ignored runtime patch * refactor(core): trim unused upgrade response fields * refactor(ipc): avoid redundant response copies * refactor(proxy): clarify automatic proxy query naming * chore(tests): remove added IPC regression tests * refactor(ipc): enforce typed command boundaries * chore(contract): check generated bindings before push * refactor(ipc): remove unused proxy liveness payloads
This commit is contained in:
@@ -1,84 +1,34 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Architecture grep gates for the frontend contract flow. Select gates via
|
||||
* argv:
|
||||
*
|
||||
* node scripts/check-architecture.mjs listen emit invoke
|
||||
*
|
||||
* - listen/emit: the Tauri event API may only be used under src/services/bus/**
|
||||
* (the event bus). Detected as a bare `listen(`/`emit(` call (member calls
|
||||
* like `this.emit(` are unrelated class methods) or as importing the name
|
||||
* from '@tauri-apps/api/event'.
|
||||
* - invoke: `invoke(` calls may exist only under src/services/**.
|
||||
*/
|
||||
import { ESLint } from 'eslint'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SRC = path.join(ROOT, 'src')
|
||||
|
||||
const tauriEventImport = (name) =>
|
||||
new RegExp(
|
||||
`import\\s*\\{[^}]*\\b${name}\\b[^}]*\\}\\s*from\\s*'@tauri-apps/api/event'`,
|
||||
)
|
||||
|
||||
const GATES = {
|
||||
listen: {
|
||||
patterns: [/(?<![.\w])listen\(/, tauriEventImport('listen')],
|
||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||
description: 'Tauri listen outside the event bus (src/services/bus)',
|
||||
},
|
||||
emit: {
|
||||
patterns: [/(?<![.\w])emit\(/, tauriEventImport('emit')],
|
||||
allowed: (file) => file.startsWith('src/services/bus/'),
|
||||
description: 'Tauri emit outside the event bus (src/services/bus)',
|
||||
},
|
||||
invoke: {
|
||||
patterns: [/\binvoke\(/],
|
||||
allowed: (file) => file.startsWith('src/services/'),
|
||||
description: 'invoke( outside the services layer (src/services)',
|
||||
},
|
||||
}
|
||||
|
||||
const walk = (dir) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
return entries.flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) return walk(full)
|
||||
return /\.(ts|tsx|js|jsx|mts|mjs)$/.test(entry.name) ? [full] : []
|
||||
})
|
||||
}
|
||||
|
||||
const selected = process.argv.slice(2)
|
||||
if (selected.length === 0) {
|
||||
const gates = process.argv.slice(2)
|
||||
if (
|
||||
!gates.length ||
|
||||
gates.some((gate) => !['listen', 'emit', 'invoke'].includes(gate))
|
||||
) {
|
||||
console.error('usage: check-architecture.mjs <gate>... (listen|emit|invoke)')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let violations = 0
|
||||
for (const gate of selected) {
|
||||
const rule = GATES[gate]
|
||||
if (!rule) {
|
||||
console.error(`unknown gate: ${gate}`)
|
||||
process.exit(2)
|
||||
}
|
||||
for (const file of walk(SRC)) {
|
||||
const relative = path.relative(ROOT, file).replaceAll('\\', '/')
|
||||
if (rule.allowed(relative)) continue
|
||||
const lines = fs.readFileSync(file, 'utf8').split('\n')
|
||||
lines.forEach((line, index) => {
|
||||
if (rule.patterns.some((pattern) => pattern.test(line))) {
|
||||
violations += 1
|
||||
console.error(
|
||||
`[arch] ${rule.description}: ${relative}:${index + 1}: ${line.trim()}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
// All IPC boundaries use the lint configuration, including when callers request a legacy gate.
|
||||
const eslint = new ESLint({ cwd: ROOT, allowInlineConfig: false })
|
||||
const results = await eslint.lintFiles(['src'])
|
||||
const boundaryRules = new Set([
|
||||
'no-restricted-imports',
|
||||
'no-restricted-syntax',
|
||||
'@typescript-eslint/no-floating-promises',
|
||||
])
|
||||
const violations = results.flatMap((result) =>
|
||||
result.messages
|
||||
.filter((message) => message.fatal || boundaryRules.has(message.ruleId))
|
||||
.map((message) => ({ file: result.filePath, ...message })),
|
||||
)
|
||||
for (const violation of violations) {
|
||||
console.error(
|
||||
`[arch] ${path.relative(ROOT, violation.file)}:${violation.line}: ${violation.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (violations > 0) {
|
||||
console.error(`[arch] ${violations} violation(s) found`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('[arch] no violations')
|
||||
if (violations.length) process.exitCode = 1
|
||||
else console.log('[arch] no violations')
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }).trim()
|
||||
const { contract } = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'scripts/contract-inputs.json'), 'utf8'),
|
||||
)
|
||||
const inputs = contract.map((pattern) => `:(glob)${pattern}`)
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length !== 2 || args[0] !== '--push') {
|
||||
console.error('usage: check-contract-changes.mjs --push <remote>')
|
||||
process.exit(2)
|
||||
}
|
||||
const hasChanges = (...range) =>
|
||||
git('diff', '--name-only', ...range, '--', ...inputs) !== ''
|
||||
const assertCleanInputs = (...range) => {
|
||||
if (
|
||||
hasChanges(...range) ||
|
||||
git('ls-files', '--others', '--exclude-standard', '--', ...inputs)
|
||||
) {
|
||||
throw new Error(
|
||||
'Contract inputs differ from the pushed commit. Commit those changes before pushing.',
|
||||
)
|
||||
}
|
||||
}
|
||||
let relevant = false
|
||||
const head = git('rev-parse', 'HEAD')
|
||||
for (const line of fs
|
||||
.readFileSync(0, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)) {
|
||||
const fields = line.trim().split(/\s+/)
|
||||
if (
|
||||
fields.length !== 4 ||
|
||||
![fields[1], fields[3]].every((sha) => /^[a-f0-9]{40,64}$/.test(sha))
|
||||
) {
|
||||
throw new Error('Invalid pre-push ref update')
|
||||
}
|
||||
const [, local, , remote] = fields
|
||||
if (/^0+$/.test(local) || local === remote) continue
|
||||
// A new remote branch has no upstream; use its fork point with the remote default branch.
|
||||
const base = /^0+$/.test(remote)
|
||||
? git('merge-base', local, `refs/remotes/${args[1]}/HEAD`)
|
||||
: remote
|
||||
if (!hasChanges(base, local)) continue
|
||||
const commit = git('rev-parse', `${local}^{commit}`)
|
||||
if (commit !== head)
|
||||
throw new Error(
|
||||
'Check out the pushed commit before validating its changed IPC contract.',
|
||||
)
|
||||
assertCleanInputs('HEAD')
|
||||
relevant = true
|
||||
}
|
||||
if (relevant) {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(ROOT, 'scripts/gen-contract.mjs'), '--check'],
|
||||
{
|
||||
cwd: ROOT,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
} else {
|
||||
console.log('[contract] inputs unchanged; skipping Rust generation')
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"contract": [
|
||||
"src-tauri/**/*.rs",
|
||||
"crates/**/*.rs",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"src-tauri/Cargo.toml",
|
||||
"crates/**/Cargo.toml",
|
||||
"rust-toolchain.toml",
|
||||
"src-tauri/tauri.conf*.json",
|
||||
"src/services/bindings.ts",
|
||||
"scripts/gen-contract.mjs",
|
||||
"scripts/check-contract-changes.mjs",
|
||||
"biome.json",
|
||||
"biome.jsonc",
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-workspace.yaml",
|
||||
"scripts/contract-inputs.json",
|
||||
".github/workflows/frontend-check.yml",
|
||||
".husky/pre-push"
|
||||
]
|
||||
}
|
||||
+46
-82
@@ -1,91 +1,55 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Regenerates src/services/contract.ts from the backend wire contract.
|
||||
* Source of truth: `cargo run -p clash-verge --bin frontend-contract` inside
|
||||
* src-tauri, which serializes the same enum variants and event payloads the
|
||||
* app emits at runtime. CI reruns this and fails on a non-empty diff.
|
||||
*/
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const OUT = path.join(ROOT, 'src/services/contract.ts')
|
||||
|
||||
const contract = JSON.parse(
|
||||
const BINDINGS = path.join(ROOT, 'src/services/bindings.ts')
|
||||
const args = process.argv.slice(2)
|
||||
if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) {
|
||||
console.error('usage: gen-contract.mjs [--check]')
|
||||
process.exit(2)
|
||||
}
|
||||
const check = args[0] === '--check'
|
||||
const temporary = check
|
||||
? fs.mkdtempSync(path.join(os.tmpdir(), 'verge-contract-'))
|
||||
: null
|
||||
const output = temporary ? path.join(temporary, 'bindings.ts') : BINDINGS
|
||||
try {
|
||||
execFileSync(
|
||||
'cargo',
|
||||
['run', '-p', 'clash-verge', '--bin', 'frontend-contract', '-q'],
|
||||
{
|
||||
cwd: path.join(ROOT, 'src-tauri'),
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if (contract.version !== 1) {
|
||||
throw new Error(`unsupported wire contract version: ${contract.version}`)
|
||||
}
|
||||
|
||||
/** TS type per event, derived from the serialized sample payload's JSON shape. */
|
||||
const payloadType = (name, sample) => {
|
||||
if (sample === null) return 'null'
|
||||
if (typeof sample === 'string') return 'string'
|
||||
if (Array.isArray(sample)) {
|
||||
if (name === 'verge://notice-message') return '[NoticeStatus, string]'
|
||||
throw new Error(`unexpected array payload for ${name}`)
|
||||
[
|
||||
'run',
|
||||
'-p',
|
||||
'clash-verge',
|
||||
'--bin',
|
||||
'frontend-contract',
|
||||
'--locked',
|
||||
'-q',
|
||||
...(check ? ['--', output] : []),
|
||||
],
|
||||
{ cwd: ROOT, stdio: 'inherit' },
|
||||
)
|
||||
const formatted = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
path.join(ROOT, 'node_modules/@biomejs/biome/bin/biome'),
|
||||
'format',
|
||||
'--stdin-file-path',
|
||||
BINDINGS,
|
||||
],
|
||||
{ cwd: ROOT, input: fs.readFileSync(output) },
|
||||
)
|
||||
if (check) {
|
||||
if (!formatted.equals(fs.readFileSync(BINDINGS))) {
|
||||
console.error(
|
||||
'IPC bindings are out of date. Run pnpm gen:contract and commit the generated file.',
|
||||
)
|
||||
process.exitCode = 1
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(BINDINGS, formatted)
|
||||
}
|
||||
if (typeof sample === 'object') {
|
||||
const keys = Object.keys(sample)
|
||||
if (keys.length === 1 && keys[0] === 'uid') return '{ uid: string }'
|
||||
if (name === 'verge://run-state-changed') return 'RunState'
|
||||
throw new Error(`unexpected object payload for ${name}: ${keys.join(',')}`)
|
||||
}
|
||||
throw new Error(`unexpected payload for ${name}`)
|
||||
} finally {
|
||||
if (temporary) fs.rmSync(temporary, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const needsRunState = contract.events.some(
|
||||
(e) => e.name === 'verge://run-state-changed',
|
||||
)
|
||||
if (!needsRunState)
|
||||
throw new Error('run-state-changed event missing from contract')
|
||||
|
||||
const lines = []
|
||||
lines.push(
|
||||
'// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT.',
|
||||
)
|
||||
lines.push('// Regen: pnpm gen:contract')
|
||||
lines.push('')
|
||||
lines.push("import type { RunState } from './cmds'")
|
||||
lines.push('')
|
||||
lines.push(
|
||||
'/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */',
|
||||
)
|
||||
lines.push('export type NoticeStatus =')
|
||||
for (const status of contract.noticeStatuses) {
|
||||
lines.push(` | ${JSON.stringify(status)}`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('/** Event name → payload type for every backend-emitted event. */')
|
||||
lines.push('export interface VergeEventPayloads {')
|
||||
for (const event of contract.events) {
|
||||
const key = JSON.stringify(event.name)
|
||||
lines.push(` ${key}: ${payloadType(event.name, event.sample)}`)
|
||||
}
|
||||
lines.push('}')
|
||||
lines.push('')
|
||||
|
||||
const content = `${lines.join('\n')}\n`
|
||||
fs.writeFileSync(OUT, content)
|
||||
|
||||
// Match the repo's biome style so CI parity (`git diff --exit-code`) is stable.
|
||||
execFileSync(
|
||||
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
|
||||
['exec', 'biome', 'format', '--write', path.relative(ROOT, OUT)],
|
||||
{ cwd: ROOT, stdio: 'ignore' },
|
||||
)
|
||||
|
||||
console.log(
|
||||
`[gen-contract] wrote ${path.relative(ROOT, OUT)}: ${contract.events.length} events, ${contract.noticeStatuses.length} statuses`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user