use percent_encoding::percent_decode_str; use serde::{Deserialize, Serialize}; use url::Url; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; pub const DEFAULT_LOCAL_SINGBOX_SERVICE_NAME: &str = "ProxyWardenSingBox"; pub const DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT: &str = r"C:\Program Files\ProxyWarden\components\sing-box"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum Protocol { Tcp, Udp, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ProfileItemType { Process, Folder, Exe, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum TargetKind { Local, External, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ProxyProtocol { Socks5, Http, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ComponentId { ControlApp, Proxyfier, Singbox, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ComponentState { Installed, Missing, Stopped, Running, Error, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileItemInput { #[serde(rename = "type")] pub item_type: String, pub value: String, #[serde(default)] pub recursive: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProfileInput { pub id: Option, pub name: String, #[serde(default = "default_enabled")] pub enabled: bool, #[serde(default = "default_target_id")] pub target_id: String, #[serde(default = "default_protocols")] pub protocols: Vec, #[serde(default)] pub items: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ProfileItem { #[serde(rename = "type")] pub item_type: ProfileItemType, pub value: String, pub recursive: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Profile { pub id: String, pub name: String, pub enabled: bool, pub target_id: String, pub protocols: Vec, pub items: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TargetInput { pub id: Option, pub name: String, #[serde(default = "default_target_kind")] pub kind: String, #[serde(default = "default_proxy_protocol")] pub protocol: String, pub host: String, pub port: u32, #[serde(default)] pub requires_component: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Target { pub id: String, pub name: String, pub kind: TargetKind, pub protocol: ProxyProtocol, pub host: String, pub port: u16, pub requires_component: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ComponentStatus { pub id: ComponentId, pub name: String, pub state: ComponentState, pub installed: bool, pub running: bool, pub version: Option, pub path: Option, #[serde(default)] pub service_name: Option, #[serde(default)] pub service_status: Option, #[serde(default)] pub problems: Vec, #[serde(default)] pub actions: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LocalSingBoxConfig { #[serde(default)] pub subscription_url: Option, #[serde(default)] pub device_hwid: Option, #[serde(default)] pub selected_server_tag: Option, #[serde(default)] pub selected_server_id: Option, #[serde(default = "default_local_singbox_listen_host")] pub listen_host: String, #[serde(default = "default_local_singbox_listen_port")] pub listen_port: u16, #[serde(default = "default_local_singbox_service_name")] pub service_name: String, #[serde(default = "default_local_singbox_install_root", skip_serializing)] pub install_root: String, #[serde(default)] pub updated_at: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StorageMigrationOutcome { InitializedEmpty, AdoptedWithoutLegacyImport, ImportedLegacyConfig, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct StorageMeta { pub storage_schema_version: u32, pub outcome: StorageMigrationOutcome, pub migration_id: String, pub completed_at_epoch_seconds: u64, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ComponentLayoutMeta { pub component_layout_version: u32, pub verified_at_epoch_seconds: u64, } impl LocalSingBoxConfig { pub fn subscription_display_url(&self) -> Option { self.subscription_url .as_deref() .map(redact_subscription_url) } pub fn normalize_percent_encoded_tags(&mut self) { if let Some(selected_server_tag) = self.selected_server_tag.as_mut() { *selected_server_tag = decode_percent_encoded_utf8(selected_server_tag); } } } impl Default for LocalSingBoxConfig { fn default() -> Self { Self { subscription_url: None, device_hwid: None, selected_server_tag: None, selected_server_id: None, listen_host: default_local_singbox_listen_host(), listen_port: default_local_singbox_listen_port(), service_name: default_local_singbox_service_name(), install_root: default_local_singbox_install_root(), updated_at: None, } } } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SubscriptionCache { pub config: serde_json::Value, #[serde(default)] pub servers: Vec, #[serde(default)] pub user_info: serde_json::Map, pub fetched_at: String, } impl SubscriptionCache { pub fn normalize_percent_encoded_tags(&mut self) { for server in &mut self.servers { server.tag = decode_percent_encoded_utf8(&server.tag); server.ensure_id(); } // Outbound bytes define stable identity. Decode display labels only. } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SubscriptionServer { #[serde(default)] pub id: String, pub tag: String, #[serde(rename = "type")] pub server_type: String, pub server: String, pub server_port: u16, } impl SubscriptionServer { pub fn ensure_id(&mut self) { if self.id.trim().is_empty() { self.id = subscription_server_id( &self.server_type, &self.tag, &self.server, self.server_port, ); } } } pub fn subscription_server_id( server_type: &str, tag: &str, server: &str, server_port: u16, ) -> String { format!( "{}|{}|{}|{}", server_type.trim().to_ascii_lowercase(), tag.trim(), server.trim().to_ascii_lowercase(), server_port ) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ActivityEntry { pub id: String, pub at: String, pub level: ActivityLevel, pub title: String, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ActivityLevel { Info, Warning, Error, Success, } fn default_enabled() -> bool { true } fn default_target_id() -> String { "local-singbox".to_string() } fn default_protocols() -> Vec { vec!["TCP".to_string(), "UDP".to_string()] } fn default_target_kind() -> String { "external".to_string() } fn default_proxy_protocol() -> String { "socks5".to_string() } fn default_local_singbox_listen_host() -> String { DEFAULT_LOCAL_SINGBOX_LISTEN_HOST.to_string() } fn default_local_singbox_listen_port() -> u16 { DEFAULT_LOCAL_SINGBOX_LISTEN_PORT } fn default_local_singbox_service_name() -> String { DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string() } fn default_local_singbox_install_root() -> String { DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string() } pub fn redact_subscription_url(raw_url: &str) -> String { let trimmed = raw_url.trim(); if trimmed.is_empty() { return String::new(); } let Ok(parsed) = Url::parse(trimmed) else { return "***".to_string(); }; let host = parsed.host_str().unwrap_or("subscription"); let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_string() }; let port = parsed .port() .map(|port| format!(":{port}")) .unwrap_or_default(); format!("{}://{}{}/...", parsed.scheme(), host, port) } pub fn decode_percent_encoded_utf8(value: &str) -> String { percent_decode_str(value) .decode_utf8() .map(|decoded| decoded.into_owned()) .unwrap_or_else(|_| value.to_string()) }