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.
This commit is contained in:
Tunglies
2026-10-01 14:34:17 +08:00
committed by GitHub
parent 96d68b057a
commit 5bf0de0d0b
10 changed files with 390 additions and 121 deletions
+8
View File
@@ -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());
}
+51 -43
View File
@@ -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",
+92 -62
View File
@@ -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 })
}
+4
View File
@@ -25,6 +25,10 @@ use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_deep_link::DeepLinkExt as _;
pub static APP_HANDLE: OnceCell<AppHandle> = 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::*;