From 42b85cc8fafc4eeeaeb7e705dc98a7fb0ea7c756 Mon Sep 17 00:00:00 2001 From: Dokril Date: Wed, 8 Jul 2026 20:31:21 +0300 Subject: [PATCH] Add device HWID support to sing-box subscriptions --- src-tauri/src/commands.rs | 77 +++++++++++++++- src-tauri/src/main.rs | 1 + src-tauri/src/models.rs | 31 +++++++ src-tauri/src/subscription.rs | 63 ++++++++++++- src-tauri/tests/singbox_adapter_tests.rs | 1 + src-tauri/tests/singbox_command_tests.rs | 109 ++++++++++++++++++++++- src-tauri/tests/storage_tests.rs | 6 ++ src-tauri/tests/subscription_tests.rs | 54 ++++++++++- src/api/tauriCommands.ts | 13 ++- src/app/App.tsx | 106 +++++++++++++++++++++- src/domain/types.ts | 2 + src/styles/app.css | 6 +- 12 files changed, 450 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cb0a24c..f5f57a6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -232,6 +232,8 @@ pub struct LocalSingBoxStatusResponse { pub struct LocalSingBoxConfigDto { pub subscription_display_url: Option, pub has_subscription: bool, + pub device_hwid_display: Option, + pub has_device_hwid: bool, pub selected_server_tag: Option, pub listen_host: String, pub listen_port: u16, @@ -262,6 +264,15 @@ pub struct SubscriptionServerDto { #[serde(rename_all = "camelCase")] pub struct SaveSingBoxSubscriptionInputDto { pub subscription_url: String, + #[serde(default)] + pub device_hwid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveSingBoxDeviceHwidInputDto { + #[serde(default)] + pub device_hwid: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -491,6 +502,7 @@ pub trait SubscriptionFetcher { fn fetch_subscription( &self, url: &str, + identity: &subscription::SubscriptionFetchIdentity, ) -> Result; } @@ -500,8 +512,9 @@ impl SubscriptionFetcher for SystemSubscriptionFetcher { fn fetch_subscription( &self, url: &str, + identity: &subscription::SubscriptionFetchIdentity, ) -> Result { - subscription::fetch_subscription(url) + subscription::fetch_subscription_with_identity(url, identity) } } @@ -671,6 +684,14 @@ pub fn save_singbox_subscription( save_singbox_subscription_to_storage(&state.storage(), input, &SystemClock) } +#[tauri::command] +pub fn save_singbox_device_hwid( + state: tauri::State<'_, CommandState>, + input: SaveSingBoxDeviceHwidInputDto, +) -> Result { + save_singbox_device_hwid_to_storage(&state.storage(), input, &SystemClock) +} + #[tauri::command] pub async fn fetch_singbox_subscription( state: tauri::State<'_, CommandState>, @@ -1411,6 +1432,9 @@ pub fn save_singbox_subscription_to_storage( let mut config = storage.read_local_singbox_config().map_err(storage_error)?; config.subscription_url = Some(subscription_url); + if let Some(device_hwid) = input.device_hwid { + config.device_hwid = normalize_device_hwid(&device_hwid)?; + } config.updated_at = Some(clock.now()); storage .write_local_singbox_config(&config) @@ -1438,8 +1462,10 @@ pub fn fetch_singbox_subscription_with_fetcher( ) })?; + let identity = + subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref()); let cache = fetcher - .fetch_subscription(&subscription_url) + .fetch_subscription(&subscription_url, &identity) .map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?; let selected_tag = config .selected_server_tag @@ -1475,6 +1501,7 @@ pub fn forget_singbox_subscription_in_storage( ) -> Result { let mut config = storage.read_local_singbox_config().map_err(storage_error)?; config.subscription_url = None; + config.device_hwid = None; config.selected_server_tag = None; config.updated_at = Some(clock.now()); storage @@ -1487,6 +1514,24 @@ pub fn forget_singbox_subscription_in_storage( read_singbox_status(storage) } +pub fn save_singbox_device_hwid_to_storage( + storage: &JsonStorage, + input: SaveSingBoxDeviceHwidInputDto, + clock: &impl Clock, +) -> Result { + let mut config = storage.read_local_singbox_config().map_err(storage_error)?; + config.device_hwid = match input.device_hwid { + Some(device_hwid) => normalize_device_hwid(&device_hwid)?, + None => None, + }; + config.updated_at = Some(clock.now()); + storage + .write_local_singbox_config(&config) + .map_err(storage_error)?; + + read_singbox_status(storage) +} + pub fn select_singbox_server_in_storage( storage: &JsonStorage, input: SelectSingBoxServerInputDto, @@ -1689,6 +1734,29 @@ fn validate_subscription_url(subscription_url: &str) -> Result<(), CommandError> Ok(()) } +fn normalize_device_hwid(raw_hwid: &str) -> Result, CommandError> { + let device_hwid = raw_hwid.trim(); + if device_hwid.is_empty() { + return Ok(None); + } + + if device_hwid.chars().count() > 256 { + return Err(CommandError::new( + "singbox_device_hwid_invalid", + "HWID устройства должен быть не длиннее 256 символов.", + )); + } + + if !device_hwid.chars().all(|ch| ch.is_ascii_graphic()) { + return Err(CommandError::new( + "singbox_device_hwid_invalid", + "HWID устройства может содержать только печатные ASCII-символы без пробелов.", + )); + } + + Ok(Some(device_hwid.to_string())) +} + fn ping_subscription_server(server: &SubscriptionServer) -> PingServerResponse { ping_endpoint(&server.tag, &server.server, server.server_port) } @@ -4166,6 +4234,11 @@ impl From<&LocalSingBoxConfig> for LocalSingBoxConfigDto { .subscription_url .as_deref() .is_some_and(|value| !value.trim().is_empty()), + device_hwid_display: config.device_hwid_display(), + has_device_hwid: config + .device_hwid + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), selected_server_tag: config.selected_server_tag.clone(), listen_host: config.listen_host.clone(), listen_port: config.listen_port, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aff92f6..4d9f0ad 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -51,6 +51,7 @@ fn main() { commands::get_singbox_setup_status, commands::resolve_profile_preview, commands::save_singbox_subscription, + commands::save_singbox_device_hwid, commands::fetch_singbox_subscription, commands::forget_singbox_subscription, commands::select_singbox_server, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 8c0ea6e..5166242 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -138,6 +138,8 @@ pub struct LocalSingBoxConfig { #[serde(default)] pub subscription_url: Option, #[serde(default)] + pub device_hwid: Option, + #[serde(default)] pub selected_server_tag: Option, #[serde(default = "default_local_singbox_listen_host")] pub listen_host: String, @@ -157,12 +159,17 @@ impl LocalSingBoxConfig { .as_deref() .map(redact_subscription_url) } + + pub fn device_hwid_display(&self) -> Option { + self.device_hwid.as_deref().map(redact_device_hwid) + } } impl Default for LocalSingBoxConfig { fn default() -> Self { Self { subscription_url: None, + device_hwid: None, selected_server_tag: None, listen_host: default_local_singbox_listen_host(), listen_port: default_local_singbox_listen_port(), @@ -271,3 +278,27 @@ pub fn redact_subscription_url(raw_url: &str) -> String { } } } + +pub fn redact_device_hwid(raw_hwid: &str) -> String { + let trimmed = raw_hwid.trim(); + if trimmed.is_empty() { + return String::new(); + } + + let length = trimmed.chars().count(); + if length <= 8 { + return "***".to_string(); + } + + let prefix = trimmed.chars().take(4).collect::(); + let suffix = trimmed + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + + format!("{prefix}...{suffix}") +} diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs index 6de1eb8..b1e6bcd 100644 --- a/src-tauri/src/subscription.rs +++ b/src-tauri/src/subscription.rs @@ -5,6 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use url::Url; const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"]; +const DEFAULT_APP_NAME: &str = "ProxyWarden"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SubscriptionError { @@ -33,6 +34,41 @@ pub struct ParsedSubscription { pub servers: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptionFetchIdentity { + pub device_hwid: Option, + pub app_name: String, + pub user_agent: String, + pub device_os: String, + pub device_model: String, +} + +impl SubscriptionFetchIdentity { + pub fn with_device_hwid(device_hwid: Option<&str>) -> Self { + Self { + device_hwid: device_hwid + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string), + ..Self::default() + } + } +} + +impl Default for SubscriptionFetchIdentity { + fn default() -> Self { + let device_os = std::env::consts::OS.to_string(); + + Self { + device_hwid: None, + app_name: DEFAULT_APP_NAME.to_string(), + user_agent: format!("{DEFAULT_APP_NAME}/{device_os}"), + device_os, + device_model: DEFAULT_APP_NAME.to_string(), + } + } +} + pub fn parse_subscription_body(body: &str) -> Result { let config = match serde_json::from_str::(body) { Ok(value) => value, @@ -66,6 +102,13 @@ pub fn parse_user_info(header_value: Option<&str>) -> Map { } pub fn fetch_subscription(url: &str) -> Result { + fetch_subscription_with_identity(url, &SubscriptionFetchIdentity::default()) +} + +pub fn fetch_subscription_with_identity( + url: &str, + identity: &SubscriptionFetchIdentity, +) -> Result { let parsed_url = Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?; if !matches!(parsed_url.scheme(), "http" | "https") { @@ -74,11 +117,23 @@ pub fn fetch_subscription(url: &str) -> Result LocalSingBoxConfig { LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/list".to_string()), + device_hwid: None, selected_server_tag: Some(selected_server_tag.to_string()), listen_host: "127.0.0.1".to_string(), listen_port: 1080, diff --git a/src-tauri/tests/singbox_command_tests.rs b/src-tauri/tests/singbox_command_tests.rs index 8de747a..aea49c9 100644 --- a/src-tauri/tests/singbox_command_tests.rs +++ b/src-tauri/tests/singbox_command_tests.rs @@ -25,9 +25,10 @@ mod validation; use commands::{ fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage, - generate_singbox_config_with_services, save_singbox_subscription_to_storage, - select_singbox_server_in_storage, Clock, SaveSingBoxSubscriptionInputDto, - SelectSingBoxServerInputDto, SubscriptionFetcher, + generate_singbox_config_with_services, save_singbox_device_hwid_to_storage, + save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock, + SaveSingBoxDeviceHwidInputDto, SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto, + SubscriptionFetcher, }; use models::{ ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache, @@ -49,6 +50,7 @@ fn saves_subscription_url_without_exposing_secret_query() { &storage, SaveSingBoxSubscriptionInputDto { subscription_url: " https://sub.example.test/path?token=secret ".to_string(), + device_hwid: Some(" hwid-1234567890 ".to_string()), }, &FixedClock, ) @@ -61,11 +63,58 @@ fn saves_subscription_url_without_exposing_secret_query() { config.subscription_url, Some("https://sub.example.test/path?token=secret".to_string()) ); + assert_eq!(config.device_hwid, Some("hwid-1234567890".to_string())); assert!(status.config.has_subscription); + assert!(status.config.has_device_hwid); assert_eq!( status.config.subscription_display_url, Some("https://sub.example.test/...".to_string()) ); + assert_eq!( + status.config.device_hwid_display, + Some("hwid...7890".to_string()) + ); + + cleanup(&root); +} + +#[test] +fn saves_and_clears_device_hwid_without_exposing_raw_value() { + let root = test_root("save-hwid"); + let storage = JsonStorage::new(root.clone()); + + let status = save_singbox_device_hwid_to_storage( + &storage, + SaveSingBoxDeviceHwidInputDto { + device_hwid: Some(" device-abcdef1234 ".to_string()), + }, + &FixedClock, + ) + .expect("device hwid should be saved"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + + assert_eq!(config.device_hwid, Some("device-abcdef1234".to_string())); + assert!(status.config.has_device_hwid); + assert_eq!( + status.config.device_hwid_display, + Some("devi...1234".to_string()) + ); + + let status = save_singbox_device_hwid_to_storage( + &storage, + SaveSingBoxDeviceHwidInputDto { device_hwid: None }, + &FixedClock, + ) + .expect("device hwid should be cleared"); + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + + assert_eq!(config.device_hwid, None); + assert!(!status.config.has_device_hwid); + assert_eq!(status.config.device_hwid_display, None); cleanup(&root); } @@ -79,6 +128,7 @@ fn rejects_non_http_subscription_url() { &storage, SaveSingBoxSubscriptionInputDto { subscription_url: "file:///C:/sub.txt".to_string(), + device_hwid: None, }, &FixedClock, ) @@ -97,6 +147,7 @@ fn fetches_subscription_cache_and_selects_first_server() { &storage, SaveSingBoxSubscriptionInputDto { subscription_url: "https://sub.example.test/path?token=secret".to_string(), + device_hwid: None, }, &FixedClock, ) @@ -127,6 +178,33 @@ fn fetches_subscription_cache_and_selects_first_server() { cleanup(&root); } +#[test] +fn fetches_subscription_with_saved_device_hwid() { + let root = test_root("fetch-subscription-hwid"); + let storage = JsonStorage::new(root.clone()); + save_singbox_subscription_to_storage( + &storage, + SaveSingBoxSubscriptionInputDto { + subscription_url: "https://sub.example.test/path?token=secret".to_string(), + device_hwid: Some("hwid-abcdef1234".to_string()), + }, + &FixedClock, + ) + .expect("save subscription URL and HWID"); + + fetch_singbox_subscription_with_fetcher( + &storage, + &HwidAssertingFetcher { + cache: sample_cache(), + expected_hwid: Some("hwid-abcdef1234"), + }, + &FixedClock, + ) + .expect("fetch subscription through mock"); + + cleanup(&root); +} + #[test] fn selects_server_from_cached_subscription() { let root = test_root("select-server"); @@ -193,6 +271,7 @@ fn generate_writes_config_and_local_singbox_target() { storage .write_local_singbox_config(&LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/path".to_string()), + device_hwid: Some("hwid-abcdef1234".to_string()), selected_server_tag: Some("nl-1".to_string()), ..LocalSingBoxConfig::default() }) @@ -259,6 +338,7 @@ fn forget_subscription_clears_url_selection_and_cache() { storage .write_local_singbox_config(&LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/path".to_string()), + device_hwid: Some("hwid-abcdef1234".to_string()), selected_server_tag: Some("nl-1".to_string()), ..LocalSingBoxConfig::default() }) @@ -278,6 +358,7 @@ fn forget_subscription_clears_url_selection_and_cache() { assert!(!status.config.has_subscription); assert_eq!(config.subscription_url, None); + assert_eq!(config.device_hwid, None); assert_eq!(config.selected_server_tag, None); assert_eq!(cache, None); @@ -290,12 +371,34 @@ impl SubscriptionFetcher for MockFetcher { fn fetch_subscription( &self, url: &str, + _identity: &subscription::SubscriptionFetchIdentity, ) -> Result { assert_eq!(url, "https://sub.example.test/path?token=secret"); Ok(self.0.clone()) } } +struct HwidAssertingFetcher { + cache: SubscriptionCache, + expected_hwid: Option<&'static str>, +} + +impl SubscriptionFetcher for HwidAssertingFetcher { + fn fetch_subscription( + &self, + url: &str, + identity: &subscription::SubscriptionFetchIdentity, + ) -> Result { + assert_eq!(url, "https://sub.example.test/path?token=secret"); + assert_eq!(identity.device_hwid.as_deref(), self.expected_hwid); + assert_eq!(identity.app_name, "ProxyWarden"); + assert!(identity.user_agent.starts_with("ProxyWarden/")); + assert_eq!(identity.device_os, std::env::consts::OS); + assert_eq!(identity.device_model, "ProxyWarden"); + Ok(self.cache.clone()) + } +} + struct MockChecker; impl SingBoxConfigChecker for MockChecker { diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index 9f8de0b..4eaec68 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -59,6 +59,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() { let storage = JsonStorage::new(root.clone()); let config = LocalSingBoxConfig { subscription_url: Some("https://sub.example.test/path?token=secret".to_string()), + device_hwid: Some("hwid-abcdef1234".to_string()), selected_server_tag: Some("nl-1".to_string()), listen_host: "127.0.0.1".to_string(), listen_port: 1080, @@ -91,6 +92,10 @@ fn roundtrips_local_singbox_config_and_subscription_cache() { config.subscription_display_url(), Some("https://sub.example.test/...".to_string()) ); + assert_eq!( + config.device_hwid_display(), + Some("hwid...1234".to_string()) + ); cleanup(&root); } @@ -104,6 +109,7 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() { .expect("read default local sing-box config"); assert_eq!(config.subscription_url, None); + assert_eq!(config.device_hwid, None); assert_eq!(config.selected_server_tag, None); assert_eq!(config.listen_host, "127.0.0.1"); assert_eq!(config.listen_port, 1080); diff --git a/src-tauri/tests/subscription_tests.rs b/src-tauri/tests/subscription_tests.rs index 3ee69b5..1fbab95 100644 --- a/src-tauri/tests/subscription_tests.rs +++ b/src-tauri/tests/subscription_tests.rs @@ -5,7 +5,10 @@ mod subscription; use base64::{engine::general_purpose, Engine}; use models::redact_subscription_url; -use subscription::{parse_subscription_body, parse_user_info}; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::time::Duration; +use subscription::{parse_subscription_body, parse_user_info, SubscriptionFetchIdentity}; #[test] fn parses_singbox_json_config_servers() { @@ -78,6 +81,55 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() { assert!(unsupported.message.contains("http or https")); } +#[test] +fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener"); + let url = format!("http://{}/subscription", listener.local_addr().unwrap()); + let request_thread = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept test request"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set read timeout"); + + let mut request = Vec::new(); + let mut buffer = [0_u8; 512]; + loop { + let bytes_read = stream.read(&mut buffer).expect("read request"); + if bytes_read == 0 { + break; + } + request.extend_from_slice(&buffer[..bytes_read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + let body = r#"{"outbounds":[{"type":"vless","tag":"nl-1","server":"nl.example.test","server_port":443}]}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + + String::from_utf8_lossy(&request).to_ascii_lowercase() + }); + + let identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123")); + let cache = subscription::fetch_subscription_with_identity(&url, &identity) + .expect("fetch subscription through local test server"); + let request = request_thread.join().expect("request thread"); + + assert_eq!(cache.servers[0].tag, "nl-1"); + assert!(request.contains("x-hwid: hwid-abc123")); + assert!(request.contains("user-agent: proxywarden/")); + assert!(request.contains("x-app-name: proxywarden")); + assert!(request.contains("x-device-os:")); + assert!(request.contains("x-device-model: proxywarden")); +} + #[test] fn redacts_subscription_url_for_display() { assert_eq!( diff --git a/src/api/tauriCommands.ts b/src/api/tauriCommands.ts index 15829a3..f80f11f 100644 --- a/src/api/tauriCommands.ts +++ b/src/api/tauriCommands.ts @@ -199,9 +199,18 @@ export function getSingBoxSetupStatus(): Promise { return invoke('get_singbox_setup_status'); } -export function saveSingBoxSubscription(subscriptionUrl: string): Promise { +export function saveSingBoxSubscription( + subscriptionUrl: string, + deviceHwid?: string, +): Promise { return invoke('save_singbox_subscription', { - input: { subscriptionUrl }, + input: { subscriptionUrl, deviceHwid }, + }); +} + +export function saveSingBoxDeviceHwid(deviceHwid?: string): Promise { + return invoke('save_singbox_device_hwid', { + input: { deviceHwid }, }); } diff --git a/src/app/App.tsx b/src/app/App.tsx index d059212..4d22065 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; -import { Cpu, FileCode2, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; +import { Cpu, FileCode2, Fingerprint, FolderOpen, Gauge, Link2, ShieldAlert, Trash2, Wand2 } from 'lucide-react'; import { applyProfiles, fetchSingBoxSubscription, @@ -19,6 +19,7 @@ import { pingSingBoxServer, restartAsAdmin, saveProfile, + saveSingBoxDeviceHwid, saveSingBoxSubscription, saveTarget, selectSingBoxServer, @@ -44,7 +45,7 @@ import { serviceControlState } from './viewModel'; type DraftItemType = Extract; type ProxiFyreAction = 'start' | 'stop' | 'restart' | 'install' | 'uninstall'; -type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'generate' | 'ping'; +type SingBoxAction = 'start' | 'stop' | 'install' | 'uninstall' | 'fetch' | 'forget' | 'identity' | 'generate' | 'ping'; type RouteMode = 'external' | 'local-singbox'; type ServiceVisualState = 'active' | 'settling' | null; type PanelId = 'summary' | 'proxifyre' | 'proxy'; @@ -184,6 +185,7 @@ export function App() { const [singBoxStatus, setSingBoxStatus] = useState(null); const [singBoxSetupStatus, setSingBoxSetupStatus] = useState(null); const [subscriptionInput, setSubscriptionInput] = useState(''); + const [deviceHwidInput, setDeviceHwidInput] = useState(''); const [serverPings, setServerPings] = useState>({}); const [proxyCheck, setProxyCheck] = useState(null); const [adminStatus, setAdminStatus] = useState(null); @@ -711,6 +713,7 @@ export function App() { async function syncSingBoxSubscription() { const subscriptionUrl = subscriptionInput.trim(); + const deviceHwid = deviceHwidInput.trim(); if (!subscriptionUrl && !singBoxStatus?.config.hasSubscription) { showNotice({ kind: 'error', @@ -723,12 +726,15 @@ export function App() { setSingBoxAction('fetch'); try { if (subscriptionUrl) { - await saveSingBoxSubscription(subscriptionUrl); + await saveSingBoxSubscription(subscriptionUrl, deviceHwid || undefined); + } else if (deviceHwid) { + await saveSingBoxDeviceHwid(deviceHwid); } const status = await fetchSingBoxSubscription(); setSingBoxStatus(status); setComponents((current) => upsertComponent(current, status.component)); setSubscriptionInput(''); + setDeviceHwidInput(''); setServerPings({}); setProxyCheck(null); showNotice({ @@ -747,6 +753,64 @@ export function App() { } } + async function saveSingBoxDeviceHwidData() { + const deviceHwid = deviceHwidInput.trim(); + if (!deviceHwid) { + showNotice({ + kind: 'error', + title: 'HWID не указан', + text: 'Вставь hard ID устройства для заголовка X-Hwid.', + }); + return; + } + + setSingBoxAction('identity'); + try { + const status = await saveSingBoxDeviceHwid(deviceHwid); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setDeviceHwidInput(''); + showNotice({ + kind: 'success', + title: 'HWID сохранен', + text: status.config.deviceHwidDisplay + ? `Для подписки будет отправляться X-Hwid: ${status.config.deviceHwidDisplay}` + : 'Для подписки будет отправляться X-Hwid.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'HWID не сохранен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + + async function forgetSingBoxDeviceHwidData() { + setSingBoxAction('identity'); + try { + const status = await saveSingBoxDeviceHwid(undefined); + setSingBoxStatus(status); + setComponents((current) => upsertComponent(current, status.component)); + setDeviceHwidInput(''); + showNotice({ + kind: 'info', + title: 'HWID очищен', + text: 'Заголовок X-Hwid больше не будет отправляться при загрузке подписки.', + }); + } catch (error) { + showNotice({ + kind: 'error', + title: 'HWID не очищен', + text: errorMessage(error), + }); + } finally { + setSingBoxAction(null); + } + } + async function forgetSingBoxSubscriptionData() { setSingBoxAction('forget'); setIsSingBoxMenuOpen(false); @@ -759,7 +823,7 @@ export function App() { showNotice({ kind: 'info', title: 'Подписка очищена', - text: 'Ссылка, cache и выбранный сервер Local sing-box удалены.', + text: 'Ссылка, HWID, cache и выбранный сервер Local sing-box удалены.', }); } catch (error) { showNotice({ @@ -1413,6 +1477,7 @@ export function App() { loading={singBoxAction === 'fetch'} loadingLabel="Загружаю" variant="neutral" + disabled={Boolean(singBoxAction)} > {subscriptionInput.trim() || !singBoxStatus?.config.hasSubscription ? 'Загрузить' : 'Обновить'} @@ -1427,6 +1492,38 @@ export function App() { /> +
+ + setDeviceHwidInput(event.target.value)} + placeholder={singBoxStatus?.config.deviceHwidDisplay ?? 'X-Hwid hard ID'} + spellCheck={false} + aria-label="Hard ID устройства для X-Hwid" + /> + + void forgetSingBoxDeviceHwidData()} + disabled={Boolean(singBoxAction) || !singBoxStatus?.config.hasDeviceHwid} + label="Очистить hard ID устройства" + tooltip="Очистить HWID" + icon={} + /> +
+
Серверы подписки
@@ -2565,6 +2662,7 @@ function singBoxDetailLines( `Файл: ${component?.path ?? 'не найден'}`, `Конфиг: ${status?.generatedConfigPath ?? 'не создан'}`, `Подписка: ${status?.config.subscriptionDisplayUrl ?? (status?.config.hasSubscription ? 'сохранена' : 'не загружена')}`, + `X-Hwid: ${status?.config.deviceHwidDisplay ?? (status?.config.hasDeviceHwid ? 'сохранен' : 'не задан')}`, `Состав: ${setupDetails}`, ]; } diff --git a/src/domain/types.ts b/src/domain/types.ts index c0a446e..41123e7 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -71,6 +71,8 @@ export interface ComponentStatus { export interface LocalSingBoxConfig { subscriptionDisplayUrl?: string; hasSubscription: boolean; + deviceHwidDisplay?: string; + hasDeviceHwid: boolean; selectedServerTag?: string; listenHost: string; listenPort: number; diff --git a/src/styles/app.css b/src/styles/app.css index f9f97b9..fb9b98d 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1896,7 +1896,7 @@ button.summary-card:hover { white-space: nowrap; } -.subscription-line .icon-command { +.subscription-line .ui-icon-button { display: grid; place-items: center; width: 42px; @@ -3153,11 +3153,11 @@ button.summary-card:hover { grid-column: 2 / 4; } - .subscription-line button:not(.icon-command) { + .subscription-line button:not(.ui-icon-button) { grid-column: 1 / 3; } - .subscription-line .icon-command { + .subscription-line .ui-icon-button { grid-column: 3; width: 42px; }