From 8a7fb6db49aacdc774ac80f7b8846d4bbbcd3419 Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:44:54 +0800 Subject: [PATCH] refactor(ipc): enforce typed command boundaries --- eslint.config.ts | 115 +++++++++++++++++++++++++++ scripts/check-architecture.mjs | 100 ++++++----------------- src/components/proxy/proxy-chain.tsx | 8 +- src/pages/proxies.tsx | 6 +- 4 files changed, 147 insertions(+), 82 deletions(-) diff --git a/eslint.config.ts b/eslint.config.ts index df7bd8deb..1e1840416 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -12,6 +12,92 @@ import pluginUnusedImports from 'eslint-plugin-unused-imports' import globals from 'globals' import tseslint from 'typescript-eslint' +const ipcBoundaryRules = ({ + allowCommands = false, + allowInvoke = false, + allowEvents = false, +} = {}) => { + const paths = [ + ...(!allowInvoke + ? [ + { + name: '@tauri-apps/api/core', + importNames: ['invoke'], + allowTypeImports: true, + message: 'Invoke backend commands through services/cmds.', + }, + ] + : []), + ...(!allowEvents + ? [ + { + name: '@tauri-apps/api/event', + importNames: ['listen', 'emit', 'emitTo', 'once'], + allowTypeImports: true, + message: + 'The event bus owns Tauri event subscriptions and emissions.', + }, + ] + : []), + ] + const patterns = allowCommands + ? [] + : [ + { + regex: '(^|/)bindings(\\.ts)?$', + importNames: ['commands'], + allowTypeImports: true, + message: + 'Only services/cmds may import generated command implementations.', + }, + ] + const modules = [ + ...(!allowInvoke ? ['@tauri-apps/api/core'] : []), + ...(!allowEvents ? ['@tauri-apps/api/event'] : []), + ] + const syntax = modules.flatMap((name) => [ + { + selector: `ImportExpression[source.value='${name}']`, + message: + 'Use the owned IPC service boundary instead of dynamically importing Tauri APIs.', + }, + { + selector: `CallExpression[callee.name='require'][arguments.0.value='${name}']`, + message: + 'Use the owned IPC service boundary instead of requiring Tauri APIs.', + }, + { + selector: `TSImportEqualsDeclaration[moduleReference.expression.value='${name}']`, + message: 'Use the owned IPC service boundary for Tauri APIs.', + }, + ]) + if (!allowCommands) { + syntax.push( + { + selector: 'ImportExpression[source.value=/bindings(\\.ts)?$/]', + message: + 'Only services/cmds may load generated command implementations.', + }, + { + selector: + "CallExpression[callee.name='require'][arguments.0.value=/bindings(\\.ts)?$/]", + message: + 'Only services/cmds may load generated command implementations.', + }, + { + selector: + 'TSImportEqualsDeclaration[moduleReference.expression.value=/bindings(\\.ts)?$/]', + message: + 'Only services/cmds may load generated command implementations.', + }, + ) + } + return { + 'no-restricted-imports': ['error', { paths, patterns }], + 'no-restricted-syntax': ['error', ...syntax], + } +} + export default defineConfig([ pluginESx.configs['flat/restrict-to-es2022'], { @@ -162,4 +248,33 @@ export default defineConfig([ }, }, }, + { + files: ['src/**/*.{ts,tsx,js,jsx,mts,mjs}'], + rules: ipcBoundaryRules(), + }, + { + files: ['src/services/cmds.ts'], + rules: ipcBoundaryRules({ allowCommands: true }), + }, + { + files: ['src/services/bindings.ts'], + rules: ipcBoundaryRules({ allowCommands: true, allowInvoke: true }), + }, + { + files: ['src/services/bus/**/*.{ts,tsx}'], + rules: ipcBoundaryRules({ allowEvents: true }), + }, + { + files: [ + 'src/components/proxy/proxy-chain.tsx', + 'src/components/proxy/proxy-groups-chain.tsx', + 'src/pages/proxies.tsx', + ], + rules: { + '@typescript-eslint/no-floating-promises': [ + 'error', + { ignoreVoid: false }, + ], + }, + }, ]) diff --git a/scripts/check-architecture.mjs b/scripts/check-architecture.mjs index 429f413a6..78afa3c19 100644 --- a/scripts/check-architecture.mjs +++ b/scripts/check-architecture.mjs @@ -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: [/(? file.startsWith('src/services/bus/'), - description: 'Tauri listen outside the event bus (src/services/bus)', - }, - emit: { - patterns: [/(? 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 ... (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') diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 23bc889f7..439b23e2e 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -465,7 +465,7 @@ export const ProxyChain = ({ id: `select-proxy-node:${targetGroup}`, errorNotice: false, }) - recordSelection(targetGroup, 'DIRECT') + await recordSelection(targetGroup, 'DIRECT') } catch { if (currentProxyChain.length >= 1) { try { @@ -477,7 +477,7 @@ export const ProxyChain = ({ errorNotice: false, }, ) - recordSelection(targetGroup, currentProxyChain[0].name) + await recordSelection(targetGroup, currentProxyChain[0].name) } catch { // ignore } @@ -553,12 +553,12 @@ export const ProxyChain = ({ ) // The chain moves the group like any other selection, so the profile has to learn about // it: what the profile holds is what gets re-applied the next time the core starts. - recordSelection(targetGroup || 'GLOBAL', lastNode.name) + await recordSelection(targetGroup || 'GLOBAL', lastNode.name) localStorage.setItem('proxy-chain-group', targetGroup || 'GLOBAL') localStorage.setItem('proxy-chain-exit-node', lastNode.name) // 刷新代理信息以更新连接状态 - refreshProxy() + await refreshProxy() debugLog('Successfully connected to proxy chain') } catch (error) { console.error('Failed to connect to proxy chain:', error) diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index bccc06534..cbf1d9ce3 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -62,7 +62,7 @@ const ProxyPage = () => { id: 'patch-clash-mode', errorNotice: false, }) - refreshClashConfig() + await refreshClashConfig() } catch (error) { showNotice.error(error) } @@ -127,7 +127,7 @@ const ProxyPage = () => { } } - fetchChainConfig() + fetchChainConfig().catch(() => {}) return () => { cancelled = true @@ -136,7 +136,7 @@ const ProxyPage = () => { useEffect(() => { if (normalizedMode && !isMode(normalizedMode)) { - onChangeMode('rule') + onChangeMode('rule').catch(() => {}) } }, [normalizedMode, onChangeMode])