From 5bf0de0d0bb99b84f61a8ab5c1dd9bac0f6f942e Mon Sep 17 00:00:00 2001 From: Tunglies <77394545+Tunglies@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:01:34 +0800 Subject: [PATCH] feat(contract): export wire contract from backend and generate frontend types Adds a zero-dependency `frontend-contract` bin that serializes the real NoticeStatus variants and FrontendEvent payloads, scripts/gen-contract.mjs which emits the committed src/services/contract.ts, and a CI job that regenerates it and fails on a non-empty diff. events.ts now derives its event map from the generated contract instead of hand-typed shapes. --- .github/workflows/frontend-check.yml | 75 +++++++++++ knip.json | 3 +- package.json | 1 + scripts/gen-contract.mjs | 91 +++++++++++++ src-tauri/src/bin/frontend-contract.rs | 8 ++ src-tauri/src/core/notify/mod.rs | 94 +++++++------ src-tauri/src/core/notify/notification.rs | 154 +++++++++++++--------- src-tauri/src/lib.rs | 4 + src/services/contract.ts | 63 +++++++++ src/services/events.ts | 18 +-- 10 files changed, 390 insertions(+), 121 deletions(-) create mode 100644 scripts/gen-contract.mjs create mode 100644 src-tauri/src/bin/frontend-contract.rs create mode 100644 src/services/contract.ts diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 150d6bc8c..0ffd070e5 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -85,3 +85,78 @@ jobs: - name: Run Knip if: steps.check_frontend.outputs.frontend == 'true' run: pnpm knip:check + + contract-parity: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + + - name: Check contract inputs + 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' + + - name: Skip if contract inputs unchanged + if: steps.check_inputs.outputs.contract != 'true' + run: echo "No contract inputs changed, skipping parity check." + + - name: Install Rust Stable + if: steps.check_inputs.outputs.contract == 'true' + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Rust Cache + if: steps.check_inputs.outputs.contract == 'true' + uses: Swatinem/rust-cache@v2 + with: + prefix-key: 'v1-rust' + key: 'rust-shared-stable-ubuntu-22.04-x86_64-unknown-linux-gnu-contract' + workspaces: | + . -> target + cache-all-crates: true + cache-workspace-crates: true + + - name: Install dependencies + if: steps.check_inputs.outputs.contract == 'true' + run: | + sudo apt-get update + sudo apt-get install -y libxslt1.1 libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf + + - name: Install pnpm + if: steps.check_inputs.outputs.contract == 'true' + uses: pnpm/action-setup@v6 + with: + run_install: false + + - uses: actions/setup-node@v7 + if: steps.check_inputs.outputs.contract == 'true' + with: + node-version: '24.21.0' + cache: 'pnpm' + + - run: pnpm install --frozen-lockfile + if: steps.check_inputs.outputs.contract == 'true' + + - name: Provision sidecar placeholders for tauri-build + if: steps.check_inputs.outputs.contract == 'true' + run: | + mkdir -p src-tauri/sidecar src-tauri/resources dist + touch dist/index.html + for bin in clash-verge-service clash-verge-service-install clash-verge-service-uninstall verge-mihomo verge-mihomo-alpha; do + touch "src-tauri/sidecar/${bin}-x86_64-unknown-linux-gnu" + done + + - name: Regenerate contract and require empty diff + if: steps.check_inputs.outputs.contract == 'true' + run: | + pnpm gen:contract + git diff --exit-code -- src/services/contract.ts diff --git a/knip.json b/knip.json index 99ea67020..9be0c22de 100644 --- a/knip.json +++ b/knip.json @@ -17,7 +17,8 @@ "ignoreBinaries": ["rustc", "cc"], "ignoreDependencies": ["lint-staged"], "ignoreIssues": { - "src/types/generated/**": ["exports", "types"] + "src/types/generated/**": ["exports", "types"], + "src/services/contract.ts": ["exports", "types"] }, "cycles": { "dynamicImports": true diff --git a/package.json b/package.json index bb8355a8a..cdbc8772c 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "i18n:check": "node scripts/cleanup-unused-i18n.mjs", "i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply", "i18n:types": "node scripts/generate-i18n-keys.mjs", + "gen:contract": "node scripts/gen-contract.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/gen-contract.mjs b/scripts/gen-contract.mjs new file mode 100644 index 000000000..c2afba095 --- /dev/null +++ b/scripts/gen-contract.mjs @@ -0,0 +1,91 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +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( + 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}`) + } + 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}`) +} + +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/src/bin/frontend-contract.rs b/src-tauri/src/bin/frontend-contract.rs new file mode 100644 index 000000000..2ce0ecede --- /dev/null +++ b/src-tauri/src/bin/frontend-contract.rs @@ -0,0 +1,8 @@ +//! 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()); +} diff --git a/src-tauri/src/core/notify/mod.rs b/src-tauri/src/core/notify/mod.rs index cc59f0b36..e8cc817b4 100644 --- a/src-tauri/src/core/notify/mod.rs +++ b/src-tauri/src/core/notify/mod.rs @@ -3,6 +3,7 @@ pub use announce::{Refresh, after_commit, announce}; pub mod desktop; pub mod handle; pub mod notification; +pub use notification::frontend_wire_contract; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] pub enum NoticeStatus { @@ -106,55 +107,62 @@ pub enum NoticeStatus { SetConfigOk, } +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. + pub const ALL: [Self; 41] = [ + Self::Info, + Self::ImportSubUrlOk, + Self::ImportSubUrlError, + Self::SetConfigError, + Self::CoreStartError, + Self::ServiceCoreRepairRequired, + Self::ServiceCoreAppDataNotOwned, + Self::ServiceCoreSidecarFallback, + Self::DnsOverrideAutoDisabled, + Self::EnhanceDiscardedKeys, + Self::TunModeAutoDisabled, + Self::TunModeAutoDisableFailed, + Self::AppRestartCoreStopFailed, + Self::AppQuitCoreStopFailed, + Self::UpdateWithClashProxy, + Self::UpdateFailedEvenWithClash, + Self::ReactivateProfilesError, + Self::UpdateFailed, + Self::ConfigValidateBootError, + Self::ConfigValidateError, + Self::ConfigValidateProcessTerminated, + Self::ConfigValidateScriptError, + Self::ConfigValidateScriptSyntaxError, + Self::ConfigValidateScriptMissingMain, + Self::ConfigValidateFileNotFound, + Self::ConfigValidateYamlSyntaxError, + Self::ConfigValidateYamlReadError, + Self::ConfigValidateYamlMappingError, + Self::ConfigValidateMergeSyntaxError, + Self::ConfigValidateMergeMappingError, + Self::ConfigCoreChangeSuccess, + Self::ConfigCoreChangeError, + Self::MixedPortFallback, + Self::MixedPortFallbackError, + Self::ConfigValidateCoreChange, + Self::ConfigValidateStdoutError, + Self::ConfigValidateYamlKeyError, + Self::ConfigValidateYamlError, + Self::ConfigValidateMergeKeyError, + Self::ConfigValidateMergeError, + Self::SetConfigOk, + ]; +} + #[cfg(test)] mod tests { use super::NoticeStatus; #[test] fn notice_status_wire_contract() -> Result<(), serde_json::Error> { - let statuses = [ - NoticeStatus::Info, - NoticeStatus::ImportSubUrlOk, - NoticeStatus::ImportSubUrlError, - NoticeStatus::SetConfigError, - NoticeStatus::CoreStartError, - NoticeStatus::ServiceCoreRepairRequired, - NoticeStatus::ServiceCoreAppDataNotOwned, - NoticeStatus::ServiceCoreSidecarFallback, - NoticeStatus::DnsOverrideAutoDisabled, - NoticeStatus::EnhanceDiscardedKeys, - NoticeStatus::TunModeAutoDisabled, - NoticeStatus::TunModeAutoDisableFailed, - NoticeStatus::AppRestartCoreStopFailed, - NoticeStatus::AppQuitCoreStopFailed, - NoticeStatus::UpdateWithClashProxy, - NoticeStatus::UpdateFailedEvenWithClash, - NoticeStatus::ReactivateProfilesError, - NoticeStatus::UpdateFailed, - NoticeStatus::ConfigValidateBootError, - NoticeStatus::ConfigValidateError, - NoticeStatus::ConfigValidateProcessTerminated, - NoticeStatus::ConfigValidateScriptError, - NoticeStatus::ConfigValidateScriptSyntaxError, - NoticeStatus::ConfigValidateScriptMissingMain, - NoticeStatus::ConfigValidateFileNotFound, - NoticeStatus::ConfigValidateYamlSyntaxError, - NoticeStatus::ConfigValidateYamlReadError, - NoticeStatus::ConfigValidateYamlMappingError, - NoticeStatus::ConfigValidateMergeSyntaxError, - NoticeStatus::ConfigValidateMergeMappingError, - NoticeStatus::ConfigCoreChangeSuccess, - NoticeStatus::ConfigCoreChangeError, - NoticeStatus::MixedPortFallback, - NoticeStatus::MixedPortFallbackError, - NoticeStatus::ConfigValidateCoreChange, - NoticeStatus::ConfigValidateStdoutError, - NoticeStatus::ConfigValidateYamlKeyError, - NoticeStatus::ConfigValidateYamlError, - NoticeStatus::ConfigValidateMergeKeyError, - NoticeStatus::ConfigValidateMergeError, - NoticeStatus::SetConfigOk, - ]; + let statuses = NoticeStatus::ALL; let expected = [ "info", "import_sub_url::ok", diff --git a/src-tauri/src/core/notify/notification.rs b/src-tauri/src/core/notify/notification.rs index 3bc73f40d..98b17f950 100644 --- a/src-tauri/src/core/notify/notification.rs +++ b/src-tauri/src/core/notify/notification.rs @@ -12,7 +12,7 @@ use std::{ }; use tauri::{AppHandle, Emitter as _, Manager as _, WebviewWindow}; -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum FrontendEvent<'a> { RefreshClash, RefreshVerge, @@ -324,67 +324,7 @@ mod wire_tests { #[test] fn frontend_event_wire_contract() -> Result<(), serde_json::Error> { let uid = String::from("profile"); - let events = [ - ( - FrontendEvent::RefreshClash, - "verge://refresh-clash-config", - json!("yes"), - ), - ( - FrontendEvent::RefreshVerge, - "verge://refresh-verge-config", - json!("yes"), - ), - (FrontendEvent::RefreshProfiles, "verge://refresh-profiles", json!("yes")), - ( - FrontendEvent::RefreshProxyConfig, - "verge://refresh-proxy-config", - json!(null), - ), - ( - FrontendEvent::NoticeMessage { - status: NoticeStatus::SetConfigOk, - message: Arc::from("ok"), - }, - "verge://notice-message", - json!(["set_config::ok", "ok"]), - ), - ( - FrontendEvent::TimerUpdated { profile_index: &uid }, - "verge://timer-updated", - json!("profile"), - ), - ( - FrontendEvent::RunStateChanged { - state: json!({"running": true}), - }, - "verge://run-state-changed", - json!({"running": true}), - ), - ( - FrontendEvent::PendingFailuresChanged, - "verge://pending-failures-changed", - json!(null), - ), - ( - FrontendEvent::ProfileChanged { - current_profile_id: &uid, - }, - "profile-changed", - json!("profile"), - ), - ( - FrontendEvent::ProfileUpdateStarted { uid: &uid }, - "profile-update-started", - json!({"uid": "profile"}), - ), - ( - FrontendEvent::ProfileUpdateCompleted { uid: &uid }, - "profile-update-completed", - json!({"uid": "profile"}), - ), - ]; - for (event, name, payload) in events { + for (event, name, payload) in super::sample_frontend_events(&uid) { let (actual_name, actual_payload) = NotificationSystem::serialize_event(event); assert_eq!(actual_name, name); assert_eq!(actual_payload?, payload); @@ -392,3 +332,93 @@ mod wire_tests { Ok(()) } } + +/// 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. +fn sample_frontend_events(uid: &String) -> Vec<(FrontendEvent<'_>, &'static str, serde_json::Value)> { + 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::RefreshProxyConfig, + "verge://refresh-proxy-config", + json!(null), + ), + ( + FrontendEvent::NoticeMessage { + status: NoticeStatus::SetConfigOk, + message: Arc::from("ok"), + }, + "verge://notice-message", + json!(["set_config::ok", "ok"]), + ), + ( + FrontendEvent::TimerUpdated { profile_index: uid }, + "verge://timer-updated", + json!("profile"), + ), + ( + FrontendEvent::RunStateChanged { + state: json!({"running": true}), + }, + "verge://run-state-changed", + json!({"running": true}), + ), + ( + FrontendEvent::PendingFailuresChanged, + "verge://pending-failures-changed", + json!(null), + ), + ( + FrontendEvent::ProfileChanged { + current_profile_id: uid, + }, + "profile-changed", + json!("profile"), + ), + ( + FrontendEvent::ProfileUpdateStarted { uid }, + "profile-update-started", + json!({"uid": "profile"}), + ), + ( + FrontendEvent::ProfileUpdateCompleted { uid }, + "profile-update-completed", + json!({"uid": "profile"}), + ), + ] +} + +/// 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() + .map(|status| serde_json::to_value(status).expect("NoticeStatus serializes")) + .collect(); + json!({ "version": 1, "events": events, "noticeStatuses": statuses }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ff27a1815..2dd0c4ae1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,10 @@ use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_deep_link::DeepLinkExt as _; pub static APP_HANDLE: OnceCell = OnceCell::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::*; diff --git a/src/services/contract.ts b/src/services/contract.ts new file mode 100644 index 000000000..84b236b23 --- /dev/null +++ b/src/services/contract.ts @@ -0,0 +1,63 @@ +// @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' + | 'config_validate::core_change' + | 'config_validate::stdout_error' + | 'config_validate::yaml_key_error' + | 'config_validate::yaml_error' + | 'config_validate::merge_key_error' + | 'config_validate::merge_error' + | 'set_config::ok' + +/** 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 } +} diff --git a/src/services/events.ts b/src/services/events.ts index 6228d35ca..29b884165 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -1,21 +1,9 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event' -import type { RunState } from './cmds' +import type { VergeEventPayloads } from './contract' -/** Centralizes frontend event names and payload types for the unchecked Rust IPC seam. */ -interface VergeEvents { - 'verge://refresh-clash-config': string - 'verge://refresh-verge-config': string - 'verge://refresh-profiles': string - 'verge://refresh-proxy-config': null - /** A backend message for the user: `[status, message]`. */ - 'verge://notice-message': [string, 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 } +/** Generated wire contract plus the frontend-internal test-all event. */ +interface VergeEvents extends VergeEventPayloads { 'verge://test-all': null }