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:
Tunglies
2026-10-12 06:16:05 +00:00
committed by GitHub
parent 27efaf34f4
commit 6e08a77a84
89 changed files with 2714 additions and 1354 deletions
+25 -75
View File
@@ -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')
+69
View File
@@ -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')
}
+23
View File
@@ -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
View File
@@ -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`,
)