From 6e08a77a8430593e9a72fe0059d9a6b7cddff6b2 Mon Sep 17 00:00:00 2001 From: Tunglies Date: Mon, 12 Oct 2026 06:16:05 +0000 Subject: [PATCH] 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 --- .github/workflows/frontend-check.yml | 16 +- .husky/pre-push | 1 + Cargo.lock | 95 ++ crates/clash-verge-media-unlock/Cargo.toml | 1 + crates/clash-verge-media-unlock/src/types.rs | 2 +- .../Cargo.toml | 1 + .../src/commands.rs | 35 +- eslint.config.ts | 115 ++ knip.json | 4 +- scripts/check-architecture.mjs | 100 +- scripts/check-contract-changes.mjs | 69 ++ scripts/contract-inputs.json | 23 + scripts/gen-contract.mjs | 128 +- src-tauri/Cargo.toml | 3 + src-tauri/src/bin/frontend-contract.rs | 18 +- src-tauri/src/cmd/app.rs | 30 +- src-tauri/src/cmd/backup.rs | 21 +- src-tauri/src/cmd/clash.rs | 60 +- src-tauri/src/cmd/lightweight.rs | 1 + src-tauri/src/cmd/listener.rs | 2 + src-tauri/src/cmd/media_unlock_checker/mod.rs | 3 + src-tauri/src/cmd/mod.rs | 2 +- src-tauri/src/cmd/network.rs | 54 +- src-tauri/src/cmd/profile.rs | 131 +- src-tauri/src/cmd/proxy.rs | 4 + src-tauri/src/cmd/runtime.rs | 77 +- src-tauri/src/cmd/save_profile.rs | 8 +- src-tauri/src/cmd/service.rs | 15 +- src-tauri/src/cmd/system.rs | 2 + src-tauri/src/cmd/uwp.rs | 1 + src-tauri/src/cmd/verge.rs | 2 + src-tauri/src/cmd/webdav.rs | 28 +- src-tauri/src/config/clash.rs | 12 +- src-tauri/src/config/dns.rs | 3 +- src-tauri/src/config/prfitem.rs | 35 +- src-tauri/src/config/profiles.rs | 4 +- src-tauri/src/config/verge.rs | 59 +- src-tauri/src/core/listener.rs | 12 +- src-tauri/src/core/manager/mod.rs | 4 +- src-tauri/src/core/notify/handle.rs | 5 +- src-tauri/src/core/notify/mod.rs | 4 +- src-tauri/src/core/notify/notification.rs | 119 +- src-tauri/src/core/proxy_view.rs | 62 +- src-tauri/src/core/runstate/health.rs | 32 +- src-tauri/src/core/service.rs | 2 +- src-tauri/src/core/tray/mod.rs | 10 +- src-tauri/src/core/updater.rs | 4 +- src-tauri/src/core/validate.rs | 16 +- src-tauri/src/feat/backup.rs | 5 +- src-tauri/src/feat/core_upgrade.rs | 16 +- src-tauri/src/feat/dns.rs | 7 +- src-tauri/src/feat/icon.rs | 5 +- src-tauri/src/feat/proxy.rs | 12 +- src-tauri/src/ipc.rs | 234 ++++ src-tauri/src/lib.rs | 102 +- src/components/home/current-proxy-card.tsx | 8 +- src/components/home/home-profile-card.tsx | 34 +- src/components/home/proxy-tun-card.tsx | 2 +- src/components/home/test-card.tsx | 2 +- src/components/profile/profile-item.tsx | 36 +- src/components/profile/profile-viewer.tsx | 22 +- src/components/proxy/proxy-chain.tsx | 47 +- .../proxy/proxy-empty-state-model.ts | 10 +- src/components/proxy/proxy-groups-chain.tsx | 22 +- src/components/proxy/proxy-render.tsx | 4 +- src/components/proxy/use-filter-sort.test.ts | 1 - src/components/proxy/use-render-list.ts | 14 +- .../setting/mods/backup-config-viewer.tsx | 6 +- src/components/setting/mods/dns-viewer.tsx | 2 +- .../setting/mods/sysproxy-viewer.tsx | 4 +- src/components/setting/mods/update-viewer.tsx | 2 +- src/components/setting/setting-clash.tsx | 2 +- .../setting/setting-verge-basic.tsx | 15 +- src/components/test/test-item.tsx | 12 +- src/hooks/use-profiles.ts | 5 +- src/hooks/use-proxy-selection.ts | 4 +- src/hooks/use-system-proxy-state.ts | 12 +- src/pages/profiles.tsx | 26 +- src/pages/proxies.tsx | 26 +- src/services/bindings.ts | 1058 +++++++++++++++++ src/services/bus/create-bus-handlers.ts | 6 +- src/services/cmds.ts | 447 +++---- src/services/contract.ts | 57 +- src/services/delay.test.ts | 6 +- src/services/notice-service.ts | 10 +- src/services/proxy-test-url.test.ts | 27 +- src/services/proxy-test-url.ts | 20 +- src/types/global.d.ts | 208 +--- src/types/proxy-view.ts | 95 +- 89 files changed, 2714 insertions(+), 1354 deletions(-) create mode 100644 scripts/check-contract-changes.mjs create mode 100644 scripts/contract-inputs.json create mode 100644 src-tauri/src/ipc.rs create mode 100644 src/services/bindings.ts diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 8cf1fdae4..937f9c89b 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read env: HUSKY: 0 @@ -99,14 +100,7 @@ jobs: id: check_inputs uses: dorny/paths-filter@v4 with: - filters: | - contract: - - 'src/services/contract.ts' - - 'scripts/gen-contract.mjs' - - 'src-tauri/src/core/notify/**' - - 'src-tauri/src/bin/**' - - 'src-tauri/src/lib.rs' - - '.github/workflows/frontend-check.yml' + filters: scripts/contract-inputs.json - name: Skip if contract inputs unchanged if: steps.check_inputs.outputs.contract != 'true' @@ -159,8 +153,6 @@ jobs: touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu" done - - name: Regenerate contract and require empty diff + - name: Check generated contract without changing the checkout if: steps.check_inputs.outputs.contract == 'true' - run: | - pnpm gen:contract - git diff --exit-code -- src/services/contract.ts + run: pnpm gen:contract --check diff --git a/.husky/pre-push b/.husky/pre-push index 9396b29ff..c3b725ee9 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -5,4 +5,5 @@ if ! command -v "cargo-make" >/dev/null 2>&1; then cargo install --force cargo-make fi +node scripts/check-contract-changes.mjs --push "$1" || exit $? cargo make pre-push diff --git a/Cargo.lock b/Cargo.lock index 83f2ef7ad..32c453c88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + [[package]] name = "adler2" version = "2.0.1" @@ -1135,6 +1141,8 @@ dependencies = [ "sha2 0.11.0", "smartstring", "socket2", + "specta", + "specta-typescript", "sysproxy", "tauri", "tauri-build", @@ -1155,6 +1163,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-wdio-webdriver", "tauri-plugin-window-state", + "tauri-specta", "tokio", "tokio-rustls", "tokio-stream", @@ -1213,6 +1222,7 @@ dependencies = [ "rust_iso3166", "serde", "serde_json", + "specta", "tokio", ] @@ -6804,6 +6814,58 @@ dependencies = [ "system-deps 6.2.2", ] +[[package]] +name = "specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" +dependencies = [ + "paste", + "rustc_version 0.4.1", + "serde_json", + "specta-macros", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "specta-serde" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" +dependencies = [ + "specta", + "specta-macros", +] + +[[package]] +name = "specta-typescript" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" +dependencies = [ + "specta", +] + +[[package]] +name = "specta-util" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" +dependencies = [ + "specta", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7159,6 +7221,7 @@ dependencies = [ "serde_json", "serde_repr", "serialize-to-javascript", + "specta", "swift-rs", "tauri-build", "tauri-macros", @@ -7276,6 +7339,7 @@ dependencies = [ "libc", "parking_lot", "serde", + "specta", "sysinfo", "tauri", "tauri-plugin-clipboard-manager", @@ -7655,6 +7719,37 @@ dependencies = [ "wry", ] +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-serde", + "specta-typescript", + "specta-util", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tauri-utils" version = "2.10.1" diff --git a/crates/clash-verge-media-unlock/Cargo.toml b/crates/clash-verge-media-unlock/Cargo.toml index eec2c2e0c..2a1395b4e 100644 --- a/crates/clash-verge-media-unlock/Cargo.toml +++ b/crates/clash-verge-media-unlock/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } clash-verge-logging = { workspace = true } chrono = { workspace = true } log = { workspace = true } diff --git a/crates/clash-verge-media-unlock/src/types.rs b/crates/clash-verge-media-unlock/src/types.rs index f643cc3b9..58b41374c 100644 --- a/crates/clash-verge-media-unlock/src/types.rs +++ b/crates/clash-verge-media-unlock/src/types.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use super::utils::{country_code_to_emoji, get_local_date_string}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)] pub struct UnlockItem { pub name: String, pub status: String, diff --git a/crates/tauri-plugin-clash-verge-sysinfo/Cargo.toml b/crates/tauri-plugin-clash-verge-sysinfo/Cargo.toml index 1536d7096..aa5f1ba02 100644 --- a/crates/tauri-plugin-clash-verge-sysinfo/Cargo.toml +++ b/crates/tauri-plugin-clash-verge-sysinfo/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" rust-version = "1.91" [dependencies] +specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] } tauri = { workspace = true } tauri-plugin-clipboard-manager = { workspace = true } parking_lot = { workspace = true } diff --git a/crates/tauri-plugin-clash-verge-sysinfo/src/commands.rs b/crates/tauri-plugin-clash-verge-sysinfo/src/commands.rs index 0c2a8f56c..bb3fa3adb 100644 --- a/crates/tauri-plugin-clash-verge-sysinfo/src/commands.rs +++ b/crates/tauri-plugin-clash-verge-sysinfo/src/commands.rs @@ -1,53 +1,46 @@ use parking_lot::RwLock; use serde::Serialize; use tauri::{AppHandle, Runtime, State, command}; -use tauri_plugin_clipboard_manager::{ClipboardExt as _, Error}; +use tauri_plugin_clipboard_manager::ClipboardExt as _; use crate::Platform; -#[derive(Serialize)] +#[derive(Serialize, specta::Type)] pub struct SystemInfo { pub system_name: String, pub system_version: String, - pub system_kernel_version: String, - pub system_arch: String, - pub app_version: String, - pub app_core_mode: String, - pub app_is_admin: bool, } -impl From for SystemInfo { - fn from(platform: Platform) -> Self { +impl From<&Platform> for SystemInfo { + fn from(platform: &Platform) -> Self { Self { - system_name: platform.sysinfo.system_name, - system_version: platform.sysinfo.system_version, - system_kernel_version: platform.sysinfo.system_kernel_version, - system_arch: platform.sysinfo.system_arch, - app_version: platform.appinfo.app_version, - app_core_mode: platform.appinfo.app_core_mode, - app_is_admin: platform.appinfo.app_is_admin, + system_name: platform.sysinfo.system_name.clone(), + system_version: platform.sysinfo.system_version.clone(), } } } #[command] -pub fn get_system_info(state: State<'_, RwLock>) -> Result { +#[specta::specta] +pub fn get_system_info(state: State<'_, RwLock>) -> Result { let platform = state.inner().read(); - Ok(SystemInfo::from(platform.clone())) + Ok(SystemInfo::from(&*platform)) } /// 获取应用的运行时间(毫秒) #[command] -pub fn get_app_uptime(state: State<'_, RwLock>) -> Result { +#[specta::specta] +pub fn get_app_uptime(state: State<'_, RwLock>) -> Result { Ok(state.inner().read().appinfo.app_startup_time.elapsed().as_millis()) } #[command] +#[specta::specta] pub fn export_diagnostic_info( app_handle: AppHandle, state: State<'_, RwLock>, -) -> Result<(), Error> { +) -> Result<(), String> { let info = state.inner().read().to_string(); let clipboard = app_handle.clipboard(); - clipboard.write_text(info) + clipboard.write_text(info).map_err(|error| error.to_string()) } 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/knip.json b/knip.json index 9be0c22de..6ea552114 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/polyfills/*.js!"], + "entry": ["src/polyfills/*.js!", "scripts/check-contract-changes.mjs!"], "project": ["src/**/*.{js,jsx,ts,tsx,sass,scss}!", "scripts/**/*.{js,mjs}!"], "include": [ "files", @@ -18,7 +18,7 @@ "ignoreDependencies": ["lint-staged"], "ignoreIssues": { "src/types/generated/**": ["exports", "types"], - "src/services/contract.ts": ["exports", "types"] + "src/services/bindings.ts": ["types"] }, "cycles": { "dynamicImports": true 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/scripts/check-contract-changes.mjs b/scripts/check-contract-changes.mjs new file mode 100644 index 000000000..20c86d9f3 --- /dev/null +++ b/scripts/check-contract-changes.mjs @@ -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 ') + 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') +} diff --git a/scripts/contract-inputs.json b/scripts/contract-inputs.json new file mode 100644 index 000000000..4e9f93100 --- /dev/null +++ b/scripts/contract-inputs.json @@ -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" + ] +} diff --git a/scripts/gen-contract.mjs b/scripts/gen-contract.mjs index c2afba095..f1103d898 100644 --- a/scripts/gen-contract.mjs +++ b/scripts/gen-contract.mjs @@ -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`, -) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b5ad51225..6a1bc7942 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -109,6 +109,9 @@ webpki-roots = "1.0.9" dark-light = "3.0.0" bytes = "1.12.1" tokio-util = { version = "0.7.19", features = ["time"] } +specta = { version = "=2.0.0-rc.25", features = ["derive", "function", "serde_json"] } +tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } +specta-typescript = "=0.0.12" [target.'cfg(unix)'.dependencies] libc = "0.2.190" diff --git a/src-tauri/src/bin/frontend-contract.rs b/src-tauri/src/bin/frontend-contract.rs index 2ce0ecede..ba205060d 100644 --- a/src-tauri/src/bin/frontend-contract.rs +++ b/src-tauri/src/bin/frontend-contract.rs @@ -1,8 +1,12 @@ -//! Prints the frontend wire contract as JSON to stdout. Zero new dependencies: -//! the payload is derived from the same serializers the app uses at runtime. -//! Consumed by scripts/gen-contract.mjs; CI regenerates and diffs the committed -//! TypeScript artifact. - -fn main() { - println!("{}", app_lib::frontend_wire_contract()); +fn main() -> Result<(), Box> { + let mut args = std::env::args_os().skip(1); + let output = args + .next() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../src/services/bindings.ts")); + if args.next().is_some() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "expected at most one output path").into()); + } + app_lib::ipc::builder().export(specta_typescript::Typescript::default(), output)?; + Ok(()) } diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 425d31f7b..e043dbc4e 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -9,12 +9,14 @@ use smartstring::alias::String; use tauri::{AppHandle, Manager as _, ipc::Channel}; #[tauri::command] +#[specta::specta] pub async fn open_app_dir() -> CmdResult<()> { let app_dir = dirs::app_home_dir().stringify_err()?; open::that(app_dir).stringify_err() } #[tauri::command] +#[specta::specta] pub async fn open_core_dir() -> CmdResult<()> { let core_dir = tauri::utils::platform::current_exe().stringify_err()?; let core_dir = core_dir.parent().ok_or("failed to get core dir")?; @@ -22,12 +24,14 @@ pub async fn open_core_dir() -> CmdResult<()> { } #[tauri::command] +#[specta::specta] pub async fn open_logs_dir() -> CmdResult<()> { let log_dir = dirs::app_logs_dir().stringify_err()?; open::that(log_dir).stringify_err() } #[tauri::command] +#[specta::specta] pub fn open_devtools(app_handle: AppHandle) { if let Some(window) = app_handle.get_webview_window("main") { if !window.is_devtools_open() { @@ -39,22 +43,26 @@ pub fn open_devtools(app_handle: AppHandle) { } #[tauri::command] +#[specta::specta] pub async fn exit_app() { feat::quit().await; } #[tauri::command] +#[specta::specta] pub async fn restart_app() -> CmdResult<()> { feat::restart_app().await; Ok(()) } #[tauri::command] +#[specta::specta] pub async fn install_update( app_handle: AppHandle, - version: String, + version: std::string::String, on_event: Channel, ) -> CmdResult { + let version: String = version.into(); SilentUpdater::global() .install_update(&app_handle, &version, |event| { let _ = on_event.send(event); @@ -64,22 +72,32 @@ pub async fn install_update( } #[tauri::command] +#[specta::specta] pub fn cancel_update_download() { SilentUpdater::global().cancel_download(); } #[tauri::command] -pub fn get_app_dir() -> CmdResult { +#[specta::specta] +pub fn get_app_dir() -> CmdResult { let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into(); Ok(app_home_dir) } #[tauri::command] -pub async fn download_icon_cache(url: String, name: String) -> CmdResult { - feat::download_icon_cache(url, name).await +#[specta::specta] +pub async fn download_icon_cache( + url: std::string::String, + name: std::string::String, +) -> CmdResult { + let url: String = url.into(); + let name: String = name.into(); + feat::download_icon_cache(url, name).await.map(Into::into) } #[tauri::command] -pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult { - feat::copy_icon_file(path, icon_info).await +#[specta::specta] +pub async fn copy_icon_file(path: std::string::String, icon_info: feat::IconInfo) -> CmdResult { + let path: String = path.into(); + feat::copy_icon_file(path, icon_info).await.map(Into::into) } diff --git a/src-tauri/src/cmd/backup.rs b/src-tauri/src/cmd/backup.rs index 52aa2f0d9..349c869b5 100644 --- a/src-tauri/src/cmd/backup.rs +++ b/src-tauri/src/cmd/backup.rs @@ -4,33 +4,44 @@ use feat::LocalBackupFile; use smartstring::alias::String; #[tauri::command] +#[specta::specta] pub async fn create_local_backup() -> CmdResult<()> { feat::create_local_backup().await.stringify_err() } #[tauri::command] +#[specta::specta] pub async fn list_local_backup() -> CmdResult> { feat::list_local_backup().await.stringify_err() } #[tauri::command] -pub async fn delete_local_backup(filename: String) -> CmdResult<()> { +#[specta::specta] +pub async fn delete_local_backup(filename: std::string::String) -> CmdResult<()> { + let filename: String = filename.into(); feat::delete_local_backup(filename).await.stringify_err() } #[tauri::command] -pub async fn restore_local_backup(filename: String) -> CmdResult<()> { +#[specta::specta] +pub async fn restore_local_backup(filename: std::string::String) -> CmdResult<()> { + let filename: String = filename.into(); feat::restore_local_backup(filename) .await .map_err(|error| super::proxy_aware_error(&error)) } #[tauri::command] -pub async fn import_local_backup(source: String) -> CmdResult { - feat::import_local_backup(source).await.stringify_err() +#[specta::specta] +pub async fn import_local_backup(source: std::string::String) -> CmdResult { + let source: String = source.into(); + feat::import_local_backup(source).await.map(Into::into).stringify_err() } #[tauri::command] -pub async fn export_local_backup(filename: String, destination: String) -> CmdResult<()> { +#[specta::specta] +pub async fn export_local_backup(filename: std::string::String, destination: std::string::String) -> CmdResult<()> { + let filename: String = filename.into(); + let destination: String = destination.into(); feat::export_local_backup(filename, destination).await.stringify_err() } diff --git a/src-tauri/src/cmd/clash.rs b/src-tauri/src/cmd/clash.rs index 2d5a567db..6fb9602ad 100644 --- a/src-tauri/src/cmd/clash.rs +++ b/src-tauri/src/cmd/clash.rs @@ -12,30 +12,33 @@ use crate::{ }, }; use clash_verge_logging::{Type, logging, logging_error}; -use serde_yaml_ng::Mapping; use smartstring::alias::String; use tokio::fs; #[tauri::command] +#[specta::specta] pub async fn copy_clash_env() -> CmdResult { - feat::copy_clash_env().await; - Ok(()) + feat::copy_clash_env().await.stringify_err() } #[tauri::command] +#[specta::specta] pub async fn get_clash_info() -> CmdResult { Ok(Config::clash().await.data_arc().get_client_info()) } #[tauri::command] -pub async fn patch_clash_config(payload: Mapping) -> CmdResult { - feat::patch_clash(&payload) +#[specta::specta] +pub async fn patch_clash_config(payload: crate::ipc::YamlMapping) -> CmdResult { + feat::patch_clash(&payload.0) .await .map_err(|error| proxy_aware_coded_error(&error, "CLASH_CONFIG_UPDATE_FAILED")) } #[tauri::command] -pub async fn patch_clash_mode(payload: String) -> CmdResult { +#[specta::specta] +pub async fn patch_clash_mode(payload: std::string::String) -> CmdResult { + let payload: String = payload.into(); feat::change_clash_mode(payload) .await .with_error_code("CLASH_MODE_UPDATE_FAILED") @@ -43,12 +46,15 @@ pub async fn patch_clash_mode(payload: String) -> CmdResult { /// Reads the saved mode without depending on strict mihomo `/configs` deserialization. #[tauri::command] -pub async fn get_clash_mode() -> CmdResult> { - Ok(Config::clash().await.data_arc().get_mode().map(Into::into)) +#[specta::specta] +pub async fn get_clash_mode() -> CmdResult> { + Ok(Config::clash().await.data_arc().get_mode()) } #[tauri::command] -pub async fn change_clash_core(clash_core: String) -> CmdResult> { +#[specta::specta] +pub async fn change_clash_core(clash_core: std::string::String) -> CmdResult> { + let clash_core: String = clash_core.into(); logging!(info, Type::Config, "changing core to {clash_core}"); match CoreManager::global().change_core(&clash_core).await { @@ -80,6 +86,7 @@ pub async fn change_clash_core(clash_core: String) -> CmdResult CmdResult { logging_error!(Type::Core, profiles_save_file_safe().await); let result = CoreManager::global() @@ -94,6 +101,7 @@ pub async fn restart_core() -> CmdResult { /// Replaces the managed core binary atomically instead of letting mihomo overwrite itself. #[tauri::command] +#[specta::specta] pub async fn upgrade_clash_core(force: bool) -> CmdResult { let report = feat::upgrade_core(force).await.with_error_code("CORE_UPGRADE_FAILED")?; if report.upgraded { @@ -103,7 +111,9 @@ pub async fn upgrade_clash_core(force: bool) -> CmdResult CmdResult { +#[specta::specta] +pub async fn test_delay(url: std::string::String) -> CmdResult { + let url: String = url.into(); let result = match feat::test_delay(url).await { Ok(delay) => delay, Err(e) => { @@ -115,13 +125,14 @@ pub async fn test_delay(url: String) -> CmdResult { } #[tauri::command] -pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { +#[specta::specta] +pub async fn save_dns_config(dns_config: crate::ipc::YamlMapping) -> CmdResult { use crate::utils::dirs; use tokio::fs; let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); - let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config).stringify_err()?; + let yaml_str = yaml_emitter::to_mihomo_config_string(&dns_config.0).stringify_err()?; fs::write(&dns_path, yaml_str).await.stringify_err()?; logging!(info, Type::Config, "DNS config saved to {dns_path:?}"); @@ -129,22 +140,27 @@ pub async fn save_dns_config(dns_config: Mapping) -> CmdResult { } #[tauri::command] +#[specta::specta] pub fn take_dns_override_notice() -> bool { crate::config::dns::take_dns_override_notice() } #[tauri::command] +#[specta::specta] pub async fn set_dns_override( - profile_uid: String, + profile_uid: std::string::String, enabled: bool, - confirmation: Option, + confirmation: Option, ) -> CmdResult { + let profile_uid: String = profile_uid.into(); + let confirmation = confirmation.map(String::from); feat::set_dns_override(profile_uid, enabled, confirmation) .await .map_err(|error| proxy_aware_coded_error(&error, "DNS_OVERRIDE_UPDATE_FAILED")) } #[tauri::command] +#[specta::specta] pub async fn apply_dns_config(apply: bool) -> CmdResult { if apply { let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); @@ -163,7 +179,7 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { ); })?; - let patch_config = serde_yaml_ng::from_str::(&dns_yaml).stringify_err_log(|e| { + serde_yaml_ng::from_str::(&dns_yaml).stringify_err_log(|e| { logging!( error, Type::Config, @@ -172,13 +188,6 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { ); })?; - let mut patch = serde_yaml_ng::Mapping::new(); - patch.insert("dns".into(), patch_config.into()); - - Config::runtime().await.edit_draft(|d| { - d.patch_config(&patch); - }); - CoreManager::global().update_config_checked().await.map_err(|error| { logging!(error, Type::Config, "failed to apply the config with DNS: {error:#}"); proxy_aware_error(&error) @@ -202,20 +211,22 @@ pub async fn apply_dns_config(apply: bool) -> CmdResult { } #[tauri::command] -pub async fn get_dns_config_content() -> CmdResult> { +#[specta::specta] +pub async fn get_dns_config_content() -> CmdResult> { use crate::utils::dirs; use tokio::fs; let dns_path = dirs::app_home_dir().stringify_err()?.join(constants::files::DNS_CONFIG); match fs::read_to_string(&dns_path).await { - Ok(content) => Ok(Some(content.into())), + Ok(content) => Ok(Some(content)), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(error) => Err(CommandFailure::plain(error)), } } #[tauri::command] +#[specta::specta] pub async fn validate_dns_config() -> CmdResult { let app_dir = dirs::app_home_dir().stringify_err()?; let dns_path = app_dir.join(constants::files::DNS_CONFIG); @@ -232,6 +243,7 @@ pub async fn validate_dns_config() -> CmdResult { } #[tauri::command] +#[specta::specta] pub async fn get_clash_logs() -> CmdResult> { let logs = CoreManager::global().get_clash_logs().await.unwrap_or_default(); Ok(logs) diff --git a/src-tauri/src/cmd/lightweight.rs b/src-tauri/src/cmd/lightweight.rs index af1fe7ad9..c6be85136 100644 --- a/src-tauri/src/cmd/lightweight.rs +++ b/src-tauri/src/cmd/lightweight.rs @@ -3,6 +3,7 @@ use crate::core::lightweight; use super::CmdResult; #[tauri::command] +#[specta::specta] pub async fn entry_lightweight_mode() -> CmdResult { lightweight::entry_lightweight_mode().await; Ok(()) diff --git a/src-tauri/src/cmd/listener.rs b/src-tauri/src/cmd/listener.rs index 610a2628f..c54f913a7 100644 --- a/src-tauri/src/cmd/listener.rs +++ b/src-tauri/src/cmd/listener.rs @@ -5,11 +5,13 @@ use crate::{ }; #[tauri::command] +#[specta::specta] pub async fn probe_listener(request: ListenerProbe) -> CmdResult { feat::probe_listener(request).await.stringify_err() } #[tauri::command] +#[specta::specta] pub async fn save_proxy_ports(settings: ProxyPortSettings) -> CmdResult { feat::save_proxy_ports(settings) .await diff --git a/src-tauri/src/cmd/media_unlock_checker/mod.rs b/src-tauri/src/cmd/media_unlock_checker/mod.rs index 42862117e..e5aa71560 100644 --- a/src-tauri/src/cmd/media_unlock_checker/mod.rs +++ b/src-tauri/src/cmd/media_unlock_checker/mod.rs @@ -4,6 +4,7 @@ use std::time::Duration; use tauri::{command, ipc::Channel}; #[command] +#[specta::specta] pub fn get_unlock_items() -> Vec { clash_verge_media_unlock::default_unlock_items() } @@ -26,6 +27,7 @@ async fn create_client() -> Result { } #[command] +#[specta::specta] pub async fn check_media_unlock(on_complete: Channel) -> Result, String> { let client = create_client().await?; Ok(clash_verge_media_unlock::check_media_unlock(&client, |item| { @@ -35,6 +37,7 @@ pub async fn check_media_unlock(on_complete: Channel) -> Result Result { let client = create_client().await?; clash_verge_media_unlock::check_media_unlock_item(&client, &name).await diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index a8c6bca68..1bb2d3041 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -4,7 +4,7 @@ use smartstring::alias::String; pub type CmdResult = Result; /// Structured command failure returned to the frontend. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)] pub struct CommandFailure { /// Stable frontend identifier, when classified. #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src-tauri/src/cmd/network.rs b/src-tauri/src/cmd/network.rs index dd3475336..8d4aeed96 100644 --- a/src-tauri/src/cmd/network.rs +++ b/src-tauri/src/cmd/network.rs @@ -3,13 +3,12 @@ use crate::cmd::StringifyErr as _; use crate::core::{proxy_control, sysopt::Sysopt}; use gethostname::gethostname; -use network_interface::NetworkInterface; -use serde_yaml_ng::Mapping; use sysproxy::{Autoproxy, Sysproxy}; use tauri_plugin_clash_verge_sysinfo; #[tauri::command] -pub async fn get_sys_proxy() -> CmdResult { +#[specta::specta] +pub async fn get_sys_proxy() -> CmdResult { Sysopt::global().wait_idle().await; // With no network service there is no proxy configured anywhere, which reads as disabled. let sys_proxy = match Sysproxy::get_system_proxy() { @@ -17,40 +16,40 @@ pub async fn get_sys_proxy() -> CmdResult { other => other.stringify_err()?, }; let Sysproxy { - ref host, - ref bypass, - ref port, - ref enable, + host, + bypass, + port, + enable, } = sys_proxy; - let mut map = Mapping::new(); - map.insert("enable".into(), (*enable).into()); - map.insert("server".into(), format!("{}:{}", host, port).into()); - map.insert("bypass".into(), bypass.as_str().into()); - Ok(map) + Ok(SystemProxy { + enable, + server: format!("{host}:{port}"), + bypass, + }) } #[tauri::command] -pub async fn get_auto_proxy() -> CmdResult { +#[specta::specta] +pub async fn get_auto_proxy() -> CmdResult { Sysopt::global().wait_idle().await; let auto_proxy = match Autoproxy::get_auto_proxy() { Err(error) if proxy_control::is_missing_network_service(&error) => Autoproxy::default(), other => other.stringify_err()?, }; - let Autoproxy { ref enable, ref url } = auto_proxy; + let Autoproxy { enable, url } = auto_proxy; - let mut map = Mapping::new(); - map.insert("enable".into(), (*enable).into()); - map.insert("url".into(), url.as_str().into()); - Ok(map) + Ok(AutoProxy { enable, url }) } #[tauri::command] +#[specta::specta] pub fn get_embedded_server_port() -> CmdResult { crate::utils::server::embedded_server_port().stringify_err() } #[tauri::command] +#[specta::specta] pub fn get_system_hostname() -> String { match gethostname().into_string() { Ok(name) => name, @@ -62,12 +61,14 @@ pub fn get_system_hostname() -> String { } #[tauri::command] +#[specta::specta] pub fn get_network_interfaces() -> Vec { tauri_plugin_clash_verge_sysinfo::list_network_interfaces() } #[tauri::command] -pub fn get_network_interfaces_info() -> CmdResult> { +#[specta::specta] +pub fn get_network_interfaces_info() -> CmdResult> { use network_interface::{NetworkInterface, NetworkInterfaceConfig as _}; let names = get_network_interfaces(); @@ -77,9 +78,22 @@ pub fn get_network_interfaces_info() -> CmdResult> { for interface in interfaces { if names.contains(&interface.name) { - result.push(interface); + result.push(interface.into()); } } Ok(result) } + +#[derive(serde::Serialize, specta::Type)] +pub struct SystemProxy { + pub enable: bool, + pub server: String, + pub bypass: String, +} + +#[derive(serde::Serialize, specta::Type)] +pub struct AutoProxy { + pub enable: bool, + pub url: String, +} diff --git a/src-tauri/src/cmd/profile.rs b/src-tauri/src/cmd/profile.rs index b89545296..8c20504e8 100644 --- a/src-tauri/src/cmd/profile.rs +++ b/src-tauri/src/cmd/profile.rs @@ -33,6 +33,7 @@ fn profile_import_error(err: &anyhow::Error) -> std::string::String { } #[tauri::command] +#[specta::specta] pub async fn get_profiles() -> CmdResult> { let draft = Config::profiles().await; let data = draft.data_arc(); @@ -40,6 +41,7 @@ pub async fn get_profiles() -> CmdResult> { } #[tauri::command] +#[specta::specta] pub async fn enhance_profiles() -> CmdResult { match feat::enhance_profiles().await { Ok(outcome) if outcome.is_valid() => { @@ -65,6 +67,7 @@ pub async fn enhance_profiles() -> CmdResult { #[tauri::command] #[tracing::instrument(skip_all, level = "info", fields(url = %help::mask_url(&url), uid = tracing::field::Empty))] +#[specta::specta] pub async fn import_profile(url: std::string::String, option: Option) -> CmdResult { let item = &mut match PrfItem::from_url(&url, None, None, option.as_ref()).await { Ok(it) => it, @@ -94,7 +97,10 @@ pub async fn import_profile(url: std::string::String, option: Option) } #[tauri::command] -pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { +#[specta::specta] +pub async fn reorder_profile(active_id: std::string::String, over_id: std::string::String) -> CmdResult { + let active_id: String = active_id.into(); + let over_id: String = over_id.into(); match profiles_reorder_safe(&active_id, &over_id).await { Ok(_) => { logging!(info, Type::Cmd, "重新排序配置文件: {} -> {}", active_id, over_id); @@ -114,16 +120,16 @@ pub async fn reorder_profile(active_id: String, over_id: String) -> CmdResult { } #[tauri::command] -pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResult { +#[specta::specta] +pub async fn create_profile(item: ProfileCreate, file_data: Option) -> CmdResult { + let item: PrfItem = item.into(); + let file_data = file_data.map(String::from); match profiles_append_item_with_filedata_safe(&item, file_data).await { Ok(_) => { profiles_save_file_safe() .await .with_error_code("PROFILE_CREATE_FAILED")?; logging_error!(Type::Timer, Timer::global().refresh().await); - if let Some(uid) = &item.uid { - handle::Handle::notify_profile_changed(uid); - } Ok(()) } Err(err) => Err(coded_error("PROFILE_CREATE_FAILED", err)), @@ -131,7 +137,9 @@ pub async fn create_profile(item: PrfItem, file_data: Option) -> CmdResu } #[tauri::command] -pub async fn update_profile(index: String, option: Option) -> CmdResult { +#[specta::specta] +pub async fn update_profile(index: std::string::String, option: Option) -> CmdResult { + let index: String = index.into(); match feat::update_profile(&index, option.as_ref(), true).await { Ok(_) => Ok(()), Err(e) => { @@ -142,7 +150,9 @@ pub async fn update_profile(index: String, option: Option) -> CmdResu } #[tauri::command] -pub async fn delete_profile(index: String) -> CmdResult { +#[specta::specta] +pub async fn delete_profile(index: std::string::String) -> CmdResult { + let index: String = index.into(); let profile_write_guard = PROFILE_WRITE_LOCK.lock().await; let profiles = Config::profiles().await; @@ -313,9 +323,85 @@ async fn perform_config_update( } } +#[derive(Default, serde::Deserialize, specta::Type)] +#[serde(default)] +pub struct ProfileSelection { + #[specta(type = Option)] + pub current: Option, +} + +#[derive(Default, serde::Deserialize, specta::Type)] +#[serde(default)] +pub struct ProfileCreate { + #[serde(rename = "type")] + #[specta(type = Option)] + pub itype: Option, + #[specta(type = Option)] + pub name: Option, + #[specta(type = Option)] + pub desc: Option, + #[specta(type = Option)] + pub url: Option, + pub option: Option, +} + +impl From for PrfItem { + fn from(item: ProfileCreate) -> Self { + Self { + itype: item.itype, + name: item.name, + desc: item.desc, + url: item.url, + option: item.option, + ..Self::default() + } + } +} + +#[derive(Default, serde::Deserialize, specta::Type)] +#[serde(default)] +pub struct ProfilePatch { + #[serde(rename = "type")] + #[specta(type = Option)] + pub itype: Option, + #[specta(type = Option)] + pub name: Option, + #[specta(type = Option)] + pub desc: Option, + #[specta(type = Option)] + pub file: Option, + #[specta(type = Option)] + pub url: Option, + pub selected: Option>, + #[specta(type = Option>)] + pub latency_test_urls: Option>, + pub extra: Option, + pub updated: Option, + pub option: Option, +} + +impl From for PrfItem { + fn from(patch: ProfilePatch) -> Self { + Self { + itype: patch.itype, + name: patch.name, + desc: patch.desc, + file: patch.file, + url: patch.url, + selected: patch.selected, + latency_test_urls: patch.latency_test_urls, + extra: patch.extra, + updated: patch.updated, + option: patch.option, + ..Self::default() + } + } +} + #[tauri::command] #[tracing::instrument(skip_all, level = "info", fields(target = ?profiles.current))] -pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { +#[specta::specta] +pub async fn patch_profiles_config(profiles: ProfileSelection) -> CmdResult { if CURRENT_SWITCHING_PROFILE .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() @@ -325,11 +411,17 @@ pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult CmdResult CmdResult { - let profiles = IProfiles { + let profiles = ProfileSelection { current: Some(profile_index), - items: None, }; patch_profiles_config(profiles).await } #[tauri::command] -pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { +#[specta::specta] +pub async fn patch_profile(index: std::string::String, profile: ProfilePatch) -> CmdResult { + let profile: PrfItem = profile.into(); + let index: String = index.into(); let profiles = Config::profiles().await; let should_refresh_timer = if let Ok(old_profile) = profiles.latest_arc().get_item(&index) && let Some(new_option) = profile.option.as_ref() @@ -385,7 +479,8 @@ pub async fn patch_profile(index: String, profile: PrfItem) -> CmdResult { } #[tauri::command] -pub async fn view_profile(index: String) -> CmdResult { +#[specta::specta] +pub async fn view_profile(index: std::string::String) -> CmdResult { let profiles = Config::profiles().await; let profiles_ref = profiles.latest_arc(); let file = profiles_ref @@ -409,7 +504,8 @@ pub async fn view_profile(index: String) -> CmdResult { } #[tauri::command] -pub async fn read_profile_file(index: String) -> CmdResult { +#[specta::specta] +pub async fn read_profile_file(index: std::string::String) -> CmdResult { let item = { let profiles = Config::profiles().await; let profiles_ref = profiles.latest_arc(); @@ -429,7 +525,7 @@ pub async fn read_profile_file(index: String) -> CmdResult { .join(file.as_str()); match tokio::fs::try_exists(&path).await { Ok(true) => {} - Ok(false) => return Ok(String::new()), + Ok(false) => return Ok(std::string::String::new()), Err(err) => { return Err(coded_error( "PROFILE_READ_FAILED", @@ -440,11 +536,12 @@ pub async fn read_profile_file(index: String) -> CmdResult { } let data = item.read_file().await.with_error_code("PROFILE_READ_FAILED")?; - Ok(data) + Ok(data.into()) } #[tauri::command] -pub async fn get_next_update_time(uid: String) -> CmdResult> { +#[specta::specta] +pub async fn get_next_update_time(uid: std::string::String) -> CmdResult> { let timer = Timer::global(); let next_time = timer.get_next_update_time(&uid).await; Ok(next_time) diff --git a/src-tauri/src/cmd/proxy.rs b/src-tauri/src/cmd/proxy.rs index f22c38e9b..f20d0b6b6 100644 --- a/src-tauri/src/cmd/proxy.rs +++ b/src-tauri/src/cmd/proxy.rs @@ -18,6 +18,7 @@ use std::{ /// Merges one selection pair into fresh backend state to avoid stale-list overwrites. #[tauri::command] +#[specta::specta] pub async fn record_selected_node(group_name: String, node: String) -> CmdResult<()> { crate::config::profiles::record_selected_node(&group_name, &node) .await @@ -25,6 +26,7 @@ pub async fn record_selected_node(group_name: String, node: String) -> CmdResult } #[tauri::command] +#[specta::specta] pub async fn forget_selected_node(group_name: String) -> CmdResult<()> { crate::config::profiles::forget_selected_node(&group_name) .await @@ -104,6 +106,7 @@ fn runtime_group_scopes(config: Option<&Mapping>) -> BTreeMap CmdResult { proxy_view(None).await } @@ -135,6 +138,7 @@ pub(crate) async fn proxy_view(provider_timeout: Option) -> } #[tauri::command] +#[specta::specta] pub async fn sync_tray_proxy_selection() -> CmdResult<()> { if TRAY_SYNC_RUNNING .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) diff --git a/src-tauri/src/cmd/runtime.rs b/src-tauri/src/cmd/runtime.rs index 13e0e7c5f..c3a53a667 100644 --- a/src-tauri/src/cmd/runtime.rs +++ b/src-tauri/src/cmd/runtime.rs @@ -1,44 +1,57 @@ use super::CmdResult; use crate::{cmd::StringifyErr as _, config::Config, core::CoreManager, utils::yaml_emitter}; use anyhow::{Context as _, anyhow}; -use clash_verge_logging::{Type, logging}; -use serde_yaml_ng::Mapping; use smartstring::alias::String; use std::collections::HashMap; #[tauri::command] -pub async fn get_runtime_config() -> CmdResult> { - Ok(Config::runtime().await.latest_arc().config.clone()) +#[specta::specta] +pub async fn get_runtime_config() -> CmdResult> { + Ok(Config::runtime() + .await + .latest_arc() + .config + .clone() + .map(crate::ipc::YamlMapping)) } #[tauri::command] -pub async fn get_runtime_yaml() -> CmdResult { +#[specta::specta] +pub async fn get_runtime_yaml() -> CmdResult { let runtime = Config::runtime().await; let runtime = runtime.latest_arc(); let config = runtime.config.as_ref(); config .ok_or_else(|| anyhow!("failed to parse config to yaml file")) - .and_then(|config| { - yaml_emitter::to_mihomo_config_string(config) - .context("failed to convert config to yaml") - .map(|s| s.into()) - }) + .and_then(|config| yaml_emitter::to_mihomo_config_string(config).context("failed to convert config to yaml")) .stringify_err() } #[tauri::command] -pub async fn get_runtime_logs() -> CmdResult>> { - Ok(Config::runtime().await.latest_arc().chain_logs.clone()) +#[specta::specta] +pub async fn get_runtime_logs() -> CmdResult { + Ok(RuntimeLogs(Config::runtime().await.latest_arc().chain_logs.clone())) +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(transparent)] +pub struct RuntimeLogs( + #[specta(type = HashMap>)] + HashMap>, +); + +#[tauri::command] +#[specta::specta] +pub fn take_discarded_keys_notice() -> Option { + crate::enhance::take_discarded_keys_notice().map(Into::into) } #[tauri::command] -pub fn take_discarded_keys_notice() -> Option { - crate::enhance::take_discarded_keys_notice() -} - -#[tauri::command] -pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> CmdResult { +#[specta::specta] +pub async fn get_runtime_proxy_chain_config( + proxy_chain_exit_node: std::string::String, +) -> CmdResult { let runtime = Config::runtime().await; let runtime = runtime.latest_arc(); @@ -59,7 +72,7 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm false } }) { - proxies_chain.push(proxy.to_owned()); + proxies_chain.push(proxy); proxy_name = proxy.get("dialer-proxy").map(|x| x.as_str()); } @@ -68,18 +81,17 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm .find(|proxy| proxy.get("name").map(|x| x.as_str()) == proxy_name) && !proxies_chain.is_empty() { - proxies_chain.push(entry_proxy.to_owned()); + proxies_chain.push(entry_proxy); } proxies_chain.reverse(); - let mut config: HashMap> = HashMap::new(); + let mut config: HashMap> = HashMap::new(); config.insert("proxies".into(), proxies_chain); yaml_emitter::to_mihomo_config_string(&config) .context("YAML generation failed") - .map(|s| s.into()) .stringify_err() } else { Err("failed to get proxies or proxy-groups".into()) @@ -87,20 +99,13 @@ pub async fn get_runtime_proxy_chain_config(proxy_chain_exit_node: String) -> Cm } #[tauri::command] -pub async fn update_proxy_chain_config_in_runtime(proxy_chain_config: Option) -> CmdResult<()> { - match CoreManager::global() +#[specta::specta] +pub async fn update_proxy_chain_config_in_runtime( + proxy_chain_config: Option, +) -> CmdResult { + let proxy_chain_config = proxy_chain_config.map(|value| value.0); + CoreManager::global() .update_runtime_config(|d| d.update_proxy_chain_config(proxy_chain_config)) .await - { - Ok(outcome) if outcome.is_valid() => {} - Ok(outcome) => logging!( - warn, - Type::Core, - "Failed to apply runtime proxy chain config: {}", - outcome - ), - Err(err) => logging!(error, Type::Core, "Failed to apply runtime proxy chain config: {err:#}"), - } - - Ok(()) + .stringify_err() } diff --git a/src-tauri/src/cmd/save_profile.rs b/src-tauri/src/cmd/save_profile.rs index cfefc7133..984af1100 100644 --- a/src-tauri/src/cmd/save_profile.rs +++ b/src-tauri/src/cmd/save_profile.rs @@ -16,7 +16,13 @@ use tokio::fs; /// 保存profiles的配置 #[tauri::command] -pub async fn save_profile_file(index: String, file_data: Option) -> CmdResult { +#[specta::specta] +pub async fn save_profile_file( + index: std::string::String, + file_data: Option, +) -> CmdResult { + let index: String = index.into(); + let file_data = file_data.map(String::from); let file_data = match file_data { Some(d) => d, None => return Ok(ValidationOutcome::Valid), diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index 2f96ce98d..592df0417 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -8,11 +8,11 @@ use crate::{ }, }; -#[derive(Debug, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, PartialEq, Eq, serde::Serialize, specta::Type)] #[serde(tag = "status", rename_all = "camelCase")] pub enum ServiceInstallOutcome { Installed, - #[cfg(any(windows, test))] + #[cfg_attr(not(any(windows, test)), allow(dead_code))] Sidecar { reason: String, }, @@ -82,11 +82,13 @@ where } #[tauri::command] +#[specta::specta] pub async fn install_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::InstallRequired, "SERVICE_INSTALL_FAILED").await } #[tauri::command] +#[specta::specta] pub async fn uninstall_service() -> CmdResult { CoreManager::global() .uninstall_service_and_start_sidecar() @@ -95,16 +97,19 @@ pub async fn uninstall_service() -> CmdResult { } #[tauri::command] +#[specta::specta] pub async fn reinstall_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::ReinstallRequired, "SERVICE_REINSTALL_FAILED").await } #[tauri::command] +#[specta::specta] pub async fn repair_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await } #[tauri::command] +#[specta::specta] pub async fn continue_with_sidecar() -> CmdResult { crate::core::CoreManager::global() .continue_with_sidecar(None) @@ -113,31 +118,37 @@ pub async fn continue_with_sidecar() -> CmdResult { } #[tauri::command] +#[specta::specta] pub fn get_sidecar_failure() -> crate::core::notification::SidecarFailureSnapshot { crate::core::notification::sidecar_failure_snapshot() } #[tauri::command] +#[specta::specta] pub fn take_service_fallback_notice() -> Option { crate::core::service::take_service_fallback_notice() } #[tauri::command] +#[specta::specta] pub fn get_core_startup_error() -> Option { crate::core::CoreManager::global().get_startup_error() } #[tauri::command] +#[specta::specta] pub fn take_service_repair_notice() -> bool { crate::core::service::take_service_repair_notice() } #[tauri::command] +#[specta::specta] pub fn take_service_owner_notice() -> Option { crate::core::service::take_service_owner_notice() } #[tauri::command] +#[specta::specta] pub fn sync_runtime_providers() { if matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { request_runtime_provider_sync(timing::RUNTIME_PROVIDER_SETTLE); diff --git a/src-tauri/src/cmd/system.rs b/src-tauri/src/cmd/system.rs index dc5ac6cf8..e3f263cb9 100644 --- a/src-tauri/src/cmd/system.rs +++ b/src-tauri/src/cmd/system.rs @@ -5,11 +5,13 @@ use crate::core::{ /// Returns one coherent core/service snapshot instead of independently refreshed state. #[tauri::command] +#[specta::specta] pub async fn get_runtime_state() -> Result { Ok(RUN_STATE.settled().await.to_view()) } #[tauri::command] +#[specta::specta] pub async fn get_pending_failures() -> Vec { notification::pending_failures() } diff --git a/src-tauri/src/cmd/uwp.rs b/src-tauri/src/cmd/uwp.rs index 5337a5a7b..8637bfefc 100644 --- a/src-tauri/src/cmd/uwp.rs +++ b/src-tauri/src/cmd/uwp.rs @@ -22,6 +22,7 @@ mod platform { } #[tauri::command] +#[specta::specta] pub async fn invoke_uwp_tool() -> CmdResult { platform::invoke_uwp_tool() } diff --git a/src-tauri/src/cmd/verge.rs b/src-tauri/src/cmd/verge.rs index 2ab25fd3e..8fc2c6c70 100644 --- a/src-tauri/src/cmd/verge.rs +++ b/src-tauri/src/cmd/verge.rs @@ -9,12 +9,14 @@ use clash_verge_draft::SharedDraft; /// 获取Verge配置 #[tauri::command] +#[specta::specta] pub async fn get_verge_config() -> CmdResult> { feat::fetch_verge_config().await.stringify_err() } /// 修改Verge配置 #[tauri::command] +#[specta::specta] pub async fn patch_verge_config(payload: IVerge) -> CmdResult { let operation = system_proxy_operation(&payload); let result = match operation { diff --git a/src-tauri/src/cmd/webdav.rs b/src-tauri/src/cmd/webdav.rs index f4a1e6f25..dad1de657 100644 --- a/src-tauri/src/cmd/webdav.rs +++ b/src-tauri/src/cmd/webdav.rs @@ -4,11 +4,18 @@ use crate::{ config::{Config, IVerge}, core, feat, }; -use reqwest_dav::list_cmd::ListFile; use smartstring::alias::String; #[tauri::command] -pub async fn save_webdav_config(url: String, username: String, password: String) -> CmdResult<()> { +#[specta::specta] +pub async fn save_webdav_config( + url: std::string::String, + username: std::string::String, + password: std::string::String, +) -> CmdResult<()> { + let url: String = url.into(); + let username: String = username.into(); + let password: String = password.into(); let patch = IVerge { webdav_url: Some(url), webdav_username: Some(username), @@ -25,22 +32,31 @@ pub async fn save_webdav_config(url: String, username: String, password: String) } #[tauri::command] +#[specta::specta] pub async fn create_webdav_backup() -> CmdResult<()> { feat::create_backup_and_upload_webdav().await.stringify_err() } #[tauri::command] -pub async fn list_webdav_backup() -> CmdResult> { - feat::list_wevdav_backup().await.stringify_err() +#[specta::specta] +pub async fn list_webdav_backup() -> CmdResult> { + feat::list_wevdav_backup() + .await + .map(|files| files.into_iter().map(Into::into).collect()) + .stringify_err() } #[tauri::command] -pub async fn delete_webdav_backup(filename: String) -> CmdResult<()> { +#[specta::specta] +pub async fn delete_webdav_backup(filename: std::string::String) -> CmdResult<()> { + let filename: String = filename.into(); feat::delete_webdav_backup(filename).await.stringify_err() } #[tauri::command] -pub async fn restore_webdav_backup(filename: String) -> CmdResult<()> { +#[specta::specta] +pub async fn restore_webdav_backup(filename: std::string::String) -> CmdResult<()> { + let filename: String = filename.into(); feat::restore_webdav_backup(filename) .await .map_err(|error| super::proxy_aware_error(&error)) diff --git a/src-tauri/src/config/clash.rs b/src-tauri/src/config/clash.rs index 8495572ee..106c55d9f 100644 --- a/src-tauri/src/config/clash.rs +++ b/src-tauri/src/config/clash.rs @@ -308,7 +308,7 @@ impl IClashTemp { } } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] pub struct ClashInfo { /// clash core port pub mixed_port: u16, @@ -364,14 +364,14 @@ fn test_clash_info() { assert_eq!(get_case(8888, "192.168.1.1:80800"), get_result(8888, "127.0.0.1:9097")); } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] #[serde(rename_all = "kebab-case")] pub struct IClashExternalControllerCors { pub allow_origins: Option>, pub allow_private_network: Option, } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] #[serde(rename_all = "kebab-case")] pub struct IClash { pub mixed_port: Option, @@ -387,7 +387,7 @@ pub struct IClash { pub external_controller_cors: Option, } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] #[serde(rename_all = "kebab-case")] pub struct IClashTUN { pub enable: Option, @@ -397,7 +397,7 @@ pub struct IClashTUN { pub dns_hijack: Option>, } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] #[serde(rename_all = "kebab-case")] pub struct IClashDNS { pub enable: Option, @@ -414,7 +414,7 @@ pub struct IClashDNS { pub nameserver_policy: Option>, } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] #[serde(rename_all = "kebab-case")] pub struct IClashFallbackFilter { pub geoip: Option, diff --git a/src-tauri/src/config/dns.rs b/src-tauri/src/config/dns.rs index 7108b70c3..86727ab76 100644 --- a/src-tauri/src/config/dns.rs +++ b/src-tauri/src/config/dns.rs @@ -106,10 +106,11 @@ impl DnsOverrideState { } } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, specta::Type)] pub struct ProfileDnsSettings { pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub confirmation: Option, } diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index d09e14ed0..0c99355d5 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -27,28 +27,34 @@ pub(super) fn normalize_profile_home_url(raw: &str) -> Option { Some(url.to_string().into()) } -#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[derive(Debug, Clone, Deserialize, Serialize, Default, specta::Type)] +#[serde(default)] pub struct PrfItem { + #[specta(type = Option)] pub uid: Option, /// enum value: remote | local | script | merge #[serde(rename = "type")] + #[specta(type = Option)] pub itype: Option, - + #[specta(type = Option)] pub name: Option, - + #[specta(type = Option)] pub file: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub desc: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub selected: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option>)] pub latency_test_urls: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -60,19 +66,24 @@ pub struct PrfItem { pub option: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub home: Option, #[serde(skip)] + #[specta(type = Option)] pub file_data: Option, } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] +#[serde(default)] pub struct PrfSelected { + #[specta(type = Option)] pub name: Option, + #[specta(type = Option)] pub now: Option, } -#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize)] +#[derive(Default, Debug, Clone, Copy, Deserialize, Serialize, specta::Type)] pub struct PrfExtra { pub upload: u64, pub download: u64, @@ -80,9 +91,11 @@ pub struct PrfExtra { pub expire: u64, } -#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq, specta::Type)] +#[serde(default)] pub struct PrfOption { #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub user_agent: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -105,15 +118,15 @@ pub struct PrfOption { #[serde(skip_serializing_if = "Option::is_none")] pub allow_auto_update: Option, - + #[specta(type = Option)] pub merge: Option, - + #[specta(type = Option)] pub script: Option, - + #[specta(type = Option)] pub rules: Option, - + #[specta(type = Option)] pub proxies: Option, - + #[specta(type = Option)] pub groups: Option, } diff --git a/src-tauri/src/config/profiles.rs b/src-tauri/src/config/profiles.rs index 793619e7d..7680fff58 100644 --- a/src-tauri/src/config/profiles.rs +++ b/src-tauri/src/config/profiles.rs @@ -48,8 +48,10 @@ const SELECTED_NODES_SETTLE_INTERVAL: Duration = Duration::from_secs(1); const SELECTED_NODES_FIRST_PASS_BUDGET: Duration = Duration::from_secs(3); /// Define the `profiles.yaml` schema -#[derive(Default, Debug, Clone, Deserialize, Serialize)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] +#[serde(default)] pub struct IProfiles { + #[specta(type = Option)] pub current: Option, pub items: Option>, diff --git a/src-tauri/src/config/verge.rs b/src-tauri/src/config/verge.rs index d6d6b8dfe..45b0e18ec 100644 --- a/src-tauri/src/config/verge.rs +++ b/src-tauri/src/config/verge.rs @@ -11,26 +11,30 @@ use serde::{Deserialize, Serialize}; use smartstring::alias::String; /// ### `verge.yaml` schema -#[derive(Default, Debug, Clone, Deserialize, Serialize)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] +#[serde(default)] pub struct IVerge { /// silent | error | warn | info | debug | trace + #[specta(type = Option)] pub app_log_level: Option, /// Maximum app log size in KiB. pub app_log_max_size: Option, pub app_log_max_count: Option, - + #[specta(type = Option)] pub language: Option, /// `light` or `dark` or `system` + #[specta(type = Option)] pub theme_mode: Option, - + #[specta(type = Option)] pub tray_event: Option, - + #[specta(type = Option)] pub env_type: Option, - + #[specta(type = Option)] pub start_page: Option, + #[specta(type = Option)] pub startup_script: Option, pub traffic_graph: Option, @@ -48,18 +52,23 @@ pub struct IVerge { #[cfg(target_os = "macos")] #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub tray_icon: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub menu_icon: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option>)] pub menu_order: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub proxy_group_tools_position: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub notice_position: Option, pub collapse_navbar: Option, @@ -84,39 +93,43 @@ pub struct IVerge { pub enable_dns_settings: Option, #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + #[specta(type = std::collections::BTreeMap)] pub profile_dns_settings: std::collections::BTreeMap, pub use_default_bypass: Option, - + #[specta(type = Option)] pub system_proxy_bypass: Option, pub proxy_guard_duration: Option, pub proxy_auto_config: Option, - + #[specta(type = Option)] pub pac_file_content: Option, - + #[specta(type = Option)] pub proxy_host: Option, pub theme_setting: Option, - + #[specta(type = Option>)] pub web_ui_list: Option>, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub clash_core: Option, /// format: {func},{key} #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option>)] pub hotkeys: Option>, pub enable_global_hotkey: Option, + #[specta(type = Option)] pub home_cards: Option, pub auto_close_connection: Option, pub auto_check_update: Option, - + #[specta(type = Option)] pub default_latency_test: Option, pub default_latency_timeout: Option, @@ -171,6 +184,7 @@ pub struct IVerge { skip_serializing_if = "Option::is_none", default )] + #[specta(type = Option)] pub webdav_url: Option, #[serde( @@ -179,6 +193,7 @@ pub struct IVerge { skip_serializing_if = "Option::is_none", default )] + #[specta(type = Option)] pub webdav_username: Option, #[serde( @@ -187,12 +202,14 @@ pub struct IVerge { skip_serializing_if = "Option::is_none", default )] + #[specta(type = Option)] pub webdav_password: Option, #[cfg(target_os = "macos")] pub enable_tray_speed: Option, #[serde(skip_serializing_if = "Option::is_none")] + #[specta(type = Option)] pub tray_proxy_groups_display_mode: Option, pub tray_inline_outbound_modes: Option, @@ -209,27 +226,41 @@ pub struct IVerge { pub enable_external_controller: Option, } -#[derive(Default, Debug, Clone, Deserialize, Serialize)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] +#[serde(default)] pub struct IVergeTestItem { + #[specta(type = Option)] pub uid: Option, + #[specta(type = Option)] pub name: Option, + #[specta(type = Option)] pub icon: Option, + #[specta(type = Option)] pub url: Option, } -#[derive(Default, Debug, Clone, Deserialize, Serialize)] +#[derive(Default, Debug, Clone, Deserialize, Serialize, specta::Type)] +#[serde(default)] pub struct IVergeTheme { + #[specta(type = Option)] pub primary_color: Option, + #[specta(type = Option)] pub secondary_color: Option, + #[specta(type = Option)] pub primary_text: Option, + #[specta(type = Option)] pub secondary_text: Option, - + #[specta(type = Option)] pub info_color: Option, + #[specta(type = Option)] pub error_color: Option, + #[specta(type = Option)] pub warning_color: Option, + #[specta(type = Option)] pub success_color: Option, - + #[specta(type = Option)] pub font_family: Option, + #[specta(type = Option)] pub css_injection: Option, } diff --git a/src-tauri/src/core/listener.rs b/src-tauri/src/core/listener.rs index fa330d7f9..8fb97c82e 100644 --- a/src-tauri/src/core/listener.rs +++ b/src-tauri/src/core/listener.rs @@ -38,7 +38,7 @@ pub(crate) fn proxy_listener_keys() -> impl Iterator { PROXY_LISTENERS.iter().map(|(key, _, _)| *key) } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProxyPortSettings { pub mixed_port: u16, @@ -48,27 +48,27 @@ pub struct ProxyPortSettings { pub tproxy: ToggleableProxyPort, } -#[derive(Clone, Copy, Debug, Deserialize)] +#[derive(Clone, Copy, Debug, Deserialize, specta::Type)] pub struct ToggleableProxyPort { pub enabled: bool, pub port: u16, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ListenerProbe { pub address: String, pub transports: Vec, } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize, specta::Type)] #[serde(rename_all = "lowercase")] pub enum ListenerTransport { Tcp, Udp, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(tag = "status", rename_all = "camelCase")] pub enum ListenerProbeOutcome { Available, @@ -77,7 +77,7 @@ pub enum ListenerProbeOutcome { Indeterminate { message: String }, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(tag = "status", rename_all = "camelCase")] pub enum SaveProxyPortsOutcome { Saved, diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index b9ea77b04..1110540b1 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -57,7 +57,7 @@ fn claim_core_readiness_generation(state: &AtomicU64, captured_generation: u64) .is_ok() } -#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, serde::Serialize, PartialEq, Eq, specta::Type)] pub enum RunningMode { Service, Sidecar, @@ -75,7 +75,7 @@ impl fmt::Display for RunningMode { } /// Why the Core is not running, kept until a Core starts again. -#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq)] +#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq, specta::Type)] #[serde(tag = "kind", content = "detail", rename_all = "camelCase")] pub enum CoreFailure { StartFailed(String), diff --git a/src-tauri/src/core/notify/handle.rs b/src-tauri/src/core/notify/handle.rs index 0a0cbfe8b..6a05ab138 100644 --- a/src-tauri/src/core/notify/handle.rs +++ b/src-tauri/src/core/notify/handle.rs @@ -52,10 +52,7 @@ impl Handle { /// Sent on every transition, so the frontend does not have to poll to notice that the Core /// stopped or that the Service came back. pub fn notify_run_state(state: &crate::core::runstate::RunStateView) { - let Ok(state) = serde_json::to_value(state) else { - return; - }; - Self::send_event(FrontendEvent::RunStateChanged { state }); + Self::send_event(FrontendEvent::RunStateChanged { state: state.clone() }); } pub fn notify_profile_changed(profile_id: &String) { diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index 1387dbc81..9a6b7699f 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -3,9 +3,8 @@ pub use announce::{Refresh, announce}; pub mod desktop; pub mod handle; pub mod notification; -pub use notification::frontend_wire_contract; -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)] pub enum NoticeStatus { #[serde(rename = "info")] Info, @@ -83,6 +82,7 @@ impl NoticeStatus { /// Every variant in golden order. The wire-contract export and the golden /// test both derive their variant list from here; expected strings stay /// hand-written in the test so renames cannot slip through. + #[cfg(test)] pub const ALL: [Self; 34] = [ Self::Info, Self::ImportSubUrlOk, diff --git a/src-tauri/src/core/notify/notification.rs b/src-tauri/src/core/notify/notification.rs index 95eaa13da..be6a01f03 100644 --- a/src-tauri/src/core/notify/notification.rs +++ b/src-tauri/src/core/notify/notification.rs @@ -1,6 +1,7 @@ use super::NoticeStatus; use clash_verge_logging::{Type, logging}; use parking_lot::Mutex; +#[cfg(test)] use serde_json::json; use smartstring::alias::String; use std::sync::Arc; @@ -31,7 +32,7 @@ pub enum FrontendEvent<'a> { uid: &'a String, }, RunStateChanged { - state: serde_json::Value, + state: crate::core::runstate::RunStateView, }, PendingFailuresChanged, #[cfg(target_os = "linux")] @@ -42,7 +43,7 @@ pub enum FrontendEvent<'a> { /// Operation associated with a pending failure. #[allow(clippy::enum_variant_names)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub enum FailedOperation { SystemProxyEnable, @@ -72,12 +73,14 @@ impl FailedOperation { } /// Latest unresolved failure for one stable code. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct PendingFailure { /// Stable code used as the table key. + #[specta(type = std::string::String)] pub code: String, /// Full diagnostic context chain. + #[specta(type = std::string::String)] pub detail: String, pub operation: FailedOperation, /// Monotonic identity for repeated failures under the same code. @@ -241,9 +244,10 @@ pub fn what_was_asked() -> FailedOperation { static PENDING_FAILURES: LazyLock = LazyLock::new(FailureTable::default); /// A removal has a revision too, so a delayed read cannot restore a resolved failure. -#[derive(Debug, Default, Clone, serde::Serialize)] +#[derive(Debug, Default, Clone, serde::Serialize, specta::Type)] pub struct SidecarFailureSnapshot { pub revision: u64, + #[specta(type = Option)] pub detail: Option, } @@ -327,24 +331,7 @@ impl NotificationSystem { } fn serialize_event(event: FrontendEvent) -> (&'static str, Result) { - match event { - FrontendEvent::RefreshClash => ("verge://refresh-clash-config", Ok(json!("yes"))), - FrontendEvent::RefreshVerge => ("verge://refresh-verge-config", Ok(json!("yes"))), - FrontendEvent::RefreshProfiles => ("verge://refresh-profiles", Ok(json!("yes"))), - FrontendEvent::RefreshProxyConfig => ("verge://refresh-proxy-config", Ok(serde_json::Value::Null)), - FrontendEvent::NoticeMessage { status, message } => ( - "verge://notice-message", - serde_json::to_value((status, message.as_ref())), - ), - FrontendEvent::ProfileChanged { current_profile_id } => ("profile-changed", Ok(json!(current_profile_id))), - FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))), - FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))), - FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))), - FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)), - FrontendEvent::PendingFailuresChanged => ("verge://pending-failures-changed", Ok(serde_json::Value::Null)), - #[cfg(target_os = "linux")] - FrontendEvent::ThemeChanged { theme } => ("tauri://theme-changed", serde_json::to_value(theme)), - } + serialize_frontend_event(event) } pub(crate) fn send_event(app_handle: AppHandle, event: FrontendEvent) { @@ -381,20 +368,21 @@ mod wire_tests { } /// One sample per `FrontendEvent` variant, paired with its wire name and payload. -/// Shared by the wire golden test and the contract export so the two cannot drift. +#[cfg(test)] fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> { + let state = crate::core::runstate::RunState { + health: Default::default(), + pending: None, + sidecar_allowed: false, + mode: crate::core::manager::RunningMode::NotRunning, + is_admin: false, + op_in_flight: false, + } + .to_view(); vec![ - ( - FrontendEvent::RefreshClash, - "verge://refresh-clash-config", - json!("yes"), - ), - ( - FrontendEvent::RefreshVerge, - "verge://refresh-verge-config", - json!("yes"), - ), - (FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")), + (FrontendEvent::RefreshClash, "verge://refresh-clash-config", json!(null)), + (FrontendEvent::RefreshVerge, "verge://refresh-verge-config", json!(null)), + (FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!(null)), ( FrontendEvent::RefreshProxyConfig, "verge://refresh-proxy-config", @@ -414,11 +402,9 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, json!("profile"), ), ( - FrontendEvent::RunStateChanged { - state: json!({"running": true}), - }, + FrontendEvent::RunStateChanged { state: state.clone() }, "verge://run-state-changed", - json!({"running": true}), + serde_json::to_value(state).unwrap_or(serde_json::Value::Null), ), ( FrontendEvent::PendingFailuresChanged, @@ -445,24 +431,41 @@ fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, ] } -/// Machine-readable form of the frontend wire contract: every event name with a -/// serialized sample payload, plus every notice status string. The sample -/// payload's JSON shape (string / null / array / object) is what the frontend -/// generator maps to TypeScript types. `ThemeChanged` is a Tauri built-in -/// linux-only window event, not part of this contract. -pub fn frontend_wire_contract() -> serde_json::Value { - let uid = String::from("sample"); - let events: Vec<_> = sample_frontend_events(&uid) - .into_iter() - .map(|(event, _, _)| { - let (name, payload) = NotificationSystem::serialize_event(event); - json!({ "name": name, "sample": payload.unwrap_or(serde_json::Value::Null) }) - }) - .collect(); - let statuses: Vec<_> = NoticeStatus::ALL - .iter() - // A unit variant with a string rename cannot fail to serialize. - .filter_map(|status| serde_json::to_value(status).ok()) - .collect(); - json!({ "version": 1, "events": events, "noticeStatuses": statuses }) +macro_rules! frontend_events { + ($( $field:ident: $payload:ty = $name:literal, $pattern:pat => $value:expr; )*) => { + #[derive(serde::Serialize, specta::Type)] + pub struct VergeEventPayloads { + $(#[serde(rename = $name)] pub $field: $payload,)* + } + + fn serialize_frontend_event(event: FrontendEvent) -> (&'static str, Result) { + match event { + $($pattern => { + let payload: $payload = $value; + ($name, serde_json::to_value(payload)) + },)* + #[cfg(target_os = "linux")] + FrontendEvent::ThemeChanged { theme } => ("tauri://theme-changed", serde_json::to_value(theme)), + } + } + }; +} + +frontend_events! { + refresh_clash: () = "verge://refresh-clash-config", FrontendEvent::RefreshClash => (); + refresh_verge: () = "verge://refresh-verge-config", FrontendEvent::RefreshVerge => (); + refresh_profiles: () = "verge://refresh-profiles", FrontendEvent::RefreshProfiles => (); + refresh_proxy: () = "verge://refresh-proxy-config", FrontendEvent::RefreshProxyConfig => (); + notice: (NoticeStatus, std::string::String) = "verge://notice-message", FrontendEvent::NoticeMessage { status, ref message } => (status, message.to_string()); + profile_changed: std::string::String = "profile-changed", FrontendEvent::ProfileChanged { current_profile_id } => current_profile_id.to_string(); + timer_updated: std::string::String = "verge://timer-updated", FrontendEvent::TimerUpdated { profile_index } => profile_index.to_string(); + profile_started: ProfileUpdatePayload = "profile-update-started", FrontendEvent::ProfileUpdateStarted { uid } => ProfileUpdatePayload { uid: uid.to_string() }; + profile_completed: ProfileUpdatePayload = "profile-update-completed", FrontendEvent::ProfileUpdateCompleted { uid } => ProfileUpdatePayload { uid: uid.to_string() }; + run_state: crate::core::runstate::RunStateView = "verge://run-state-changed", FrontendEvent::RunStateChanged { state } => state; + pending_failures: () = "verge://pending-failures-changed", FrontendEvent::PendingFailuresChanged => (); +} + +#[derive(serde::Serialize, specta::Type)] +pub struct ProfileUpdatePayload { + pub uid: std::string::String, } diff --git a/src-tauri/src/core/proxy_view.rs b/src-tauri/src/core/proxy_view.rs index 9d9db585c..54d488fc0 100644 --- a/src-tauri/src/core/proxy_view.rs +++ b/src-tauri/src/core/proxy_view.rs @@ -1,6 +1,6 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; -use serde::Serialize; +use serde::{Serialize, Serializer, ser::SerializeMap as _}; use tauri_plugin_mihomo::models::{ DelayHistory, Extra, Proxies, Proxy, ProxyProvider, ProxyProviders, ProxyType, VehicleType, }; @@ -20,7 +20,7 @@ pub struct GroupScope { pub struct ProxyViewBuilder; -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProxyViewV1 { pub schema_version: u8, @@ -34,21 +34,21 @@ pub struct ProxyViewV1 { pub providers: Vec, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "lowercase")] pub enum ProxyViewOrderSource { Runtime, Fallback, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "lowercase")] pub enum ProxyViewProviderState { Ready, Unavailable, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] pub struct ProxyCapabilities { pub udp: bool, pub xudp: bool, @@ -57,13 +57,35 @@ pub struct ProxyCapabilities { pub smux: bool, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Serialize, specta::Type)] +struct ProxyLatencyHistory<'a> { + #[specta(type = Vec)] + history: &'a [DelayHistory], +} + +fn serialize_proxy_latency_history( + extra: &HashMap, + serializer: S, +) -> Result { + let mut map = serializer.serialize_map(Some(extra.len()))?; + for (url, extra) in extra { + map.serialize_entry( + url, + &ProxyLatencyHistory { + history: &extra.history, + }, + )?; + } + map.end() +} + +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProxyGroupView { pub name: String, #[serde(rename = "type")] + #[specta(type = String)] pub proxy_type: ProxyType, - pub alive: bool, #[serde(skip_serializing_if = "Option::is_none")] pub now: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -74,22 +96,28 @@ pub struct ProxyGroupView { pub icon: Option, #[serde(skip_serializing_if = "Option::is_none")] pub test_url: Option, + #[specta(type = Vec)] pub history: Vec, + #[serde(serialize_with = "serialize_proxy_latency_history")] + #[specta(type = HashMap>)] pub extra: HashMap, #[serde(flatten)] pub capabilities: ProxyCapabilities, pub members: Vec, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProxyNodeView { pub record_id: String, pub name: String, #[serde(rename = "type")] + #[specta(type = String)] pub proxy_type: ProxyType, - pub alive: bool, + #[specta(type = Vec)] pub history: Vec, + #[serde(serialize_with = "serialize_proxy_latency_history")] + #[specta(type = HashMap>)] pub extra: HashMap, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, @@ -104,7 +132,7 @@ pub struct ProxyNodeView { pub source: ProxyNodeSource, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum ProxyNodeSource { Core { @@ -119,7 +147,7 @@ pub enum ProxyNodeSource { }, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum ProxyMemberRef { Group { @@ -136,7 +164,7 @@ pub enum ProxyMemberRef { }, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)] pub enum ProxyMemberUnresolvedReason { #[serde(rename = "missing")] Missing, @@ -146,7 +174,7 @@ pub enum ProxyMemberUnresolvedReason { ProviderUnavailable, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct ProxyProviderView { pub name: String, @@ -158,7 +186,7 @@ pub struct ProxyProviderView { pub proxy_record_ids: Vec, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, specta::Type)] pub enum ProxyProviderVehicleType { #[serde(rename = "HTTP")] Http, @@ -168,7 +196,7 @@ pub enum ProxyProviderVehicleType { Inline, } -#[derive(Debug, PartialEq, Eq, Serialize)] +#[derive(Debug, PartialEq, Eq, Serialize, specta::Type)] pub struct ProxySubscriptionInfo { pub upload: i64, pub download: i64, @@ -402,7 +430,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro icon, now, test_url, - alive, history, extra, udp, @@ -431,7 +458,6 @@ fn build_group(name: String, proxy: Proxy, resolver: &MemberResolver<'_>) -> Pro ProxyGroupView { name, proxy_type, - alive, now, fixed, hidden, @@ -456,7 +482,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo hidden, icon, test_url, - alive, history, extra, udp, @@ -472,7 +497,6 @@ fn build_node(record_id: String, name: String, proxy: Proxy, source: ProxyNodeSo record_id, name, proxy_type, - alive, history, extra, id, diff --git a/src-tauri/src/core/runstate/health.rs b/src-tauri/src/core/runstate/health.rs index 6bb3505a0..1a9ff685e 100644 --- a/src-tauri/src/core/runstate/health.rs +++ b/src-tauri/src/core/runstate/health.rs @@ -14,13 +14,13 @@ pub enum ServiceHealth { } impl ServiceHealth { - const fn kind(&self) -> &'static str { + const fn kind(&self) -> ServiceHealthKind { match self { - Self::Unknown => "unknown", - Self::Ready => "ready", - Self::NotInstalled => "notInstalled", - Self::VersionMismatch => "versionMismatch", - Self::Unavailable(_) => "unavailable", + Self::Unknown => ServiceHealthKind::Unknown, + Self::Ready => ServiceHealthKind::Ready, + Self::NotInstalled => ServiceHealthKind::NotInstalled, + Self::VersionMismatch => ServiceHealthKind::VersionMismatch, + Self::Unavailable(_) => ServiceHealthKind::Unavailable, } } @@ -32,7 +32,7 @@ impl ServiceHealth { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub enum PendingAction { Install, @@ -118,11 +118,11 @@ impl RunState { } } -#[derive(Debug, Clone, serde::Serialize)] +#[derive(Debug, Clone, serde::Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct RunStateView { pub mode: RunningMode, - pub service: &'static str, + pub service: ServiceHealthKind, pub service_unavailable_reason: Option, pub pending_action: Option, pub sidecar_allowed: bool, @@ -232,7 +232,7 @@ mod tests { let view = run_state.to_view(); - assert_eq!(view.service, "unavailable"); + assert_eq!(view.service, ServiceHealthKind::Unavailable); assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused")); assert_eq!(view.pending_action, Some(PendingAction::Reinstall)); assert!(view.tun_capable, "elevation alone makes TUN possible"); @@ -244,7 +244,7 @@ mod tests { fn a_healthy_view_reports_no_unavailable_reason() { let view = state(ServiceHealth::Ready, false, false).to_view(); - assert_eq!(view.service, "ready"); + assert_eq!(view.service, ServiceHealthKind::Ready); assert_eq!(view.service_unavailable_reason, None); assert!(view.service_usable); assert!(!view.service_needs_attention); @@ -350,3 +350,13 @@ mod tests { assert!(settled.tun_should_be_disabled(true)); } } + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum ServiceHealthKind { + Unknown, + Ready, + NotInstalled, + VersionMismatch, + Unavailable, +} diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 4b0704300..7d9242087 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -44,7 +44,7 @@ static PENDING_SERVICE_REPAIR_NOTICE: AtomicBool = AtomicBool::new(false); static PENDING_SERVICE_OWNER_NOTICE: Mutex> = Mutex::new(None); /// Why the Service was unavailable when the core fell back to Sidecar. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, specta::Type)] #[serde(tag = "kind", content = "reason", rename_all = "camelCase")] pub enum ServiceFallbackNotice { Unavailable, diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 8348b67a0..221f23aa1 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1160,7 +1160,15 @@ fn on_menu_event(_: &AppHandle, event: MenuEvent) { logging!(error, Type::Tray, "Failed to close all connections from tray: {err}"); } } - MenuIds::COPY_ENV => feat::copy_clash_env().await, + MenuIds::COPY_ENV => { + if let Err(err) = feat::copy_clash_env().await { + logging!( + error, + Type::Tray, + "Failed to copy proxy environment variables from tray: {err:#}" + ); + } + } MenuIds::CONF_DIR => { let _ = cmd::open_app_dir().await; } diff --git a/src-tauri/src/core/updater.rs b/src-tauri/src/core/updater.rs index 0948a3801..c017582eb 100644 --- a/src-tauri/src/core/updater.rs +++ b/src-tauri/src/core/updater.rs @@ -34,7 +34,7 @@ impl SilentUpdater { } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, specta::Type)] struct UpdateCacheMeta { version: String, downloaded_at: String, @@ -214,7 +214,7 @@ fn nsis_language_id(app_language: &str) -> &'static str { } /// Same wire shape as the updater plugin's JS `DownloadEvent`. -#[derive(Clone, Serialize)] +#[derive(Clone, Serialize, specta::Type)] #[serde(tag = "event", content = "data")] pub enum DownloadEvent { #[serde(rename_all = "camelCase")] diff --git a/src-tauri/src/core/validate.rs b/src-tauri/src/core/validate.rs index 90517bb44..b244127d1 100644 --- a/src-tauri/src/core/validate.rs +++ b/src-tauri/src/core/validate.rs @@ -51,7 +51,7 @@ pub struct CoreConfigValidator { is_processing: AtomicBool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub enum ValidationErrorKind { FileMissing, @@ -94,7 +94,7 @@ impl ValidationErrorKind { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, specta::Type)] #[serde(rename_all = "camelCase")] pub enum ValidationSkipReason { Exiting, @@ -110,12 +110,18 @@ impl fmt::Display for ValidationSkipReason { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, specta::Type)] #[serde(tag = "status", rename_all = "camelCase")] pub enum ValidationOutcome { Valid, - Invalid { kind: ValidationErrorKind, message: String }, - Skipped { reason: ValidationSkipReason }, + Invalid { + kind: ValidationErrorKind, + #[specta(type = std::string::String)] + message: String, + }, + Skipped { + reason: ValidationSkipReason, + }, Busy, } diff --git a/src-tauri/src/feat/backup.rs b/src-tauri/src/feat/backup.rs index 32a6e1de6..7c246d36a 100644 --- a/src-tauri/src/feat/backup.rs +++ b/src-tauri/src/feat/backup.rs @@ -16,10 +16,13 @@ use smartstring::alias::String; use std::path::PathBuf; use tokio::fs; -#[derive(Debug, Serialize)] +#[derive(Debug, Serialize, specta::Type)] pub struct LocalBackupFile { + #[specta(type = std::string::String)] pub filename: String, + #[specta(type = std::string::String)] pub path: String, + #[specta(type = std::string::String)] pub last_modified: String, pub content_length: u64, } diff --git a/src-tauri/src/feat/core_upgrade.rs b/src-tauri/src/feat/core_upgrade.rs index 805dbdb59..dfb415aa0 100644 --- a/src-tauri/src/feat/core_upgrade.rs +++ b/src-tauri/src/feat/core_upgrade.rs @@ -34,12 +34,10 @@ static STAGING_GENERATION: AtomicU64 = AtomicU64::new(0); /// `.rollback` and `.old` are fixed paths, so two upgrades must not overlap. static UPGRADE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -#[derive(Debug, serde::Serialize)] +#[derive(Debug, serde::Serialize, specta::Type)] pub struct CoreUpgradeReport { /// False when the managed core was already at the latest version. pub upgraded: bool, - pub from: std::string::String, - pub to: std::string::String, } pub async fn upgrade_core(force: bool) -> Result { @@ -62,11 +60,7 @@ pub async fn upgrade_core(force: bool) -> Result { logging!(debug, Type::Core, "core upgrade: latest version resolved via {proxy:?}"); if !force && installed == latest { - return Ok(CoreUpgradeReport { - upgraded: false, - from: installed, - to: latest, - }); + return Ok(CoreUpgradeReport { upgraded: false }); } let package = download_package(proxy, alpha, &latest).await?; @@ -150,11 +144,7 @@ pub async fn upgrade_core(force: bool) -> Result { #[cfg(windows)] let _ = std::fs::remove_file(target.with_extension("old")); - Ok(CoreUpgradeReport { - upgraded: true, - from: installed, - to: latest, - }) + Ok(CoreUpgradeReport { upgraded: true }) } /// Whether this upgrade handed bytes to the Service's approved core directory. diff --git a/src-tauri/src/feat/dns.rs b/src-tauri/src/feat/dns.rs index 9092676e1..a897b1b59 100644 --- a/src-tauri/src/feat/dns.rs +++ b/src-tauri/src/feat/dns.rs @@ -15,11 +15,14 @@ use crate::{ utils::dirs, }; -#[derive(Serialize)] +#[derive(Serialize, specta::Type)] #[serde(tag = "status", rename_all = "snake_case")] pub enum DnsOverrideOutcome { Applied, - ConfirmationRequired { source: String }, + ConfirmationRequired { + #[specta(type = std::string::String)] + source: String, + }, } pub async fn set_dns_override( diff --git a/src-tauri/src/feat/icon.rs b/src-tauri/src/feat/icon.rs index a69577cee..2ac973002 100644 --- a/src-tauri/src/feat/icon.rs +++ b/src-tauri/src/feat/icon.rs @@ -8,10 +8,13 @@ use std::path::{Component, Path, PathBuf}; use tokio::fs; use tokio::io::AsyncWriteExt as _; -#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[derive(Debug, serde::Serialize, serde::Deserialize, specta::Type)] pub struct IconInfo { + #[specta(type = std::string::String)] name: String, + #[specta(type = std::string::String)] previous_t: String, + #[specta(type = std::string::String)] current_t: String, } diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index 49eb4ae9f..80cc2c667 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -11,6 +11,7 @@ use crate::{ window_manager::WindowManager, }, }; +use anyhow::Context as _; use clash_verge_logging::{Type, logging}; use std::env; use tauri_plugin_clipboard_manager::ClipboardExt as _; @@ -95,7 +96,7 @@ pub async fn toggle_tun_mode(not_save_file: Option) -> bool { } } -pub async fn copy_clash_env() { +pub async fn copy_clash_env() -> anyhow::Result<()> { let env_ip = env::var("CLASH_VERGE_REV_IP").ok(); let verge_cfg = Config::verge().await.latest_arc(); let ip = env_ip @@ -133,12 +134,11 @@ pub async fn copy_clash_env() { } "fish" => format!("set -x http_proxy {http_proxy}; set -x https_proxy {http_proxy}"), _ => { - logging!(error, Type::ProxyMode, "copy_clash_env: Invalid env type! {env_type}"); - return; + anyhow::bail!("Invalid environment variable format: {env_type}"); } }; - if let Err(err) = clipboard.write_text(&export_text) { - logging!(error, Type::ProxyMode, "Failed to write to clipboard: {err}"); - } + clipboard + .write_text(export_text) + .context("Failed to write proxy environment variables to clipboard") } diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs new file mode 100644 index 000000000..334ef1ccb --- /dev/null +++ b/src-tauri/src/ipc.rs @@ -0,0 +1,234 @@ +use crate::cmd; + +pub fn builder() -> tauri_specta::Builder { + tauri_specta::Builder::new() + .types(&platform_types()) + .commands(tauri_specta::collect_commands![ + tauri_plugin_clash_verge_sysinfo::commands::get_system_info, + tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime, + tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info::, + cmd::probe_listener, + cmd::save_proxy_ports, + cmd::get_sys_proxy, + cmd::get_auto_proxy, + cmd::get_embedded_server_port, + cmd::open_app_dir, + cmd::open_logs_dir, + cmd::open_core_dir, + cmd::get_network_interfaces, + cmd::get_system_hostname, + cmd::restart_app, + cmd::install_update, + cmd::cancel_update_download, + cmd::restart_core, + cmd::upgrade_clash_core, + cmd::get_runtime_state, + cmd::get_pending_failures, + cmd::get_sidecar_failure, + cmd::entry_lightweight_mode, + cmd::install_service, + cmd::uninstall_service, + cmd::reinstall_service, + cmd::repair_service, + cmd::continue_with_sidecar, + cmd::sync_runtime_providers, + cmd::get_clash_info, + cmd::patch_clash_config, + cmd::patch_clash_mode, + cmd::get_clash_mode, + cmd::change_clash_core, + cmd::get_runtime_config, + cmd::get_proxy_view, + cmd::get_runtime_yaml, + cmd::get_runtime_logs, + cmd::get_runtime_proxy_chain_config, + cmd::update_proxy_chain_config_in_runtime, + cmd::invoke_uwp_tool, + cmd::copy_clash_env, + cmd::sync_tray_proxy_selection, + cmd::record_selected_node, + cmd::forget_selected_node, + cmd::save_dns_config, + cmd::apply_dns_config, + cmd::set_dns_override, + cmd::take_dns_override_notice, + cmd::take_service_fallback_notice, + cmd::get_core_startup_error, + cmd::take_service_repair_notice, + cmd::take_service_owner_notice, + cmd::take_discarded_keys_notice, + cmd::get_dns_config_content, + cmd::validate_dns_config, + cmd::get_clash_logs, + cmd::get_verge_config, + cmd::patch_verge_config, + cmd::test_delay, + cmd::get_app_dir, + cmd::copy_icon_file, + cmd::download_icon_cache, + cmd::open_devtools, + cmd::exit_app, + cmd::get_network_interfaces_info, + cmd::get_profiles, + cmd::enhance_profiles, + cmd::patch_profiles_config, + cmd::view_profile, + cmd::patch_profile, + cmd::create_profile, + cmd::import_profile, + cmd::reorder_profile, + cmd::update_profile, + cmd::delete_profile, + cmd::read_profile_file, + cmd::save_profile_file, + cmd::get_next_update_time, + cmd::create_local_backup, + cmd::list_local_backup, + cmd::delete_local_backup, + cmd::restore_local_backup, + cmd::import_local_backup, + cmd::export_local_backup, + cmd::create_webdav_backup, + cmd::save_webdav_config, + cmd::list_webdav_backup, + cmd::delete_webdav_backup, + cmd::restore_webdav_backup, + cmd::get_unlock_items, + cmd::check_media_unlock, + cmd::check_media_unlock_item, + ]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw) + .dangerously_cast_bigints_to_number() + .typ::() +} + +// Tauri transports YAML values as JSON; keep YAML storage out of the IPC type graph. +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(transparent)] +pub struct YamlMapping(#[specta(type = std::collections::HashMap)] pub serde_yaml_ng::Mapping); + +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(transparent)] +pub struct YamlValue(#[specta(type = JsonValue)] pub serde_yaml_ng::Value); + +#[derive(serde::Serialize, specta::Type)] +pub struct WebDavFile { + pub href: String, + #[specta(type = String)] + pub last_modified: chrono::DateTime, +} + +impl From for WebDavFile { + fn from(file: reqwest_dav::list_cmd::ListFile) -> Self { + Self { + href: file.href, + last_modified: file.last_modified, + } + } +} + +#[derive(serde::Serialize, specta::Type)] +pub struct NetworkInterfaceView { + pub name: String, + pub addr: Vec, + pub mac_addr: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +pub enum InterfaceAddress { + V4(IpAddress), + V6(IpAddress), +} + +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +pub struct IpAddress { + pub ip: String, +} + +impl From for NetworkInterfaceView { + fn from(interface: network_interface::NetworkInterface) -> Self { + Self { + name: interface.name, + addr: interface + .addr + .into_iter() + .map(|address| match address { + network_interface::Addr::V4(address) => InterfaceAddress::V4(IpAddress { + ip: address.ip.to_string(), + }), + network_interface::Addr::V6(address) => InterfaceAddress::V6(IpAddress { + ip: address.ip.to_string(), + }), + }) + .collect(), + mac_addr: interface.mac_addr, + } + } +} + +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +pub struct DelayHistory { + pub time: String, + pub delay: u16, +} + +fn platform_types() -> specta::Types { + use specta::datatype::{DataType, Fields}; + let mut types = specta::Types::default().register::(); + ::definition(&mut types); + let platform = types + .into_unsorted_iter() + .find(|ty| ty.name == "PlatformVergeFields") + .and_then(|ty| ty.ty.clone()); + let Some(DataType::Struct(platform)) = platform else { + unreachable!() + }; + let Fields::Named(platform) = platform.fields else { + unreachable!() + }; + types.iter_mut(|ty| { + if ty.name != "IVerge" { + return; + } + let Some(DataType::Struct(config)) = &mut ty.ty else { + return; + }; + let Fields::Named(fields) = &mut config.fields else { + return; + }; + fields + .fields + .retain(|(name, _)| !platform.fields.iter().any(|(platform_name, _)| name == platform_name)); + fields.fields.extend(platform.fields.iter().cloned()); + }); + types +} + +// These fields exist only on some hosts; IPC generation covers every desktop target. +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +struct PlatformVergeFields { + #[specta(optional)] + tray_icon: Option, + #[specta(optional)] + verge_redir_port: Option, + #[specta(optional)] + verge_redir_enabled: Option, + #[specta(optional)] + verge_tproxy_port: Option, + #[specta(optional)] + verge_tproxy_enabled: Option, + #[specta(optional)] + enable_tray_speed: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(untagged)] +pub enum JsonValue { + Null(()), + Bool(bool), + Number(f64), + String(String), + // Specta resolves fields in nested functions, where Self is unavailable. + Array(#[specta(type = Vec)] Vec), + Object(#[specta(type = std::collections::HashMap)] std::collections::HashMap), +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1df5b4582..f2d61f427 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod constants; mod core; mod enhance; mod feat; +pub mod ipc; mod process; pub mod utils; @@ -26,9 +27,6 @@ use tauri_plugin_deep_link::DeepLinkExt as _; pub static APP_HANDLE: OnceLock = OnceLock::new(); -// Re-exported for src/bin/frontend-contract.rs; the wire contract stays owned -// by core::notify. -pub use crate::core::notify::frontend_wire_contract; /// Application initialization helper functions mod app_init { use super::*; @@ -127,100 +125,10 @@ mod app_init { } pub fn generate_handlers() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { - tauri::generate_handler![ - tauri_plugin_clash_verge_sysinfo::commands::get_system_info, - tauri_plugin_clash_verge_sysinfo::commands::get_app_uptime, - tauri_plugin_clash_verge_sysinfo::commands::export_diagnostic_info, - cmd::probe_listener, - cmd::save_proxy_ports, - cmd::get_sys_proxy, - cmd::get_auto_proxy, - cmd::get_embedded_server_port, - cmd::open_app_dir, - cmd::open_logs_dir, - cmd::open_core_dir, - cmd::get_network_interfaces, - cmd::get_system_hostname, - cmd::restart_app, - cmd::install_update, - cmd::cancel_update_download, - cmd::restart_core, - cmd::upgrade_clash_core, - cmd::get_runtime_state, - cmd::get_pending_failures, - cmd::get_sidecar_failure, - cmd::entry_lightweight_mode, - cmd::install_service, - cmd::uninstall_service, - cmd::reinstall_service, - cmd::repair_service, - cmd::continue_with_sidecar, - cmd::sync_runtime_providers, - cmd::get_clash_info, - cmd::patch_clash_config, - cmd::patch_clash_mode, - cmd::get_clash_mode, - cmd::change_clash_core, - cmd::get_runtime_config, - cmd::get_proxy_view, - cmd::get_runtime_yaml, - cmd::get_runtime_logs, - cmd::get_runtime_proxy_chain_config, - cmd::update_proxy_chain_config_in_runtime, - cmd::invoke_uwp_tool, - cmd::copy_clash_env, - cmd::sync_tray_proxy_selection, - cmd::record_selected_node, - cmd::forget_selected_node, - cmd::save_dns_config, - cmd::apply_dns_config, - cmd::set_dns_override, - cmd::take_dns_override_notice, - cmd::take_service_fallback_notice, - cmd::get_core_startup_error, - cmd::take_service_repair_notice, - cmd::take_service_owner_notice, - cmd::take_discarded_keys_notice, - cmd::get_dns_config_content, - cmd::validate_dns_config, - cmd::get_clash_logs, - cmd::get_verge_config, - cmd::patch_verge_config, - cmd::test_delay, - cmd::get_app_dir, - cmd::copy_icon_file, - cmd::download_icon_cache, - cmd::open_devtools, - cmd::exit_app, - cmd::get_network_interfaces_info, - cmd::get_profiles, - cmd::enhance_profiles, - cmd::patch_profiles_config, - cmd::view_profile, - cmd::patch_profile, - cmd::create_profile, - cmd::import_profile, - cmd::reorder_profile, - cmd::update_profile, - cmd::delete_profile, - cmd::read_profile_file, - cmd::save_profile_file, - cmd::get_next_update_time, - cmd::create_local_backup, - cmd::list_local_backup, - cmd::delete_local_backup, - cmd::restore_local_backup, - cmd::import_local_backup, - cmd::export_local_backup, - cmd::create_webdav_backup, - cmd::save_webdav_config, - cmd::list_webdav_backup, - cmd::delete_webdav_backup, - cmd::restore_webdav_backup, - cmd::get_unlock_items, - cmd::check_media_unlock, - cmd::check_media_unlock_item, - ] + { + let builder = crate::ipc::builder(); + move |invoke| builder.invoke_handler()(invoke) + } } } diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index 5e1ec2cdf..c7145b05f 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -423,7 +423,7 @@ interface PersistentProxySelectProps value: string selectedName: string groupName: string - fixed?: string + fixed?: string | null selectedDelay: number options: ProxyOption[] onChange: (value: string) => void @@ -785,8 +785,8 @@ export const CurrentProxyCard = () => { // The profile selection is the durable source across core restarts and run modes. handleSelectChange( selectedGroup.name, - previousProxy, - selectedGroup.fixed, + previousProxy!, + selectedGroup.fixed!, )({ target: { value: nextName }, }) @@ -940,7 +940,7 @@ export const CurrentProxyCard = () => { unsortedProxyOptions, sortType, delays.of, - defaultLatencyTimeout, + defaultLatencyTimeout!, ), [ delays, diff --git a/src/components/home/home-profile-card.tsx b/src/components/home/home-profile-card.tsx index ad9b4bb82..b41b7fef9 100644 --- a/src/components/home/home-profile-card.tsx +++ b/src/components/home/home-profile-card.tsx @@ -56,26 +56,7 @@ const openProfileHome = (url: string) => { void openExternalUrl(url).catch(showNotice.error) } -// 使用类型定义,而不是导入 -interface ProfileExtra { - upload: number - download: number - total: number - expire: number -} - -interface ProfileItem { - uid: string - type?: 'local' | 'remote' | 'merge' | 'script' - name?: string - desc?: string - file?: string - url?: string - updated?: number - extra?: ProfileExtra - home?: string - option?: any -} +type ProfileItem = IProfileItem interface HomeProfileCardProps { current: ProfileItem | null | undefined @@ -298,10 +279,13 @@ export const HomeProfileCard = ({ setUpdating(true) try { - await mutate(() => updateProfile(current.uid, current.option), { - id: `update-profile:${current.uid}`, - errorNotice: false, - }) + await mutate( + () => updateProfile(current.uid!, current.option ?? undefined), + { + id: `update-profile:${current.uid}`, + errorNotice: false, + }, + ) onProfileUpdated?.() // 刷新首页数据 @@ -345,7 +329,7 @@ export const HomeProfileCard = ({ flex: 1, }, }} - title={current.name} + title={current.name!} > {current.name} { onClick={() => handleTabChange('tun')} icon={TroubleshootRounded} label={t('settings.sections.system.toggles.tunMode')} - hasIndicator={enable_tun_mode && isTunModeAvailable} + hasIndicator={enable_tun_mode! && isTunModeAvailable} /> diff --git a/src/components/home/test-card.tsx b/src/components/home/test-card.tsx index 5081c477a..5371f995e 100644 --- a/src/components/home/test-card.tsx +++ b/src/components/home/test-card.tsx @@ -164,7 +164,7 @@ export const TestCard = () => { }} > {testList.map((item, index) => ( - + viewerRef.current?.edit(item)} diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index ed3c430ca..bc001d157 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -120,7 +120,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { ) const setLoading = useCallback( (loading: boolean) => { - setProfileLoading([itemData.uid], loading) + setProfileLoading([itemData.uid!], loading) }, [itemData.uid, setProfileLoading], ) @@ -140,7 +140,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { debugLog(`强制刷新定时器任务`) } - const nextUpdate = await getNextUpdateTime(itemData.uid) + const nextUpdate = await getNextUpdateTime(itemData.uid!) debugLog(`获取到下次更新时间结果:`, nextUpdate) if (nextUpdate) { @@ -245,7 +245,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { const hasHome = !!itemData.home // only subscription url has home page const { upload = 0, download = 0, total = 0 } = extra ?? {} - const from = parseUrl(itemData.url) + const from = parseUrl(itemData.url!) const description = itemData.desc const expire = parseExpire(extra?.expire) const progress = Math.min( @@ -253,7 +253,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { 100, ) - const loading = loadingCache.has(itemData.uid) + const loading = loadingCache.has(itemData.uid!) const [, forceRefresh] = useReducer((value: number) => value + 1, 0) useEffect(() => { @@ -263,7 +263,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { const handler = () => { const now = Date.now() - const lastUpdate = updated * 1000 + const lastUpdate = updated! * 1000 if (now - lastUpdate >= 24 * 36e5) return const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4 @@ -293,7 +293,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { const [confirmOpen, setConfirmOpen] = useState(false) const [qrOpen, setQrOpen] = useState(false) - const loadProfileDocument = useCallback(() => readProfileFile(uid), [uid]) + const loadProfileDocument = useCallback(() => readProfileFile(uid!), [uid]) const loadMergeDocument = useCallback( () => readProfileFile(option?.merge ?? ''), [option?.merge], @@ -370,7 +370,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { const onOpenFile = useLockFn(async () => { setAnchorEl(null) try { - await mutate(() => viewProfile(itemData.uid), { + await mutate(() => viewProfile(itemData.uid!), { id: 'view-profile', errorNotice: false, }) @@ -402,7 +402,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { try { const payload = Object.keys(option).length > 0 ? option : undefined - await mutate(() => updateProfile(itemData.uid, payload), { + await mutate(() => updateProfile(itemData.uid!, payload), { id: `update-profile:${itemData.uid}`, errorNotice: false, }) @@ -593,7 +593,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { const handleSaveProfileDocument = useLockFn(async () => { const currentValue = profileDocument.value - const result = await mutate(() => saveProfileFile(uid, currentValue), { + const result = await mutate(() => saveProfileFile(uid!, currentValue), { id: `save-profile-file:${uid}`, errorNotice: false, }) @@ -728,7 +728,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { variant="h6" component="h2" noWrap - title={name} + title={name!} > {name} @@ -794,7 +794,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { title={ showNextUpdate ? t('profiles.components.profileItem.tooltips.showLast') - : `${t('shared.labels.updateTime')}: ${parseExpire(updated)}\n${t('profiles.components.profileItem.tooltips.showNext')}` + : `${t('shared.labels.updateTime')}: ${parseExpire(updated!)}\n${t('profiles.components.profileItem.tooltips.showNext')}` } sx={{ fontSize: 14, @@ -812,8 +812,8 @@ const ProfileItemBase = (props: ProfileItemProps) => { > {showNextUpdate ? nextUpdateTime - : updated > 0 - ? dayjs(updated * 1000).fromNow() + : updated! > 0 + ? dayjs(updated! * 1000).fromNow() : ''} @@ -831,7 +831,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { ) : ( - {parseExpire(updated)} + {parseExpire(updated!)} )} @@ -896,7 +896,7 @@ const ProfileItemBase = (props: ProfileItemProps) => { { )} {proxiesOpen && ( { { {qrOpen && itemData.url && ( setQrOpen(false)} /> )} diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index e40b9f4d5..6846c0763 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -115,7 +115,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { } const name = form.name || `${form.type} file` - const item = { ...form, name, option } + const item = { + type: form.type, + name, + desc: form.desc, + option, + ...(form.type === 'remote' ? { url: form.url } : {}), + } const isRemote = form.type === 'remote' const isUpdate = openType === 'edit' @@ -139,7 +145,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { t('profiles.modals.profileForm.errors.uidMissing'), ) } - await mutate(() => patchProfile(form.uid, item), { + await mutate(() => patchProfile(form.uid!, item), { id: 'patch-profile', errorNotice: false, }) @@ -157,7 +163,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { t('profiles.modals.profileForm.errors.uidMissing'), ) } - await mutate(() => patchProfile(form.uid, item), { + await mutate(() => patchProfile(form.uid!, item), { id: 'patch-profile', errorNotice: false, }) @@ -190,13 +196,13 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { t('profiles.modals.profileForm.errors.uidMissing'), ) } - await mutate(() => patchProfile(form.uid, retryItem), { + await mutate(() => patchProfile(form.uid!, retryItem), { id: 'patch-profile', errorNotice: false, }) await mutate( - () => patchProfile(form.uid, { option: originalOptions }), + () => patchProfile(form.uid!, { option: originalOptions }), { id: 'patch-profile', errorNotice: false, @@ -413,7 +419,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {t('profiles.modals.profileForm.fields.useSystemProxy')} - + )} /> @@ -426,7 +432,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {t('profiles.modals.profileForm.fields.useClashProxy')} - + )} /> @@ -439,7 +445,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { {t('profiles.modals.profileForm.fields.acceptInvalidCerts')} - + )} /> diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 69e718486..439b23e2e 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -445,10 +445,14 @@ export const ProxyChain = ({ if (isConnected) { setIsConnecting(true) try { - await mutate(() => updateProxyChainConfigInRuntime(null), { - id: 'update-proxy-chain-runtime', - errorNotice: false, - }) + const result = await mutate( + () => updateProxyChainConfigInRuntime(null), + { + id: 'update-proxy-chain-runtime', + errorNotice: false, + }, + ) + if (!result.ok) return const targetGroup = mode === 'global' @@ -461,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 { @@ -473,7 +477,7 @@ export const ProxyChain = ({ errorNotice: false, }, ) - recordSelection(targetGroup, currentProxyChain[0].name) + await recordSelection(targetGroup, currentProxyChain[0].name) } catch { // ignore } @@ -519,10 +523,14 @@ export const ProxyChain = ({ // 第一步:保存链式代理配置 const chainProxies = currentProxyChain.map((node) => node.name) debugLog('Saving chain config:', chainProxies) - await mutate(() => updateProxyChainConfigInRuntime(chainProxies), { - id: 'update-proxy-chain-runtime', - errorNotice: false, - }) + const result = await mutate( + () => updateProxyChainConfigInRuntime(chainProxies), + { + id: 'update-proxy-chain-runtime', + errorNotice: false, + }, + ) + if (!result.ok) return debugLog('Chain configuration saved successfully') // 第二步:连接到代理链的最后一个节点 @@ -545,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) @@ -619,14 +627,15 @@ export const ProxyChain = ({ { - mutate(() => updateProxyChainConfigInRuntime(null), { + void mutate(() => updateProxyChainConfigInRuntime(null), { id: 'update-proxy-chain-runtime', - errorNotice: false, - }) - localStorage.removeItem('proxy-chain-group') - localStorage.removeItem('proxy-chain-exit-node') - localStorage.removeItem('proxy-chain-items') - onUpdateChain([]) + onFulfilled: () => { + localStorage.removeItem('proxy-chain-group') + localStorage.removeItem('proxy-chain-exit-node') + localStorage.removeItem('proxy-chain-items') + onUpdateChain([]) + }, + }).catch(() => {}) }} sx={{ color: theme.palette.error.main, diff --git a/src/components/proxy/proxy-empty-state-model.ts b/src/components/proxy/proxy-empty-state-model.ts index a3f731dd1..17c6817db 100644 --- a/src/components/proxy/proxy-empty-state-model.ts +++ b/src/components/proxy/proxy-empty-state-model.ts @@ -5,11 +5,11 @@ export type ProxyEmptyStateReason = | 'no-proxy-info' type ProfileSummary = { - current?: string + current?: string | null items?: Array<{ - uid?: string - type?: string - }> + uid?: string | null + type?: string | null + }> | null } interface ProxyListStateInput { @@ -20,7 +20,7 @@ interface ProxyListStateInput { isRunningModePending: boolean } -const isSubscriptionProfile = (item: { type?: string }): boolean => +const isSubscriptionProfile = (item: { type?: string | null }): boolean => item.type === 'remote' || item.type === 'local' /** Decides only pre-render states; the built list owns whether it has renderable content. */ diff --git a/src/components/proxy/proxy-groups-chain.tsx b/src/components/proxy/proxy-groups-chain.tsx index a52435091..ab4b61d88 100644 --- a/src/components/proxy/proxy-groups-chain.tsx +++ b/src/components/proxy/proxy-groups-chain.tsx @@ -400,20 +400,26 @@ export function ProxyGroupsChain(props: ProxyGroupsChainProps) { setRuleMenuAnchor(null) } - const handleGroupSelect = (groupName: string) => { - onGroupSelect(groupName) - handleGroupMenuClose() - + const handleGroupSelect = async (groupName: string) => { if (mode === 'rule') { - mutate(() => updateProxyChainConfigInRuntime(null), { - id: 'update-proxy-chain-runtime', - errorNotice: false, - }) + try { + const result = await mutate( + () => updateProxyChainConfigInRuntime(null), + { + id: 'update-proxy-chain-runtime', + }, + ) + if (!result.ok) return + } catch { + return + } localStorage.removeItem('proxy-chain-group') localStorage.removeItem('proxy-chain-exit-node') localStorage.removeItem('proxy-chain-items') setProxyChain([]) } + onGroupSelect(groupName) + handleGroupMenuClose() } const handleCloseDuplicateWarning = useCallback(() => { diff --git a/src/components/proxy/proxy-render.tsx b/src/components/proxy/proxy-render.tsx index f30479127..5881295a3 100644 --- a/src/components/proxy/proxy-render.tsx +++ b/src/components/proxy/proxy-render.tsx @@ -198,7 +198,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) { {toolsOnLeft && proxyCount} onLocation(group)} @@ -247,7 +247,7 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) { return ( onLocation(group)} diff --git a/src/components/proxy/use-filter-sort.test.ts b/src/components/proxy/use-filter-sort.test.ts index 08730cd5e..dd5effc33 100644 --- a/src/components/proxy/use-filter-sort.test.ts +++ b/src/components/proxy/use-filter-sort.test.ts @@ -17,7 +17,6 @@ const node = (memberIndex: number, delay: number, name = `${memberIndex}`) => history: [{ delay }], extra: { 'http://cp.cloudflare.com/generate_204': { - alive: true, history: [{ time: new Date(0).toISOString(), delay }], }, }, diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index a47f44c92..826331bde 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -110,13 +110,13 @@ const CHAIN_DELAY_GROUP = 'chain-mode' const virtualGroup = (members: ProxyGroupView['members']): ProxyGroupView => ({ name: CHAIN_DELAY_GROUP, type: 'Selector', - alive: true, udp: false, xudp: false, tfo: false, mptcp: false, smux: false, history: [], + extra: {}, members, }) @@ -237,7 +237,7 @@ export const useRenderList = ( selected?.name ?? CHAIN_DELAY_GROUP, '', 0, - latencyTimeout, + latencyTimeout!, ) if (col > 1) { return groupOccurrences(occurrences, col).map((memberCol) => ({ @@ -289,8 +289,8 @@ export const useRenderList = ( key: group.name, group, headState, - icon: group.icon, - testUrl: group.testUrl, + icon: group.icon!, + testUrl: group.testUrl!, }, ] @@ -300,7 +300,7 @@ export const useRenderList = ( group.name, headState.filterText, headState.sortType, - latencyTimeout, + latencyTimeout!, { matchCase: headState.filterMatchCase, matchWholeWord: headState.filterMatchWholeWord, @@ -337,11 +337,11 @@ export const useRenderList = ( } cache.set(group.name, { - now: group.now, + now: group.now!, members: group.members, headState, col, - latencyTimeout, + latencyTimeout: latencyTimeout!, delays: groupDelays.get(group.name), items: ret, }) diff --git a/src/components/setting/mods/backup-config-viewer.tsx b/src/components/setting/mods/backup-config-viewer.tsx index 0dbce6954..dd4972628 100644 --- a/src/components/setting/mods/backup-config-viewer.tsx +++ b/src/components/setting/mods/backup-config-viewer.tsx @@ -50,9 +50,9 @@ export const BackupConfigViewer = memo( const { register, handleSubmit, watch } = useForm({ defaultValues: { - url: webdav_url, - username: webdav_username, - password: webdav_password, + url: webdav_url!, + username: webdav_username!, + password: webdav_password!, }, }) const url = watch('url') diff --git a/src/components/setting/mods/dns-viewer.tsx b/src/components/setting/mods/dns-viewer.tsx index 15493da24..1ab82ba7f 100644 --- a/src/components/setting/mods/dns-viewer.tsx +++ b/src/components/setting/mods/dns-viewer.tsx @@ -196,7 +196,7 @@ export function DnsViewer({ ref }: { ref?: Ref }) { const { verge } = useVerge() const { current: currentProfile } = useProfiles() const dnsEnabled = currentProfile - ? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ?? + ? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ?? verge?.enable_dns_settings ?? false) : false diff --git a/src/components/setting/mods/sysproxy-viewer.tsx b/src/components/setting/mods/sysproxy-viewer.tsx index a4080b774..09f5472c4 100644 --- a/src/components/setting/mods/sysproxy-viewer.tsx +++ b/src/components/setting/mods/sysproxy-viewer.tsx @@ -438,7 +438,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { setValue((v) => ({ ...v, pac: e }))} /> @@ -455,7 +455,7 @@ export const SysproxyViewer = forwardRef((props, ref) => { setValue((v) => ({ ...v, guard: e }))} sx={{ marginLeft: 'auto' }} /> diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index 6704f93a9..d179f737f 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -1,5 +1,4 @@ import { alpha, Box, Button, LinearProgress } from '@mui/material' -import type { DownloadEvent } from '@tauri-apps/plugin-updater' import { useLockFn } from 'ahooks' import type { Ref } from 'react' import { @@ -15,6 +14,7 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown' import { BaseDialog, DialogRef } from '@/components/base' import { useUpdate } from '@/hooks/use-update' +import type { DownloadEvent } from '@/services/bindings' import { cancelUpdateDownload, installUpdate, diff --git a/src/components/setting/setting-clash.tsx b/src/components/setting/setting-clash.tsx index c4ab0f458..048aa97da 100644 --- a/src/components/setting/setting-clash.tsx +++ b/src/components/setting/setting-clash.tsx @@ -40,7 +40,7 @@ const SettingClash = ({ onError }: Props) => { const { verge, mutateVerge } = useVerge() const { current: currentProfile } = useProfiles() const dnsEnabled = currentProfile - ? (verge?.profile_dns_settings?.[currentProfile.uid]?.enabled ?? + ? (verge?.profile_dns_settings?.[currentProfile.uid!]?.enabled ?? verge?.enable_dns_settings ?? false) : false diff --git a/src/components/setting/setting-verge-basic.tsx b/src/components/setting/setting-verge-basic.tsx index 913e9859e..0dba7f952 100644 --- a/src/components/setting/setting-verge-basic.tsx +++ b/src/components/setting/setting-verge-basic.tsx @@ -9,6 +9,7 @@ import { useVerge } from '@/hooks/use-verge' import { navigationItems } from '@/pages/_navigation-meta' import { copyClashEnv } from '@/services/cmds' import { supportedLanguages } from '@/services/i18n' +import { mutate } from '@/services/mutate' import { showNotice } from '@/services/notice-service' import getSystem from '@/utils/get-system' @@ -75,8 +76,18 @@ const SettingVergeBasic = ({ onError }: Props) => { } const onCopyClashEnv = useCallback(async () => { - await copyClashEnv() - showNotice.success('shared.feedback.notifications.common.copySuccess', 1000) + try { + await mutate(copyClashEnv, { + id: 'copy-clash-env', + onFulfilled: () => + showNotice.success( + 'shared.feedback.notifications.common.copySuccess', + 1000, + ), + }) + } catch { + // The shared mutation boundary reports the failure. + } }, []) return ( diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx index 27e11dcbf..94760d0ad 100644 --- a/src/components/test/test-item.tsx +++ b/src/components/test/test-item.tsx @@ -25,11 +25,11 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => { const [position, setPosition] = useState({ left: 0, top: 0 }) const [delay, setDelay] = useState(-1) const { uid, name, icon, url } = itemData - const iconCachePath = useIconCache({ icon, cacheKey: uid }) + const iconCachePath = useIconCache({ icon: icon!, cacheKey: uid! }) const onDelay = useCallback(async () => { setDelay(-2) - const result = await cmdTestDelay(url) + const result = await cmdTestDelay(url!) setDelay(result) }, [url]) @@ -41,7 +41,7 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => { const onDelete = useLockFn(async () => { setAnchorEl(null) try { - removeTest(uid) + removeTest(uid!) } catch (err: any) { showNotice.error(err) } @@ -81,17 +81,17 @@ export const TestItem = ({ itemData, onEdit, onDelete: removeTest }: Props) => { {icon.trim().startsWith('http') && ( {name} )} {icon.trim().startsWith('data') && ( - {name} + {name!} )} {icon.trim().startsWith(' diff --git a/src/hooks/use-profiles.ts b/src/hooks/use-profiles.ts index f4416bd76..2ebbd3c2a 100644 --- a/src/hooks/use-profiles.ts +++ b/src/hooks/use-profiles.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from 'react' +import type { ProfilePatch, ProfileSelection } from '@/services/bindings' import { getProfiles, patchProfile, patchProfilesConfig } from '@/services/cmds' import { mutate } from '@/services/mutate' import { migrateProfileTestUrls } from '@/services/proxy-test-url' @@ -46,7 +47,7 @@ export const useProfiles = () => { }, []) const patchProfiles = useCallback( - async (value: Partial) => { + async (value: ProfileSelection) => { try { const result = await mutate(() => patchProfilesConfig(value), { id: 'patch-profiles-config', @@ -77,7 +78,7 @@ export const useProfiles = () => { ) const patchCurrent = useCallback( - async (value: Partial) => { + async (value: ProfilePatch) => { if (profiles?.current) { const uid = profiles.current await mutate(() => patchProfile(uid, value), { diff --git a/src/hooks/use-proxy-selection.ts b/src/hooks/use-proxy-selection.ts index e3a1d40e6..3b70daa82 100644 --- a/src/hooks/use-proxy-selection.ts +++ b/src/hooks/use-proxy-selection.ts @@ -166,10 +166,10 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => { const handleProxyGroupChange = useCallback( ( - group: { name: string; now?: string; fixed?: string }, + group: { name: string; now?: string | null; fixed?: string | null }, proxy: { name: string }, ) => { - changeProxy(group.name, proxy.name, group.now, group.fixed) + changeProxy(group.name, proxy.name, group.now!, group.fixed!) }, [changeProxy], ) diff --git a/src/hooks/use-system-proxy-state.ts b/src/hooks/use-system-proxy-state.ts index f16d1c106..974b0d63a 100644 --- a/src/hooks/use-system-proxy-state.ts +++ b/src/hooks/use-system-proxy-state.ts @@ -5,7 +5,7 @@ import { useDisplayedMixedPort } from '@/hooks/use-displayed-mixed-port' import { useVerge } from '@/hooks/use-verge' import { useSystemData } from '@/providers/app-data-context' import { - getAutotemProxy, + getAutoProxy, getEmbeddedServerPort, patchVergeConfig, } from '@/services/cmds' @@ -21,8 +21,8 @@ export const useSystemProxyState = () => { const { sysproxy } = useSystemData() const displayedMixedPort = useDisplayedMixedPort() const { data: autoproxy } = useQuery({ - queryKey: ['getAutotemProxy'], - queryFn: getAutotemProxy, + queryKey: ['getAutoProxy'], + queryFn: getAutoProxy, refetchOnWindowFocus: true, refetchOnReconnect: true, }) @@ -99,7 +99,7 @@ export const useSystemProxyState = () => { } // Kept separate so an unreadable config cannot discard OS state that did read. try { - await revalidateQueries([['getSystemProxy'], ['getAutotemProxy']]) + await revalidateQueries([['getSystemProxy'], ['getAutoProxy']]) } catch (error) { console.warn( '[system-proxy] rereading the OS proxy after a toggle failed:', @@ -109,14 +109,14 @@ export const useSystemProxyState = () => { // rather than stay live from a stale cache. await Promise.all([ removeCacheData(['getSystemProxy']), - removeCacheData(['getAutotemProxy']), + removeCacheData(['getAutoProxy']), ]) } } } const invalidateProxyState = () => - revalidateQueries([['getSystemProxy'], ['getAutotemProxy']]) + revalidateQueries([['getSystemProxy'], ['getAutoProxy']]) return { indicator, diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index e7b243028..efb7ef99f 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -117,7 +117,7 @@ const ProfilePage = () => { } = useProfiles() useEffect(() => { - currentProfileRef.current = profiles.current + currentProfileRef.current = profiles.current! }, [profiles]) useEffect(() => { @@ -263,7 +263,7 @@ const ProfilePage = () => { } // `useProfiles` already retries three times; add only one business-level retry. - const performRobustRefresh = async (prevCurrent?: string) => { + const performRobustRefresh = async (prevCurrent?: string | null) => { let retryCount = 0 const maxRetries = 1 const baseDelay = 200 @@ -580,7 +580,7 @@ const ProfilePage = () => { const onUpdateAll = useLockFn(async () => { const items = profileItems.filter((e) => e.type === 'remote') const target = items - .map((item) => item.uid) + .map((item) => item.uid!) .filter((uid) => !loadingCache.has(uid)) setLoadingProfiles(target, true) @@ -612,7 +612,7 @@ const ProfilePage = () => { } const selectAllProfiles = () => { - setSelectedProfiles(new Set(profileItems.map((item) => item.uid))) + setSelectedProfiles(new Set(profileItems.map((item) => item.uid!))) } const clearAllSelections = () => { @@ -904,20 +904,20 @@ const ProfilePage = () => { {profileItems.map((item, index) => ( onSelect(item.uid, f)} + onSelect={(f) => onSelect(item.uid!, f)} onEdit={() => viewerRef.current?.edit(item)} onSave={async (prev, curr) => { if (prev !== curr && profiles.current === item.uid) { @@ -926,14 +926,14 @@ const ProfilePage = () => { }} onDelete={() => { if (batchMode) { - toggleProfileSelection(item.uid) + toggleProfileSelection(item.uid!) } else { - onDelete(item.uid) + onDelete(item.uid!) } }} batchMode={batchMode} - isSelected={selectedProfiles.has(item.uid)} - onSelectionChange={() => toggleProfileSelection(item.uid)} + isSelected={selectedProfiles.has(item.uid!)} + onSelectionChange={() => toggleProfileSelection(item.uid!)} /> ))} diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index ac70a1c61..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) } @@ -71,23 +71,27 @@ const ProxyPage = () => { const onToggleChainMode = useLockFn(async () => { const newChainMode = !isChainMode - setIsChainMode(newChainMode) - // 保存链式代理按钮状态到 localStorage - localStorage.setItem('proxy-chain-mode-enabled', newChainMode.toString()) - if (!newChainMode) { // 退出链式代理模式时,清除链式代理配置 try { debugLog('Exiting chain mode, clearing chain configuration') - await mutate(() => updateProxyChainConfigInRuntime(null), { - id: 'update-proxy-chain-runtime', - errorNotice: false, - }) + const result = await mutate( + () => updateProxyChainConfigInRuntime(null), + { + id: 'update-proxy-chain-runtime', + errorNotice: false, + }, + ) + if (!result.ok) return debugLog('Chain configuration cleared successfully') } catch (error) { console.error('Failed to clear chain configuration:', error) + showNotice.error(error) + return } } + setIsChainMode(newChainMode) + localStorage.setItem('proxy-chain-mode-enabled', newChainMode.toString()) }) // 当开启链式代理模式时,获取配置数据 @@ -123,7 +127,7 @@ const ProxyPage = () => { } } - fetchChainConfig() + fetchChainConfig().catch(() => {}) return () => { cancelled = true @@ -132,7 +136,7 @@ const ProxyPage = () => { useEffect(() => { if (normalizedMode && !isMode(normalizedMode)) { - onChangeMode('rule') + onChangeMode('rule').catch(() => {}) } }, [normalizedMode, onChangeMode]) diff --git a/src/services/bindings.ts b/src/services/bindings.ts new file mode 100644 index 000000000..73e886a69 --- /dev/null +++ b/src/services/bindings.ts @@ -0,0 +1,1058 @@ +// This file has been generated by Tauri Specta. Do not edit this file manually. + +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core' + +/** Commands */ +export const commands = { + getSystemInfo: () => __TAURI_INVOKE('get_system_info'), + /** 获取应用的运行时间(毫秒) */ + getAppUptime: () => __TAURI_INVOKE('get_app_uptime'), + exportDiagnosticInfo: () => __TAURI_INVOKE('export_diagnostic_info'), + probeListener: (request: ListenerProbe) => + __TAURI_INVOKE('probe_listener', { request }), + saveProxyPorts: (settings: ProxyPortSettings) => + __TAURI_INVOKE('save_proxy_ports', { settings }), + getSysProxy: () => __TAURI_INVOKE('get_sys_proxy'), + getAutoProxy: () => __TAURI_INVOKE('get_auto_proxy'), + getEmbeddedServerPort: () => + __TAURI_INVOKE('get_embedded_server_port'), + openAppDir: () => __TAURI_INVOKE('open_app_dir'), + openLogsDir: () => __TAURI_INVOKE('open_logs_dir'), + openCoreDir: () => __TAURI_INVOKE('open_core_dir'), + getNetworkInterfaces: () => + __TAURI_INVOKE('get_network_interfaces'), + getSystemHostname: () => __TAURI_INVOKE('get_system_hostname'), + restartApp: () => __TAURI_INVOKE('restart_app'), + installUpdate: (version: string, onEvent: Channel) => + __TAURI_INVOKE('install_update', { version, onEvent }), + cancelUpdateDownload: () => __TAURI_INVOKE('cancel_update_download'), + restartCore: () => __TAURI_INVOKE('restart_core'), + /** Replaces the managed core binary atomically instead of letting mihomo overwrite itself. */ + upgradeClashCore: (force: boolean) => + __TAURI_INVOKE('upgrade_clash_core', { force }), + /** Returns one coherent core/service snapshot instead of independently refreshed state. */ + getRuntimeState: () => __TAURI_INVOKE('get_runtime_state'), + getPendingFailures: () => + __TAURI_INVOKE('get_pending_failures'), + getSidecarFailure: () => + __TAURI_INVOKE('get_sidecar_failure'), + entryLightweightMode: () => __TAURI_INVOKE('entry_lightweight_mode'), + installService: () => + __TAURI_INVOKE('install_service'), + uninstallService: () => __TAURI_INVOKE('uninstall_service'), + reinstallService: () => + __TAURI_INVOKE('reinstall_service'), + repairService: () => __TAURI_INVOKE('repair_service'), + continueWithSidecar: () => __TAURI_INVOKE('continue_with_sidecar'), + syncRuntimeProviders: () => __TAURI_INVOKE('sync_runtime_providers'), + getClashInfo: () => __TAURI_INVOKE('get_clash_info'), + patchClashConfig: (payload: YamlMapping) => + __TAURI_INVOKE('patch_clash_config', { payload }), + patchClashMode: (payload: string) => + __TAURI_INVOKE('patch_clash_mode', { payload }), + /** Reads the saved mode without depending on strict mihomo `/configs` deserialization. */ + getClashMode: () => __TAURI_INVOKE('get_clash_mode'), + changeClashCore: (clashCore: string) => + __TAURI_INVOKE<{ + /** Stable frontend identifier, when classified. */ + code?: string | null + /** Human-readable diagnostic detail. */ + detail: string + /** Requested operation when known by the command. */ + operation?: FailedOperation | null + } | null>('change_clash_core', { clashCore }), + getRuntimeConfig: () => + __TAURI_INVOKE<{ [key in string]: JsonValue } | null>('get_runtime_config'), + getProxyView: () => __TAURI_INVOKE('get_proxy_view'), + getRuntimeYaml: () => __TAURI_INVOKE('get_runtime_yaml'), + getRuntimeLogs: () => __TAURI_INVOKE('get_runtime_logs'), + getRuntimeProxyChainConfig: (proxyChainExitNode: string) => + __TAURI_INVOKE('get_runtime_proxy_chain_config', { + proxyChainExitNode, + }), + updateProxyChainConfigInRuntime: (proxyChainConfig: JsonValue | null) => + __TAURI_INVOKE('update_proxy_chain_config_in_runtime', { + proxyChainConfig, + }), + invokeUwpTool: () => __TAURI_INVOKE('invoke_uwp_tool'), + copyClashEnv: () => __TAURI_INVOKE('copy_clash_env'), + syncTrayProxySelection: () => + __TAURI_INVOKE('sync_tray_proxy_selection'), + /** Merges one selection pair into fresh backend state to avoid stale-list overwrites. */ + recordSelectedNode: (groupName: string, node: string) => + __TAURI_INVOKE('record_selected_node', { groupName, node }), + forgetSelectedNode: (groupName: string) => + __TAURI_INVOKE('forget_selected_node', { groupName }), + saveDnsConfig: (dnsConfig: YamlMapping) => + __TAURI_INVOKE('save_dns_config', { dnsConfig }), + applyDnsConfig: (apply: boolean) => + __TAURI_INVOKE('apply_dns_config', { apply }), + setDnsOverride: ( + profileUid: string, + enabled: boolean, + confirmation: string | null, + ) => + __TAURI_INVOKE('set_dns_override', { + profileUid, + enabled, + confirmation, + }), + takeDnsOverrideNotice: () => + __TAURI_INVOKE('take_dns_override_notice'), + takeServiceFallbackNotice: () => + __TAURI_INVOKE< + | { kind: 'unavailable' } + | { kind: 'coreRejected'; reason: string } + | { kind: 'notAutoStarted' } + | null + >('take_service_fallback_notice'), + getCoreStartupError: () => + __TAURI_INVOKE< + | { kind: 'startFailed'; detail: string } + | { kind: 'serviceCoreStopped'; detail: string } + | null + >('get_core_startup_error'), + takeServiceRepairNotice: () => + __TAURI_INVOKE('take_service_repair_notice'), + takeServiceOwnerNotice: () => + __TAURI_INVOKE('take_service_owner_notice'), + takeDiscardedKeysNotice: () => + __TAURI_INVOKE('take_discarded_keys_notice'), + getDnsConfigContent: () => + __TAURI_INVOKE('get_dns_config_content'), + validateDnsConfig: () => + __TAURI_INVOKE('validate_dns_config'), + getClashLogs: () => __TAURI_INVOKE('get_clash_logs'), + /** 获取Verge配置 */ + getVergeConfig: () => __TAURI_INVOKE('get_verge_config'), + /** 修改Verge配置 */ + patchVergeConfig: (payload: IVerge_Deserialize) => + __TAURI_INVOKE('patch_verge_config', { payload }), + testDelay: (url: string) => __TAURI_INVOKE('test_delay', { url }), + getAppDir: () => __TAURI_INVOKE('get_app_dir'), + copyIconFile: (path: string, iconInfo: IconInfo) => + __TAURI_INVOKE('copy_icon_file', { path, iconInfo }), + downloadIconCache: (url: string, name: string) => + __TAURI_INVOKE('download_icon_cache', { url, name }), + openDevtools: () => __TAURI_INVOKE('open_devtools'), + exitApp: () => __TAURI_INVOKE('exit_app'), + getNetworkInterfacesInfo: () => + __TAURI_INVOKE('get_network_interfaces_info'), + getProfiles: () => __TAURI_INVOKE('get_profiles'), + enhanceProfiles: () => __TAURI_INVOKE('enhance_profiles'), + patchProfilesConfig: (profiles: ProfileSelection) => + __TAURI_INVOKE('patch_profiles_config', { profiles }), + viewProfile: (index: string) => + __TAURI_INVOKE('view_profile', { index }), + patchProfile: (index: string, profile: ProfilePatch_Deserialize) => + __TAURI_INVOKE('patch_profile', { index, profile }), + createProfile: (item: ProfileCreate_Deserialize, fileData: string | null) => + __TAURI_INVOKE('create_profile', { item, fileData }), + importProfile: ( + url: string, + option: { + user_agent?: string | null + with_proxy?: boolean | null + self_proxy?: boolean | null + update_interval?: number | null + /** + * HTTP request timeout in seconds + * default is 60 seconds + */ + timeout_seconds?: number | null + /** default is `false` */ + danger_accept_invalid_certs?: boolean | null + allow_auto_update?: boolean | null + merge?: string | null + script?: string | null + rules?: string | null + proxies?: string | null + groups?: string | null + } | null, + ) => __TAURI_INVOKE('import_profile', { url, option }), + reorderProfile: (activeId: string, overId: string) => + __TAURI_INVOKE('reorder_profile', { activeId, overId }), + updateProfile: ( + index: string, + option: { + user_agent?: string | null + with_proxy?: boolean | null + self_proxy?: boolean | null + update_interval?: number | null + /** + * HTTP request timeout in seconds + * default is 60 seconds + */ + timeout_seconds?: number | null + /** default is `false` */ + danger_accept_invalid_certs?: boolean | null + allow_auto_update?: boolean | null + merge?: string | null + script?: string | null + rules?: string | null + proxies?: string | null + groups?: string | null + } | null, + ) => __TAURI_INVOKE('update_profile', { index, option }), + deleteProfile: (index: string) => + __TAURI_INVOKE('delete_profile', { index }), + readProfileFile: (index: string) => + __TAURI_INVOKE('read_profile_file', { index }), + /** 保存profiles的配置 */ + saveProfileFile: (index: string, fileData: string | null) => + __TAURI_INVOKE('save_profile_file', { index, fileData }), + getNextUpdateTime: (uid: string) => + __TAURI_INVOKE('get_next_update_time', { uid }), + createLocalBackup: () => __TAURI_INVOKE('create_local_backup'), + listLocalBackup: () => __TAURI_INVOKE('list_local_backup'), + deleteLocalBackup: (filename: string) => + __TAURI_INVOKE('delete_local_backup', { filename }), + restoreLocalBackup: (filename: string) => + __TAURI_INVOKE('restore_local_backup', { filename }), + importLocalBackup: (source: string) => + __TAURI_INVOKE('import_local_backup', { source }), + exportLocalBackup: (filename: string, destination: string) => + __TAURI_INVOKE('export_local_backup', { filename, destination }), + createWebdavBackup: () => __TAURI_INVOKE('create_webdav_backup'), + saveWebdavConfig: (url: string, username: string, password: string) => + __TAURI_INVOKE('save_webdav_config', { url, username, password }), + listWebdavBackup: () => __TAURI_INVOKE('list_webdav_backup'), + deleteWebdavBackup: (filename: string) => + __TAURI_INVOKE('delete_webdav_backup', { filename }), + restoreWebdavBackup: (filename: string) => + __TAURI_INVOKE('restore_webdav_backup', { filename }), + getUnlockItems: () => __TAURI_INVOKE('get_unlock_items'), + checkMediaUnlock: (onComplete: Channel) => + __TAURI_INVOKE('check_media_unlock', { onComplete }), + checkMediaUnlockItem: (name: string) => + __TAURI_INVOKE('check_media_unlock_item', { name }), +} + +/* Types */ +export type AutoProxy = { + enable: boolean + url: string +} + +export type ClashInfo = { + /** clash core port */ + mixed_port: number + socks_port: number + port: number + /** same as `external-controller` */ + server: string + /** clash secret */ + secret: string | null +} + +/** Structured command failure returned to the frontend. */ +export type CommandFailure = + | CommandFailure_Serialize + | CommandFailure_Deserialize + +/** Structured command failure returned to the frontend. */ +export type CommandFailure_Deserialize = { + /** Stable frontend identifier, when classified. */ + code: string | null + /** Human-readable diagnostic detail. */ + detail: string + /** Requested operation when known by the command. */ + operation: FailedOperation | null +} + +/** Structured command failure returned to the frontend. */ +export type CommandFailure_Serialize = { + /** Stable frontend identifier, when classified. */ + code?: string | null + /** Human-readable diagnostic detail. */ + detail: string + /** Requested operation when known by the command. */ + operation?: FailedOperation | null +} + +/** Why the Core is not running, kept until a Core starts again. */ +export type CoreFailure = + | { kind: 'startFailed'; detail: string } + | { kind: 'serviceCoreStopped'; detail: string } + +export type CoreUpgradeReport = { + /** False when the managed core was already at the latest version. */ + upgraded: boolean +} + +export type DelayHistory = { + time: string + delay: number +} + +export type DnsOverrideOutcome = + | { status: 'applied' } + | { status: 'confirmation_required'; source: string } + +/** Same wire shape as the updater plugin's JS `DownloadEvent`. */ +export type DownloadEvent = + | { + event: 'Started' + data: { + contentLength: number | null + } + } + | { + event: 'Progress' + data: { + chunkLength: number + } + } + | { event: 'Finished' } + +/** Operation associated with a pending failure. */ +export type FailedOperation = + | 'systemProxyEnable' + | 'systemProxyDisable' + | 'systemProxyRestore' + | 'systemProxyGuard' + +/** Define the `profiles.yaml` schema */ +export type IProfiles = IProfiles_Serialize | IProfiles_Deserialize + +/** Define the `profiles.yaml` schema */ +export type IProfiles_Deserialize = { + current?: string | null + items?: PrfItem_Deserialize[] | null +} + +/** Define the `profiles.yaml` schema */ +export type IProfiles_Serialize = { + current: string | null + items: PrfItem_Serialize[] | null +} + +/** ### `verge.yaml` schema */ +export type IVerge = IVerge_Serialize | IVerge_Deserialize + +export type IVergeTestItem = { + uid?: string | null + name?: string | null + icon?: string | null + url?: string | null +} + +export type IVergeTheme = { + primary_color?: string | null + secondary_color?: string | null + primary_text?: string | null + secondary_text?: string | null + info_color?: string | null + error_color?: string | null + warning_color?: string | null + success_color?: string | null + font_family?: string | null + css_injection?: string | null +} + +/** ### `verge.yaml` schema */ +export type IVerge_Deserialize = { + /** silent | error | warn | info | debug | trace */ + app_log_level?: string | null + /** Maximum app log size in KiB. */ + app_log_max_size?: number | null + app_log_max_count?: number | null + language?: string | null + /** `light` or `dark` or `system` */ + theme_mode?: string | null + tray_event?: string | null + env_type?: string | null + start_page?: string | null + startup_script?: string | null + traffic_graph?: boolean | null + /** show memory info (only for Clash Meta) */ + enable_memory_usage?: boolean | null + enable_group_icon?: boolean | null + pause_render_traffic_stats_on_blur?: boolean | null + common_tray_icon?: boolean | null + menu_icon?: string | null + menu_order?: string[] | null + proxy_group_tools_position?: string | null + notice_position?: string | null + collapse_navbar?: boolean | null + sysproxy_tray_icon?: boolean | null + tun_tray_icon?: boolean | null + enable_tun_mode?: boolean | null + enable_auto_launch?: boolean | null + enable_silent_start?: boolean | null + enable_system_proxy?: boolean | null + enable_proxy_guard?: boolean | null + enable_bypass_check?: boolean | null + /** Initial DNS override preference for profiles without a saved setting. */ + enable_dns_settings?: boolean | null + profile_dns_settings?: { [key in string]: ProfileDnsSettings_Deserialize } + use_default_bypass?: boolean | null + system_proxy_bypass?: string | null + proxy_guard_duration?: number | null + proxy_auto_config?: boolean | null + pac_file_content?: string | null + proxy_host?: string | null + theme_setting?: IVergeTheme | null + web_ui_list?: string[] | null + clash_core?: string | null + /** format: {func},{key} */ + hotkeys?: string[] | null + enable_global_hotkey?: boolean | null + home_cards?: JsonValue | null + auto_close_connection?: boolean | null + auto_check_update?: boolean | null + default_latency_test?: string | null + default_latency_timeout?: number | null + enable_auto_delay_detection?: boolean | null + /** 自动检测当前节点延迟的间隔(分钟) */ + auto_delay_detection_interval_minutes?: number | null + enable_builtin_enhanced?: boolean | null + proxy_layout_column?: number | null + test_list?: IVergeTestItem[] | null + /** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */ + auto_log_clean?: number | null + enable_auto_backup_schedule?: boolean | null + /** Automatic backup interval in hours */ + auto_backup_interval_hours?: number | null + auto_backup_on_change?: boolean | null + verge_mixed_port?: number | null + verge_socks_port?: number | null + verge_socks_enabled?: boolean | null + verge_port?: number | null + verge_http_enabled?: boolean | null + webdav_url?: string | null + webdav_username?: string | null + webdav_password?: string | null + tray_proxy_groups_display_mode?: string | null + tray_inline_outbound_modes?: boolean | null + enable_auto_light_weight_mode?: boolean | null + /** 自动进入轻量模式的延迟(分钟) */ + auto_light_weight_minutes?: number | null + enable_hover_jump_navigator?: boolean | null + /** 代理页面自动滚动延迟(毫秒) */ + hover_jump_navigator_delay?: number | null + enable_external_controller?: boolean | null + tray_icon?: string | null + verge_redir_port?: number | null + verge_redir_enabled?: boolean | null + verge_tproxy_port?: number | null + verge_tproxy_enabled?: boolean | null + enable_tray_speed?: boolean | null +} + +/** ### `verge.yaml` schema */ +export type IVerge_Serialize = { + /** silent | error | warn | info | debug | trace */ + app_log_level: string | null + /** Maximum app log size in KiB. */ + app_log_max_size: number | null + app_log_max_count: number | null + language: string | null + /** `light` or `dark` or `system` */ + theme_mode: string | null + tray_event: string | null + env_type: string | null + start_page: string | null + startup_script: string | null + traffic_graph: boolean | null + /** show memory info (only for Clash Meta) */ + enable_memory_usage: boolean | null + enable_group_icon?: boolean | null + pause_render_traffic_stats_on_blur: boolean | null + common_tray_icon?: boolean | null + menu_icon?: string | null + menu_order?: string[] | null + proxy_group_tools_position?: string | null + notice_position?: string | null + collapse_navbar: boolean | null + sysproxy_tray_icon: boolean | null + tun_tray_icon: boolean | null + enable_tun_mode: boolean | null + enable_auto_launch: boolean | null + enable_silent_start: boolean | null + enable_system_proxy: boolean | null + enable_proxy_guard: boolean | null + enable_bypass_check: boolean | null + /** Initial DNS override preference for profiles without a saved setting. */ + enable_dns_settings: boolean | null + profile_dns_settings?: { [key in string]: ProfileDnsSettings_Serialize } + use_default_bypass: boolean | null + system_proxy_bypass: string | null + proxy_guard_duration: number | null + proxy_auto_config: boolean | null + pac_file_content: string | null + proxy_host: string | null + theme_setting: IVergeTheme | null + web_ui_list: string[] | null + clash_core?: string | null + /** format: {func},{key} */ + hotkeys?: string[] | null + enable_global_hotkey: boolean | null + home_cards: JsonValue | null + auto_close_connection: boolean | null + auto_check_update: boolean | null + default_latency_test: string | null + default_latency_timeout: number | null + enable_auto_delay_detection: boolean | null + /** 自动检测当前节点延迟的间隔(分钟) */ + auto_delay_detection_interval_minutes: number | null + enable_builtin_enhanced: boolean | null + proxy_layout_column: number | null + test_list: IVergeTestItem[] | null + /** 0: 不清理; 1: 1天;2: 7天; 3: 30天; 4: 90天 */ + auto_log_clean: number | null + enable_auto_backup_schedule: boolean | null + /** Automatic backup interval in hours */ + auto_backup_interval_hours: number | null + auto_backup_on_change: boolean | null + verge_mixed_port: number | null + verge_socks_port: number | null + verge_socks_enabled: boolean | null + verge_port: number | null + verge_http_enabled: boolean | null + webdav_url?: string | null + webdav_username?: string | null + webdav_password?: string | null + tray_proxy_groups_display_mode?: string | null + tray_inline_outbound_modes: boolean | null + enable_auto_light_weight_mode: boolean | null + /** 自动进入轻量模式的延迟(分钟) */ + auto_light_weight_minutes: number | null + enable_hover_jump_navigator: boolean | null + /** 代理页面自动滚动延迟(毫秒) */ + hover_jump_navigator_delay: number | null + enable_external_controller: boolean | null + tray_icon?: string | null + verge_redir_port?: number | null + verge_redir_enabled?: boolean | null + verge_tproxy_port?: number | null + verge_tproxy_enabled?: boolean | null + enable_tray_speed?: boolean | null +} + +export type IconInfo = { + name: string + previous_t: string + current_t: string +} + +export type InterfaceAddress = + | ({ V4: IpAddress } & { V6?: never }) + | ({ V6: IpAddress } & { V4?: never }) + +export type IpAddress = { + ip: string +} + +export type JsonValue = + | null + | boolean + | number + | null + | string + | JsonValue[] + | { [key in string]: JsonValue } + +export type ListenerProbe = { + address: string + transports: ListenerTransport[] +} + +export type ListenerProbeOutcome = + | { status: 'available' } + | { status: 'conflict'; port: number; transport: ListenerTransport } + | { status: 'invalid'; message: string } + | { status: 'indeterminate'; message: string } + +export type ListenerTransport = 'tcp' | 'udp' + +export type LocalBackupFile = { + filename: string + path: string + last_modified: string + content_length: number +} + +export type NetworkInterfaceView = { + name: string + addr: InterfaceAddress[] + mac_addr: string | null +} + +export type NoticeStatus = + | 'info' + | 'import_sub_url::ok' + | 'import_sub_url::error' + | 'set_config::error' + | 'core_start::error' + | 'service_core::repair_required' + | 'service_core::app_data_not_owned' + | 'service_core::sidecar_fallback' + | 'dns_override::auto_disabled' + | 'enhance::discarded_keys' + | 'tun_mode::auto_disabled' + | 'tun_mode::auto_disable_failed' + | 'app_restart::core_stop_failed' + | 'app_quit::core_stop_failed' + | 'update_with_clash_proxy' + | 'update_failed_even_with_clash' + | 'reactivate_profiles::error' + | 'update_failed' + | 'config_validate::boot_error' + | 'config_validate::error' + | 'config_validate::process_terminated' + | 'config_validate::script_error' + | 'config_validate::script_syntax_error' + | 'config_validate::script_missing_main' + | 'config_validate::file_not_found' + | 'config_validate::yaml_syntax_error' + | 'config_validate::yaml_read_error' + | 'config_validate::yaml_mapping_error' + | 'config_validate::merge_syntax_error' + | 'config_validate::merge_mapping_error' + | 'config_core::change_success' + | 'config_core::change_error' + | 'mixed_port::fallback' + | 'mixed_port::fallback_error' + +export type PendingAction = + | 'install' + | 'uninstall' + | 'reinstall' + | 'forceReinstall' + +/** Latest unresolved failure for one stable code. */ +export type PendingFailure = { + /** Stable code used as the table key. */ + code: string + /** Full diagnostic context chain. */ + detail: string + operation: FailedOperation + /** Monotonic identity for repeated failures under the same code. */ + sequence: number +} + +export type PlatformVergeFields = { + tray_icon?: string | null + verge_redir_port?: number | null + verge_redir_enabled?: boolean | null + verge_tproxy_port?: number | null + verge_tproxy_enabled?: boolean | null + enable_tray_speed?: boolean | null +} + +export type PrfExtra = { + upload: number + download: number + total: number + expire: number +} + +export type PrfItem = PrfItem_Serialize | PrfItem_Deserialize + +export type PrfItem_Deserialize = { + uid?: string | null + /** enum value: remote | local | script | merge */ + type?: string | null + name?: string | null + file?: string | null + desc?: string | null + url?: string | null + selected?: PrfSelected[] | null + latency_test_urls?: { [key in string]: string } | null + extra?: PrfExtra | null + updated?: number | null + option?: PrfOption_Deserialize | null + home?: string | null +} + +export type PrfItem_Serialize = { + uid: string | null + /** enum value: remote | local | script | merge */ + type: string | null + name: string | null + file: string | null + desc?: string | null + url?: string | null + selected?: PrfSelected[] | null + latency_test_urls?: { [key in string]: string } | null + extra?: PrfExtra | null + updated: number | null + option?: PrfOption_Serialize | null + home?: string | null +} + +export type PrfOption = PrfOption_Serialize | PrfOption_Deserialize + +export type PrfOption_Deserialize = { + user_agent?: string | null + with_proxy?: boolean | null + self_proxy?: boolean | null + update_interval?: number | null + /** + * HTTP request timeout in seconds + * default is 60 seconds + */ + timeout_seconds?: number | null + /** default is `false` */ + danger_accept_invalid_certs?: boolean | null + allow_auto_update?: boolean | null + merge?: string | null + script?: string | null + rules?: string | null + proxies?: string | null + groups?: string | null +} + +export type PrfOption_Serialize = { + user_agent?: string | null + with_proxy?: boolean | null + self_proxy?: boolean | null + update_interval?: number | null + /** + * HTTP request timeout in seconds + * default is 60 seconds + */ + timeout_seconds?: number | null + /** default is `false` */ + danger_accept_invalid_certs?: boolean | null + allow_auto_update?: boolean | null + merge: string | null + script: string | null + rules: string | null + proxies: string | null + groups: string | null +} + +export type PrfSelected = { + name?: string | null + now?: string | null +} + +export type ProfileCreate = ProfileCreate_Serialize | ProfileCreate_Deserialize + +export type ProfileCreate_Deserialize = { + type?: string | null + name?: string | null + desc?: string | null + url?: string | null + option?: PrfOption_Deserialize | null +} + +export type ProfileCreate_Serialize = { + type: string | null + name: string | null + desc: string | null + url: string | null + option: PrfOption_Serialize | null +} + +export type ProfileDnsSettings = + | ProfileDnsSettings_Serialize + | ProfileDnsSettings_Deserialize + +export type ProfileDnsSettings_Deserialize = { + enabled: boolean + confirmation?: string | null +} + +export type ProfileDnsSettings_Serialize = { + enabled: boolean + confirmation?: string | null +} + +export type ProfilePatch = ProfilePatch_Serialize | ProfilePatch_Deserialize + +export type ProfilePatch_Deserialize = { + type?: string | null + name?: string | null + desc?: string | null + file?: string | null + url?: string | null + selected?: PrfSelected[] | null + latency_test_urls?: { [key in string]: string } | null + extra?: PrfExtra | null + updated?: number | null + option?: PrfOption_Deserialize | null +} + +export type ProfilePatch_Serialize = { + type: string | null + name: string | null + desc: string | null + file: string | null + url: string | null + selected: PrfSelected[] | null + latency_test_urls: { [key in string]: string } | null + extra: PrfExtra | null + updated: number | null + option: PrfOption_Serialize | null +} + +export type ProfileSelection = { + current?: string | null +} + +export type ProfileUpdatePayload = { + uid: string +} + +export type ProxyCapabilities = { + udp: boolean + xudp: boolean + tfo: boolean + mptcp: boolean + smux: boolean +} + +export type ProxyGroupView = + | ProxyGroupView_Serialize + | ProxyGroupView_Deserialize + +export type ProxyGroupView_Deserialize = { + name: string + type: string + now: string | null + fixed: string | null + hidden: boolean | null + icon: string | null + testUrl: string | null + history: DelayHistory[] + extra: { [key in string]: ProxyLatencyHistory } + members: ProxyMemberRef[] +} & ProxyCapabilities + +export type ProxyGroupView_Serialize = { + name: string + type: string + now?: string | null + fixed?: string | null + hidden?: boolean | null + icon?: string | null + testUrl?: string | null + history: DelayHistory[] + extra: { [key in string]: ProxyLatencyHistory } + members: ProxyMemberRef[] +} & ProxyCapabilities + +export type ProxyLatencyHistory = { + history: DelayHistory[] +} + +export type ProxyMemberRef = + | { kind: 'group'; name: string } + | { kind: 'node'; name: string; recordId: string } + | { kind: 'unresolved'; name: string; reason: ProxyMemberUnresolvedReason } + +export type ProxyMemberUnresolvedReason = + | 'missing' + | 'ambiguous' + | 'provider-unavailable' + +export type ProxyNodeSource = + | { kind: 'core'; proxyName: string } + | { kind: 'provider'; providerName: string; proxyName: string } + +export type ProxyNodeView = ProxyNodeView_Serialize | ProxyNodeView_Deserialize + +export type ProxyNodeView_Deserialize = { + recordId: string + name: string + type: string + history: DelayHistory[] + extra: { [key in string]: ProxyLatencyHistory } + id: string | null + hidden: boolean | null + icon: string | null + testUrl: string | null + source: ProxyNodeSource +} & ProxyCapabilities + +export type ProxyNodeView_Serialize = { + recordId: string + name: string + type: string + history: DelayHistory[] + extra: { [key in string]: ProxyLatencyHistory } + id?: string | null + hidden?: boolean | null + icon?: string | null + testUrl?: string | null + source: ProxyNodeSource +} & ProxyCapabilities + +export type ProxyPortSettings = { + mixedPort: number + socks: ToggleableProxyPort + http: ToggleableProxyPort + redir: ToggleableProxyPort + tproxy: ToggleableProxyPort +} + +export type ProxyProviderVehicleType = 'HTTP' | 'File' | 'Inline' + +export type ProxyProviderView = + | ProxyProviderView_Serialize + | ProxyProviderView_Deserialize + +export type ProxyProviderView_Deserialize = { + name: string + vehicleType: ProxyProviderVehicleType + updatedAt: string | null + subscriptionInfo: ProxySubscriptionInfo | null + proxyRecordIds: string[] +} + +export type ProxyProviderView_Serialize = { + name: string + vehicleType: ProxyProviderVehicleType + updatedAt?: string | null + subscriptionInfo?: ProxySubscriptionInfo | null + proxyRecordIds: string[] +} + +export type ProxySubscriptionInfo = { + upload: number + download: number + total: number + expire: number +} + +export type ProxyViewOrderSource = 'runtime' | 'fallback' + +export type ProxyViewProviderState = 'ready' | 'unavailable' + +export type ProxyViewV1 = ProxyViewV1_Serialize | ProxyViewV1_Deserialize + +export type ProxyViewV1_Deserialize = { + schemaVersion: number + orderSource: ProxyViewOrderSource + providerState: ProxyViewProviderState + global: ProxyGroupView_Deserialize | null + direct: string | null + groups: ProxyGroupView_Deserialize[] + records: { [key in string]: ProxyNodeView_Deserialize } + standalone: string[] + providers: ProxyProviderView_Deserialize[] +} + +export type ProxyViewV1_Serialize = { + schemaVersion: number + orderSource: ProxyViewOrderSource + providerState: ProxyViewProviderState + global: ProxyGroupView_Serialize | null + direct: string | null + groups: ProxyGroupView_Serialize[] + records: { [key in string]: ProxyNodeView_Serialize } + standalone: string[] + providers: ProxyProviderView_Serialize[] +} + +export type RunStateView = { + mode: RunningMode + service: ServiceHealthKind + serviceUnavailableReason: string | null + pendingAction: PendingAction | null + sidecarAllowed: boolean + isAdmin: boolean + opInFlight: boolean + serviceUsable: boolean + tunCapable: boolean + serviceNeedsAttention: boolean +} + +export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning' + +export type RuntimeLogs = { [key in string]: [string, string][] } + +export type SaveProxyPortsOutcome = + | { status: 'saved' } + | { status: 'conflict'; port: number; transport: ListenerTransport } + +/** Why the Service was unavailable when the core fell back to Sidecar. */ +export type ServiceFallbackNotice = + | { kind: 'unavailable' } + | { kind: 'coreRejected'; reason: string } + | { kind: 'notAutoStarted' } + +export type ServiceHealthKind = + | 'unknown' + | 'ready' + | 'notInstalled' + | 'versionMismatch' + | 'unavailable' + +export type ServiceInstallOutcome = + | { status: 'installed' } + | { status: 'sidecar'; reason: string } + +/** A removal has a revision too, so a delayed read cannot restore a resolved failure. */ +export type SidecarFailureSnapshot = { + revision: number + detail: string | null +} + +export type SystemInfo = { + system_name: string + system_version: string +} + +export type SystemProxy = { + enable: boolean + server: string + bypass: string +} + +export type ToggleableProxyPort = { + enabled: boolean + port: number +} + +export type UnlockItem = { + name: string + status: string + region: string | null + check_time: string | null +} + +export type ValidationErrorKind = + | 'fileMissing' + | 'fileRead' + | 'yamlSyntax' + | 'yamlMapping' + | 'scriptSyntax' + | 'scriptMissingMain' + | 'coreRejected' + | 'processTerminated' + | 'timeout' + +export type ValidationOutcome = + | { status: 'valid' } + | { status: 'invalid'; kind: ValidationErrorKind; message: string } + | { status: 'skipped'; reason: ValidationSkipReason } + | { status: 'busy' } + +export type ValidationSkipReason = 'exiting' | 'debounced' + +export type VergeEventPayloads = { + 'verge://refresh-clash-config': null + 'verge://refresh-verge-config': null + 'verge://refresh-profiles': null + 'verge://refresh-proxy-config': null + 'verge://notice-message': [NoticeStatus, string] + 'profile-changed': string + 'verge://timer-updated': string + 'profile-update-started': ProfileUpdatePayload + 'profile-update-completed': ProfileUpdatePayload + 'verge://run-state-changed': RunStateView + 'verge://pending-failures-changed': null +} + +export type WebDavFile = { + href: string + last_modified: string +} + +export type YamlMapping = { [key in string]: JsonValue } + +export type YamlValue = JsonValue diff --git a/src/services/bus/create-bus-handlers.ts b/src/services/bus/create-bus-handlers.ts index 957589ea7..c41442704 100644 --- a/src/services/bus/create-bus-handlers.ts +++ b/src/services/bus/create-bus-handlers.ts @@ -72,11 +72,7 @@ export const createEventBus = (deps: BusHandlerDeps) => { 'getRuleProviders', ]), 'verge://refresh-verge-config': () => - deps.revalidateKeys([ - 'getVergeConfig', - 'getSystemProxy', - 'getAutotemProxy', - ]), + deps.revalidateKeys(['getVergeConfig', 'getSystemProxy', 'getAutoProxy']), 'verge://refresh-profiles': () => deps.revalidateKeys(['getProfiles']), 'verge://refresh-proxy-config': handleRefreshProxyConfig, 'verge://notice-message': (payload) => deps.handleNotice(payload), diff --git a/src/services/cmds.ts b/src/services/cmds.ts index c5d7c1197..001ee3c71 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -1,141 +1,183 @@ -import { Channel, invoke } from '@tauri-apps/api/core' -import type { DownloadEvent } from '@tauri-apps/plugin-updater' +import { Channel } from '@tauri-apps/api/core' import dayjs from 'dayjs' -import type { CommandFailure } from '@/services/notice-service' import { showNotice } from '@/services/notice-service' -import type { ProxyViewV1 } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' +import { commands } from './bindings' +import type { + DownloadEvent, + JsonValue, + ListenerProbe, + ProxyPortSettings, + UnlockItem, + ProxyViewV1_Serialize as ProxyViewV1, + ProfileSelection, + ProfilePatch, + ProfileCreate, +} from './bindings' + +export type { + RunningMode, + RunStateView as RunState, + FailedOperation, + PendingFailure, + SidecarFailureSnapshot, + ServiceInstallOutcome, + ProxyPortSettings, + ListenerTransport, + ListenerProbe, + UnlockItem, +} from './bindings' + export async function copyClashEnv() { - return invoke('copy_clash_env') + return commands.copyClashEnv() } -export async function getProfiles() { - return invoke('get_profiles') +export async function getProfiles(): Promise { + return commands.getProfiles() } export async function enhanceProfiles() { - return ( - (await invoke('enhance_profiles')).status === 'valid' - ) + return (await commands.enhanceProfiles()).status === 'valid' } -export async function patchProfilesConfig(profiles: IProfilesConfig) { - return invoke('patch_profiles_config', { profiles }) +export async function patchProfilesConfig(profiles: ProfileSelection) { + return commands.patchProfilesConfig({ current: profiles.current }) } export async function createProfile( - item: Partial, + item: ProfileCreate, fileData?: string | null, ) { - return invoke('create_profile', { item, fileData }) -} - -export async function viewProfile(index: string) { - return invoke('view_profile', { index }) -} - -export async function readProfileFile(index: string) { - return invoke('read_profile_file', { index }) -} - -export async function saveProfileFile(index: string, fileData: string) { - return ( - ( - await invoke('save_profile_file', { - index, - fileData, - }) - ).status === 'valid' + return commands.createProfile( + { + type: item.type, + name: item.name, + desc: item.desc, + ...(item.type === 'remote' ? { url: item.url } : {}), + option: item.option, + }, + fileData ?? null, ) } +export async function viewProfile(index: string) { + return commands.viewProfile(index) +} + +export async function readProfileFile(index: string) { + return commands.readProfileFile(index) +} + +export async function saveProfileFile(index: string, fileData: string) { + return (await commands.saveProfileFile(index, fileData)).status === 'valid' +} + export async function importProfile(url: string, option?: IProfileOption) { - return invoke('import_profile', { - url, - option: option || { with_proxy: true }, - }) + return commands.importProfile(url, option || { with_proxy: true }) } export async function reorderProfile(activeId: string, overId: string) { - return invoke('reorder_profile', { - activeId, - overId, - }) + return commands.reorderProfile(activeId, overId) } export async function updateProfile(index: string, option?: IProfileOption) { - return invoke('update_profile', { index, option }) + return commands.updateProfile(index, option ?? null) } export async function deleteProfile(index: string) { - return invoke('delete_profile', { index }) + return commands.deleteProfile(index) } -export async function patchProfile( - index: string, - profile: Partial, -) { - return invoke('patch_profile', { index, profile }) +export async function patchProfile(index: string, profile: ProfilePatch) { + const { + type, + name, + desc, + file, + url, + selected, + latency_test_urls, + extra, + updated, + option, + } = profile + return commands.patchProfile(index, { + type, + name, + desc, + file, + url, + selected, + latency_test_urls, + extra, + updated, + option, + }) } export async function getClashInfo() { - return invoke('get_clash_info') + return commands.getClashInfo() } // Fallback mode read independent of strict mihomo `/configs` deserialization. export async function getClashMode() { - return invoke('get_clash_mode') + return commands.getClashMode() } export async function getRuntimeConfig() { - return invoke('get_runtime_config') + return (await commands.getRuntimeConfig()) as IConfigData | null } export async function getRuntimeYaml() { - return invoke('get_runtime_yaml') + return commands.getRuntimeYaml() } export async function getRuntimeLogs() { - return invoke>('get_runtime_logs') + return commands.getRuntimeLogs() } export async function getRuntimeProxyChainConfig(proxyChainExitNode: string) { - return invoke('get_runtime_proxy_chain_config', { - proxyChainExitNode, - }) + return commands.getRuntimeProxyChainConfig(proxyChainExitNode) } -export async function updateProxyChainConfigInRuntime(proxyChainConfig: any) { - return invoke('update_proxy_chain_config_in_runtime', { - proxyChainConfig, - }) +export async function updateProxyChainConfigInRuntime( + proxyChainConfig: unknown, +) { + const outcome = await commands.updateProxyChainConfigInRuntime( + proxyChainConfig == null ? null : (proxyChainConfig as JsonValue), + ) + if (outcome.status === 'invalid') throw new Error(outcome.message) + if (outcome.status === 'skipped') { + throw new Error(`Proxy chain validation skipped: ${outcome.reason}`) + } + return outcome } export async function patchClashConfig(payload: Partial) { - return invoke('patch_clash_config', { payload }) + return commands.patchClashConfig(payload as Record) } export async function patchClashMode(payload: string) { - return invoke('patch_clash_mode', { payload }) + return commands.patchClashMode(payload) } export async function syncTrayProxySelection() { - return invoke('sync_tray_proxy_selection') + return commands.syncTrayProxySelection() } /** Sends one selection pair so the backend can merge against current profile state. */ export async function recordSelectedNode(groupName: string, node: string) { - return invoke('record_selected_node', { groupName, node }) + return commands.recordSelectedNode(groupName, node) } export async function forgetSelectedNode(groupName: string) { - return invoke('forget_selected_node', { groupName }) + return commands.forgetSelectedNode(groupName) } export async function getProxyView(): Promise { - const view = await invoke('get_proxy_view') + const view = await commands.getProxyView() if (view.schemaVersion !== 1) { throw new Error('Unsupported proxy view schema: ' + view.schemaVersion) } @@ -145,7 +187,7 @@ export async function getProxyView(): Promise { export async function getClashLogs() { const regex = /time="(.+?)"\s+level=(.+?)\s+msg="(.+?)"/ const newRegex = /(.+?)\s+(.+?)\s+(.+)/ - const logs = await invoke('get_clash_logs') + const logs = await commands.getClashLogs() return logs.reduce((acc, log) => { const result = log.match(regex) @@ -165,12 +207,12 @@ export async function getClashLogs() { }, []) } -export async function getVergeConfig() { - return invoke('get_verge_config') +export async function getVergeConfig(): Promise { + return commands.getVergeConfig() } export async function patchVergeConfig(payload: IVergeConfig) { - return invoke('patch_verge_config', { payload }) + return commands.patchVergeConfig(payload) } export async function setDnsOverride( @@ -178,76 +220,57 @@ export async function setDnsOverride( enabled: boolean, confirmation?: string, ) { - return invoke< - { status: 'applied' } | { status: 'confirmation_required'; source: string } - >('set_dns_override', { profileUid, enabled, confirmation }) + return commands.setDnsOverride(profileUid, enabled, confirmation ?? null) } export async function getDnsConfigContent() { - return invoke('get_dns_config_content') + return commands.getDnsConfigContent() } export async function saveDnsConfig(dnsConfig: Record) { - return invoke('save_dns_config', { dnsConfig }) + return commands.saveDnsConfig(dnsConfig as Record) } export async function validateDnsConfig() { - return invoke('validate_dns_config') + return commands.validateDnsConfig() } export async function applyDnsConfig(apply: boolean) { - return invoke('apply_dns_config', { apply }) + return commands.applyDnsConfig(apply) } export async function takeDnsOverrideNotice() { - return invoke('take_dns_override_notice') + return commands.takeDnsOverrideNotice() } -export type ServiceFallbackNotice = - | { kind: 'unavailable' } - | { kind: 'coreRejected'; reason: string } - | { kind: 'notAutoStarted' } - export async function takeServiceFallbackNotice() { - return invoke('take_service_fallback_notice') -} - -export interface CoreFailure { - kind: 'startFailed' | 'serviceCoreStopped' - detail: string + return commands.takeServiceFallbackNotice() } export async function getCoreStartupError() { - return invoke('get_core_startup_error') + return commands.getCoreStartupError() } export async function takeServiceRepairNotice() { - return invoke('take_service_repair_notice') + return commands.takeServiceRepairNotice() } export async function takeServiceOwnerNotice() { - return invoke('take_service_owner_notice') + return commands.takeServiceOwnerNotice() } export async function takeDiscardedKeysNotice() { - return invoke('take_discarded_keys_notice') + return commands.takeDiscardedKeysNotice() } export async function getSystemProxy() { - return invoke<{ - enable: boolean - server: string - bypass: string - }>('get_sys_proxy') + return commands.getSysProxy() } -export async function getAutotemProxy() { +export async function getAutoProxy() { try { debugLog('[API] 开始调用 get_auto_proxy') - const result = await invoke<{ - enable: boolean - url: string - }>('get_auto_proxy') + const result = await commands.getAutoProxy() debugLog('[API] get_auto_proxy 调用成功:', result) return result } catch (error) { @@ -260,30 +283,23 @@ export async function getAutotemProxy() { } export async function getEmbeddedServerPort() { - return invoke('get_embedded_server_port') + return commands.getEmbeddedServerPort() } export async function changeClashCore(clashCore: string) { - return invoke('change_clash_core', { clashCore }) + return commands.changeClashCore(clashCore) } export async function restartCore() { - return invoke('restart_core') -} - -export interface CoreUpgradeReport { - /** False when the managed core was already at the latest version. */ - upgraded: boolean - from: string - to: string + return commands.restartCore() } export async function upgradeClashCore(force = false) { - return invoke('upgrade_clash_core', { force }) + return commands.upgradeClashCore(force) } export async function restartApp() { - return invoke('restart_app') + return commands.restartApp() } export async function installUpdate( @@ -292,69 +308,57 @@ export async function installUpdate( ) { const channel = new Channel() channel.onmessage = onEvent - return invoke('install_update', { version, onEvent: channel }) + return commands.installUpdate(version, channel) } export async function cancelUpdateDownload() { - return invoke('cancel_update_download') + return commands.cancelUpdateDownload() } export async function getAppDir() { - return invoke('get_app_dir') + return commands.getAppDir() } export async function openAppDir() { - return invoke('open_app_dir').catch((err) => showNotice.error(err)) + return commands.openAppDir().catch((err) => showNotice.error(err)) } export async function openCoreDir() { - return invoke('open_core_dir').catch((err) => showNotice.error(err)) + return commands.openCoreDir().catch((err) => showNotice.error(err)) } export async function openLogsDir() { - return invoke('open_logs_dir').catch((err) => showNotice.error(err)) + return commands.openLogsDir().catch((err) => showNotice.error(err)) } export async function syncRuntimeProviders() { - return invoke('sync_runtime_providers').catch((err) => { + return commands.syncRuntimeProviders().catch((err) => { console.warn('failed to queue the provider cache sync', err) }) } export async function cmdTestDelay(url: string) { - return invoke('test_delay', { url }) + return commands.testDelay(url) } export async function invoke_uwp_tool() { - return invoke('invoke_uwp_tool').catch((err) => - showNotice.error(err, 1500), - ) + return commands.invokeUwpTool().catch((err) => showNotice.error(err, 1500)) } export async function openDevTools() { - return invoke('open_devtools') + return commands.openDevtools() } export async function exitApp() { - return invoke('exit_app') + return commands.exitApp() } export async function exportDiagnosticInfo() { - return invoke('export_diagnostic_info') -} - -interface SystemInfo { - system_name: string - system_version: string - system_kernel_version: string - system_arch: string - app_version: string - app_core_mode: string - app_is_admin: boolean + return commands.exportDiagnosticInfo() } export async function getSystemInfo() { - return invoke('get_system_info') + return commands.getSystemInfo() } export async function copyIconFile( @@ -373,55 +377,55 @@ export async function copyIconFile( current_t: currentTime, } - return invoke('copy_icon_file', { path, iconInfo }) + return commands.copyIconFile(path, iconInfo) } export async function downloadIconCache(url: string, name: string) { - return invoke('download_icon_cache', { url, name }) + return commands.downloadIconCache(url, name) } export async function getNetworkInterfaces() { - return invoke('get_network_interfaces') + return commands.getNetworkInterfaces() } export async function getSystemHostname() { - return invoke('get_system_hostname') + return commands.getSystemHostname() } export async function getNetworkInterfacesInfo() { - return invoke('get_network_interfaces_info') + return commands.getNetworkInterfacesInfo() } export async function createWebdavBackup() { - return invoke('create_webdav_backup') + return commands.createWebdavBackup() } export async function createLocalBackup() { - return invoke('create_local_backup') + return commands.createLocalBackup() } export async function deleteWebdavBackup(filename: string) { - return invoke('delete_webdav_backup', { filename }) + return commands.deleteWebdavBackup(filename) } export async function deleteLocalBackup(filename: string) { - return invoke('delete_local_backup', { filename }) + return commands.deleteLocalBackup(filename) } export async function restoreWebDavBackup(filename: string) { - return invoke('restore_webdav_backup', { filename }) + return commands.restoreWebdavBackup(filename) } export async function restoreLocalBackup(filename: string) { - return invoke('restore_local_backup', { filename }) + return commands.restoreLocalBackup(filename) } export async function importLocalBackup(source: string) { - return invoke('import_local_backup', { source }) + return commands.importLocalBackup(source) } export async function exportLocalBackup(filename: string, destination: string) { - return invoke('export_local_backup', { filename, destination }) + return commands.exportLocalBackup(filename, destination) } export async function saveWebdavConfig( @@ -429,177 +433,82 @@ export async function saveWebdavConfig( username: string, password: string, ) { - return invoke('save_webdav_config', { - url, - username, - password, - }) + return commands.saveWebdavConfig(url, username, password) } export async function listWebDavBackup() { - const list: IWebDavFile[] = await invoke('list_webdav_backup') - list.forEach((item) => { - item.filename = item.href.split('/').pop() as string - }) - return list + return (await commands.listWebdavBackup()).map((item) => ({ + ...item, + filename: item.href.split('/').pop() ?? '', + })) } export async function listLocalBackup() { - return invoke('list_local_backup') + return commands.listLocalBackup() } -export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning' - -type ServiceHealth = - | 'unknown' - | 'ready' - | 'notInstalled' - | 'versionMismatch' - | 'unavailable' - -type PendingServiceAction = - | 'install' - | 'uninstall' - | 'reinstall' - | 'forceReinstall' - /** Consistent core/service snapshot with backend-derived availability flags. */ -export interface RunState { - mode: RunningMode - service: ServiceHealth - serviceUnavailableReason: string | null - pendingAction: PendingServiceAction | null - sidecarAllowed: boolean - isAdmin: boolean - opInFlight: boolean - serviceUsable: boolean - tunCapable: boolean - serviceNeedsAttention: boolean -} export const getRuntimeState = async () => { - return invoke('get_runtime_state') -} - -export type FailedOperation = - | 'systemProxyEnable' - | 'systemProxyDisable' - | 'systemProxyRestore' - | 'systemProxyGuard' - -export interface PendingFailure { - code: string - detail: string - operation: FailedOperation - sequence: number + return commands.getRuntimeState() } export const getPendingFailures = async () => { - return invoke('get_pending_failures') -} - -export interface SidecarFailureSnapshot { - revision: number - detail: string | null + return commands.getPendingFailures() } export const getSidecarFailure = async () => { - return invoke('get_sidecar_failure') + return commands.getSidecarFailure() } export const getAppUptime = async () => { - return invoke('get_app_uptime') + return commands.getAppUptime() } -export type ServiceInstallOutcome = - | { status: 'installed' } - | { status: 'sidecar'; reason: string } - export const installService = async () => { - return invoke('install_service') + return commands.installService() } export const uninstallService = async () => { - return invoke('uninstall_service') + return commands.uninstallService() } export const reinstallService = async () => { - return invoke('reinstall_service') + return commands.reinstallService() } export const repairService = async () => { - return invoke('repair_service') + return commands.repairService() } export const continueWithSidecar = async () => { - return invoke('continue_with_sidecar') + return commands.continueWithSidecar() } export const entry_lightweight_mode = async () => { - return invoke('entry_lightweight_mode') + return commands.entryLightweightMode() } export async function getNextUpdateTime(uid: string) { - return invoke('get_next_update_time', { uid }) + return commands.getNextUpdateTime(uid) } -interface ToggleableProxyPort { - enabled: boolean - port: number -} - -export interface ProxyPortSettings { - mixedPort: number - socks: ToggleableProxyPort - http: ToggleableProxyPort - redir: ToggleableProxyPort - tproxy: ToggleableProxyPort -} - -export type ListenerTransport = 'tcp' | 'udp' - -export interface ListenerProbe { - address: string - transports: ListenerTransport[] -} - -export type ListenerProbeOutcome = - | { status: 'available' } - | { - status: 'conflict' - port: number - transport: ListenerTransport - } - | { status: 'invalid'; message: string } - | { status: 'indeterminate'; message: string } - -export type SaveProxyPortsOutcome = - | { status: 'saved' } - | { status: 'conflict'; port: number; transport: ListenerTransport } - export const probeListener = async (request: ListenerProbe) => { - return invoke('probe_listener', { request }) + return commands.probeListener(request) } export const saveProxyPorts = async (settings: ProxyPortSettings) => { - return invoke('save_proxy_ports', { settings }) -} - -export interface UnlockItem { - name: string - status: string - region?: string | null - check_time?: string | null + return commands.saveProxyPorts(settings) } export async function getUnlockItems() { - return invoke('get_unlock_items') + return commands.getUnlockItems() } export async function checkMediaUnlock(onComplete: Channel) { - return invoke('check_media_unlock', { onComplete }) + return commands.checkMediaUnlock(onComplete) } export async function checkMediaUnlockItem(name: string) { - return invoke('check_media_unlock_item', { name }) + return commands.checkMediaUnlockItem(name) } diff --git a/src/services/contract.ts b/src/services/contract.ts index 093d9fcd8..5c1b83957 100644 --- a/src/services/contract.ts +++ b/src/services/contract.ts @@ -1,56 +1 @@ -// @generated by scripts/gen-contract.mjs from the backend wire contract. DO NOT EDIT. -// Regen: pnpm gen:contract - -import type { RunState } from './cmds' - -/** Wire status strings from NoticeStatus; keep in sync via `pnpm gen:contract`. */ -export type NoticeStatus = - | 'info' - | 'import_sub_url::ok' - | 'import_sub_url::error' - | 'set_config::error' - | 'core_start::error' - | 'service_core::repair_required' - | 'service_core::app_data_not_owned' - | 'service_core::sidecar_fallback' - | 'dns_override::auto_disabled' - | 'enhance::discarded_keys' - | 'tun_mode::auto_disabled' - | 'tun_mode::auto_disable_failed' - | 'app_restart::core_stop_failed' - | 'app_quit::core_stop_failed' - | 'update_with_clash_proxy' - | 'update_failed_even_with_clash' - | 'reactivate_profiles::error' - | 'update_failed' - | 'config_validate::boot_error' - | 'config_validate::error' - | 'config_validate::process_terminated' - | 'config_validate::script_error' - | 'config_validate::script_syntax_error' - | 'config_validate::script_missing_main' - | 'config_validate::file_not_found' - | 'config_validate::yaml_syntax_error' - | 'config_validate::yaml_read_error' - | 'config_validate::yaml_mapping_error' - | 'config_validate::merge_syntax_error' - | 'config_validate::merge_mapping_error' - | 'config_core::change_success' - | 'config_core::change_error' - | 'mixed_port::fallback' - | 'mixed_port::fallback_error' - -/** Event name → payload type for every backend-emitted event. */ -export interface VergeEventPayloads { - 'verge://refresh-clash-config': string - 'verge://refresh-verge-config': string - 'verge://refresh-profiles': string - 'verge://refresh-proxy-config': null - 'verge://notice-message': [NoticeStatus, string] - 'verge://timer-updated': string - 'verge://run-state-changed': RunState - 'verge://pending-failures-changed': null - 'profile-changed': string - 'profile-update-started': { uid: string } - 'profile-update-completed': { uid: string } -} +export type { NoticeStatus, VergeEventPayloads } from './bindings' diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts index 5f6a51afe..a26d25dde 100644 --- a/src/services/delay.test.ts +++ b/src/services/delay.test.ts @@ -66,7 +66,6 @@ test('uses newer core history after a tray test without hiding an active page te const cached = delayManager.setDelay(member, 'tray-group', 50) member.node.extra = { [delayManager.getUrl('tray-group')]: { - alive: true, history: [ { time: new Date(cached.updatedAt + 1000).toISOString(), delay: 300 }, ], @@ -107,13 +106,11 @@ test('another URL cannot replace a group measurement, but a newer matching URL c history: [{ time: newer, delay: 126 }], extra: { 'http://localhost/a': { - alive: true, history: [ { time: new Date(cached.updatedAt - 1000).toISOString(), delay: 49 }, ], }, 'http://localhost/b': { - alive: true, history: [{ time: newer, delay: 126 }], }, }, @@ -122,7 +119,6 @@ test('another URL cannot replace a group measurement, but a newer matching URL c Object.assign(member.node, { extra: { 'http://localhost/a': { - alive: true, history: [{ time: newer, delay: 75 }], }, }, @@ -190,7 +186,7 @@ test('unmeasured selectors show automatic history without replacing a manual URL { time: new Date(Date.now() + 1000).toISOString(), delay: 126 }, ] member.node.extra = { - 'http://localhost/automatic': { alive: true, history: member.node.history }, + 'http://localhost/automatic': { history: member.node.history }, } expect(delayManager.getDelayFix(member, 'selector')).toBe(126) delayManager.setDelay(member, 'selector', 50) diff --git a/src/services/notice-service.ts b/src/services/notice-service.ts index 34157a1ea..c36a0a85b 100644 --- a/src/services/notice-service.ts +++ b/src/services/notice-service.ts @@ -305,11 +305,7 @@ function extractDisplayText(input: unknown): string | undefined { } } -export interface CommandFailure { - code?: string - operation?: FailedOperation - detail: string -} +import type { CommandFailure_Serialize as CommandFailure } from './bindings' function isCommandFailure(input: unknown): input is CommandFailure { if (typeof input !== 'object' || input === null) return false @@ -495,11 +491,11 @@ const baseShowNotice = ( } function failureCode(input: unknown): string | undefined { - return isCommandFailure(input) ? input.code : undefined + return isCommandFailure(input) ? input.code! : undefined } function failureOperation(input: unknown): FailedOperation | undefined { - return isCommandFailure(input) ? input.operation : undefined + return isCommandFailure(input) ? input.operation! : undefined } /** diff --git a/src/services/proxy-test-url.test.ts b/src/services/proxy-test-url.test.ts index e1e348f11..4f2969c88 100644 --- a/src/services/proxy-test-url.test.ts +++ b/src/services/proxy-test-url.test.ts @@ -32,6 +32,10 @@ test('committed edits retain their profile and merge with fresh saved URLs', asy items: [ { uid: 'old-profile', + type: null, + name: null, + file: null, + updated: null, latency_test_urls: { untouched: 'https://example.com/' }, }, ], @@ -49,7 +53,16 @@ test('committed edits retain their profile and merge with fresh saved URLs', asy test('migration cannot overwrite a previously saved or cleared override', async () => { vi.mocked(getProfiles).mockResolvedValue({ - items: [{ uid: 'p', latency_test_urls: { A: '' } }], + items: [ + { + uid: 'p', + type: null, + name: null, + file: null, + updated: null, + latency_test_urls: { A: '' }, + }, + ], }) await saveProfileTestUrls('p', { A: 'http://localhost/stale' }, true) expect(patchProfile).not.toHaveBeenCalled() @@ -59,14 +72,18 @@ test('pending edits survive a reload and older save acknowledgements', async () vi.useFakeTimers() const profile = { uid: 'pending-profile', + type: null, + name: null, + file: null, + updated: null, latency_test_urls: { A: 'http://localhost/old' }, } vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) let finish!: () => void vi.mocked(patchProfile).mockImplementationOnce( () => - new Promise((resolve) => { - finish = resolve + new Promise((resolve) => { + finish = () => resolve(null) }), ) const first = commitProfileTestUrl(profile.uid, 'A', 'http://localhost/new') @@ -90,6 +107,10 @@ test('pending edits survive a reload and older save acknowledgements', async () test('a failed save preserves the committed input across profile refreshes', async () => { const profile = { uid: 'failed-profile', + type: null, + name: null, + file: null, + updated: null, latency_test_urls: { A: 'http://localhost/old' }, } vi.mocked(getProfiles).mockResolvedValue({ items: [profile] }) diff --git a/src/services/proxy-test-url.ts b/src/services/proxy-test-url.ts index cdee55d5d..059b632fe 100644 --- a/src/services/proxy-test-url.ts +++ b/src/services/proxy-test-url.ts @@ -53,16 +53,17 @@ const migrating = new Set() export function migrateProfileTestUrls(profiles: IProfilesConfig) { for (const profile of profiles.items || []) { - if (profile.latency_test_urls !== undefined || migrating.has(profile.uid)) - continue - const urls = readLegacyTestUrls(profile.uid) + const uid = profile.uid + if (!uid) continue + if (profile.latency_test_urls !== undefined || migrating.has(uid)) continue + const urls = readLegacyTestUrls(uid) if (!Object.keys(urls).length) continue - migrating.add(profile.uid) - void saveProfileTestUrls(profile.uid, urls, true) + migrating.add(uid) + void saveProfileTestUrls(uid, urls, true) .catch((error) => { console.error('Failed to migrate latency test URLs:', error) }) - .finally(() => migrating.delete(profile.uid)) + .finally(() => migrating.delete(uid)) } } @@ -92,10 +93,9 @@ export function profileTestUrls(profile: IProfileItem): Record { return { ...profile.latency_test_urls, ...Object.fromEntries( - Object.entries(pending.get(profile.uid) ?? {}).map(([group, edit]) => [ - group, - edit.url, - ]), + Object.entries( + (profile.uid ? pending.get(profile.uid) : undefined) ?? {}, + ).map(([group, edit]) => [group, edit.url]), ), } } diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 4083bc95d..d86c1673d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -13,10 +13,7 @@ type Platform = declare const OS_PLATFORM: Platform -type ValidationOutcome = - | { status: 'valid' | 'busy' } - | { status: 'invalid'; kind: string; message: string } - | { status: 'skipped'; reason: string } +type ValidationOutcome = import('@/services/bindings').ValidationOutcome interface IConfigData { port: number @@ -207,83 +204,19 @@ interface IConnectionSetting { layout: 'table' | 'list' } -interface IClashInfo { - mixed_port?: number // clash mixed port - socks_port?: number // clash socks port - redir_port?: number // clash redir port - tproxy_port?: number // clash tproxy port - port?: number // clash http port - server?: string // external-controller - secret?: string -} +type IClashInfo = import('@/services/bindings').ClashInfo -interface IProfileItem { - latency_test_urls?: Record - uid: string - type?: 'local' | 'remote' | 'merge' | 'script' - name?: string - desc?: string - file?: string - url?: string - updated?: number - selected?: { - name?: string - now?: string - }[] - extra?: { - upload: number - download: number - total: number - expire: number - } - option?: IProfileOption - home?: string -} +type IProfileItem = import('@/services/bindings').PrfItem_Serialize -interface IProfileOption { - user_agent?: string - with_proxy?: boolean - self_proxy?: boolean - update_interval?: number - timeout_seconds?: number - danger_accept_invalid_certs?: boolean - allow_auto_update?: boolean - merge?: string - script?: string - rules?: string - proxies?: string - groups?: string -} +type IProfileOption = import('@/services/bindings').PrfOption_Deserialize -interface IProfilesConfig { - current?: string - items?: IProfileItem[] -} +type IProfilesConfig = Partial< + import('@/services/bindings').IProfiles_Serialize +> -interface IVergeTestItem { - uid: string - name?: string - icon?: string - url: string -} -interface IAddress { - V4?: { - ip: string - broadcast?: string - netmask?: string - } - V6?: { - ip: string - broadcast?: string - netmask?: string - } -} -interface INetworkInterface { - name: string - addr: IAddress[] - mac_addr?: string - index: number -} +type IVergeTestItem = import('@/services/bindings').IVergeTestItem +type IAddress = import('@/services/bindings').InterfaceAddress +type INetworkInterface = import('@/services/bindings').NetworkInterfaceView interface ISeqProfileConfig { prepend: [] @@ -851,119 +784,24 @@ interface IProxyConfig | 'sudoku' } -interface IVergeConfig { - app_log_level?: 'trace' | 'debug' | 'info' | 'warn' | 'error' | string - app_log_max_size?: number // KB - app_log_max_count?: number - language?: string - tray_event?: - | 'main_window' - | 'tray_menu' - | 'system_proxy' - | 'tun_mode' - | string - env_type?: 'bash' | 'cmd' | 'powershell' | 'fish' | string - startup_script?: string - start_page?: string - clash_core?: string - theme_mode?: 'light' | 'dark' | 'system' - traffic_graph?: boolean - enable_memory_usage?: boolean - enable_group_icon?: boolean - pause_render_traffic_stats_on_blur?: boolean - menu_icon?: 'monochrome' | 'colorful' | 'disable' - menu_order?: string[] - proxy_group_tools_position?: 'left' | 'right' - notice_position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' - collapse_navbar?: boolean - tray_icon?: 'monochrome' | 'colorful' - common_tray_icon?: boolean - sysproxy_tray_icon?: boolean - tun_tray_icon?: boolean - enable_tray_speed?: boolean - tray_proxy_groups_display_mode?: 'default' | 'inline' | 'disable' - tray_inline_outbound_modes?: boolean - enable_tun_mode?: boolean - enable_auto_light_weight_mode?: boolean - auto_light_weight_minutes?: number - enable_auto_launch?: boolean - enable_silent_start?: boolean - enable_system_proxy?: boolean - enable_global_hotkey?: boolean - enable_dns_settings?: boolean - profile_dns_settings?: Record - proxy_auto_config?: boolean - pac_file_content?: string - proxy_host?: string - enable_random_port?: boolean - verge_mixed_port?: number - verge_socks_port?: number - verge_redir_port?: number - verge_tproxy_port?: number - verge_port?: number - verge_redir_enabled?: boolean - verge_tproxy_enabled?: boolean - verge_socks_enabled?: boolean - verge_http_enabled?: boolean - enable_proxy_guard?: boolean - enable_bypass_check?: boolean - use_default_bypass?: boolean - proxy_guard_duration?: number - system_proxy_bypass?: string - web_ui_list?: string[] - hotkeys?: string[] - theme_setting?: { - primary_color?: string - secondary_color?: string - primary_text?: string - secondary_text?: string - info_color?: string - error_color?: string - warning_color?: string - success_color?: string - font_family?: string - css_injection?: string - background_image?: string - background_blend_mode?: string - background_opacity?: number - } - auto_close_connection?: boolean - auto_check_update?: boolean - default_latency_test?: string - default_latency_timeout?: number - enable_auto_delay_detection?: boolean - auto_delay_detection_interval_minutes?: number - enable_builtin_enhanced?: boolean - auto_log_clean?: 0 | 1 | 2 | 3 | 4 - enable_auto_backup_schedule?: boolean - auto_backup_interval_hours?: number - auto_backup_on_change?: boolean - proxy_layout_column?: number - test_list?: IVergeTestItem[] - webdav_url?: string - webdav_username?: string - webdav_password?: string - home_cards?: Record - enable_hover_jump_navigator?: boolean - hover_jump_navigator_delay?: number - enable_external_controller?: boolean +type IVergeConfig = Omit< + Partial, + 'theme_setting' +> & { + theme_setting?: + | (import('@/services/bindings').IVergeTheme & { + background_image?: string + background_blend_mode?: string + background_opacity?: number + }) + | null } -interface IWebDavFile { +type IWebDavFile = import('@/services/bindings').WebDavFile & { filename: string - href: string - last_modified: string - content_length: number - content_type: string - tag: string } -interface ILocalBackupFile { - filename: string - path: string - last_modified: string - content_length: number -} +type ILocalBackupFile = import('@/services/bindings').LocalBackupFile interface IWebDavConfig { url: string diff --git a/src/types/proxy-view.ts b/src/types/proxy-view.ts index 7b41bafc5..d65b1fa89 100644 --- a/src/types/proxy-view.ts +++ b/src/types/proxy-view.ts @@ -1,86 +1,15 @@ -export interface ProxyViewV1 { - schemaVersion: 1 - orderSource: 'runtime' | 'fallback' - providerState: 'ready' | 'unavailable' - global: ProxyGroupView | null - direct: string | null - groups: ProxyGroupView[] - records: Record - standalone: string[] - providers: ProxyProviderView[] -} - -interface ProxyCapabilities { - udp: boolean - xudp: boolean - tfo: boolean - mptcp: boolean - smux: boolean -} - -interface DelayHistory { - time: string - delay: number -} - -export interface ProxyGroupView extends ProxyCapabilities { - name: string - type: string - alive: boolean - now?: string - fixed?: string - hidden?: boolean - icon?: string - testUrl?: string - history: DelayHistory[] - extra?: Record - members: ProxyMemberRef[] -} - -export interface ProxyNodeView extends ProxyCapabilities { - recordId: string - name: string - type: string - alive: boolean - history: DelayHistory[] - extra?: Record - id?: string - hidden?: boolean - icon?: string - testUrl?: string - source: - | { kind: 'core'; proxyName: string } - | { kind: 'provider'; providerName: string; proxyName: string } -} - -type ProxyMemberUnresolvedReason = - | 'missing' - | 'ambiguous' - | 'provider-unavailable' - -export type ProxyMemberRef = - | { kind: 'group'; name: string } - | { kind: 'node'; name: string; recordId: string } - | { - kind: 'unresolved' - name: string - reason: ProxyMemberUnresolvedReason - } - -interface ProxyProviderView { - name: string - vehicleType: 'HTTP' | 'File' | 'Inline' - updatedAt?: string - subscriptionInfo?: ProxySubscriptionInfo - proxyRecordIds: string[] -} - -interface ProxySubscriptionInfo { - upload: number - download: number - total: number - expire: number -} +import type { + ProxyViewV1_Serialize as ProxyViewV1, + ProxyGroupView_Serialize as ProxyGroupView, + ProxyNodeView_Serialize as ProxyNodeView, + ProxyMemberRef, +} from '@/services/bindings' +export type { + ProxyViewV1_Serialize as ProxyViewV1, + ProxyGroupView_Serialize as ProxyGroupView, + ProxyNodeView_Serialize as ProxyNodeView, + ProxyMemberRef, +} from '@/services/bindings' export type ResolvedProxyMember = | {