diff --git a/scripts/prepare-release.ps1 b/scripts/prepare-release.ps1 index e3dda5c..0758c3b 100644 --- a/scripts/prepare-release.ps1 +++ b/scripts/prepare-release.ps1 @@ -386,6 +386,25 @@ function Invoke-NativeCommand { } } +function Clear-ReleaseBundleOutput { + if (-not (Test-Path -LiteralPath $BundleRoot)) { + return + } + + $targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target") + $bundleFull = Get-FullPath -Path $BundleRoot + if ( + $bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + -not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull) + ) { + throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull" + } + + Write-Host "" + Write-Host "Cleaning stale Tauri bundle output: $bundleFull" + Remove-Item -LiteralPath $bundleFull -Recurse -Force +} + function Invoke-ReleaseBuild { if ($SkipBuild) { Write-Host "" @@ -402,22 +421,50 @@ function Invoke-ReleaseBuild { Write-Host "Skipping Rust tests because -SkipTests was provided." } + Clear-ReleaseBundleOutput Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build") } +function Get-ArtifactVersionPattern { + param([string]$TargetVersion) + + "(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)" +} + function Copy-ReleaseArtifacts { - param([string]$ReleaseDir) + param( + [string]$ReleaseDir, + [string]$TargetVersion + ) if (-not (Test-Path -LiteralPath $BundleRoot)) { throw "Tauri bundle output was not found: $BundleRoot" } $artifactDir = Join-Path $ReleaseDir "artifacts" - $files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File | - Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } + $allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File | + Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } | + Sort-Object FullName) + + if ($allFiles.Count -eq 0) { + throw "No release artifacts were found under $BundleRoot." + } + + $versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion + $files = @($allFiles | Where-Object { $_.Name -match $versionPattern }) + $ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern }) if ($files.Count -eq 0) { - throw "No release artifacts were found under $BundleRoot." + $found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", " + throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found" + } + + if ($ignoredFiles.Count -gt 0) { + Write-Host "" + Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:" + foreach ($ignored in $ignoredFiles) { + Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName)) + } } $copied = @() @@ -587,7 +634,7 @@ try { Invoke-ReleaseBuild $releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion - $artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir) + $artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion) Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a622f3c..6328d5d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2317,6 +2317,7 @@ name = "proxywarden" version = "1.0.1" dependencies = [ "base64 0.22.1", + "percent-encoding", "reqwest 0.12.28", "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ba2591..19d36f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,5 +19,6 @@ serde_json = "1" tauri-plugin-dialog = "2.7.1" base64 = "0.22" reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] } +percent-encoding = "2" url = "2" uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 14501fe..a3dfd4a 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -1,4 +1,6 @@ +use percent_encoding::percent_decode_str; use serde::{Deserialize, Serialize}; +use serde_json::Value; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080; @@ -159,6 +161,12 @@ impl LocalSingBoxConfig { .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 { @@ -186,6 +194,36 @@ pub struct SubscriptionCache { 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); + } + + let Some(outbounds) = self + .config + .get_mut("outbounds") + .and_then(Value::as_array_mut) + else { + return; + }; + + for outbound in outbounds { + let Some(decoded_tag) = outbound + .get("tag") + .and_then(Value::as_str) + .map(decode_percent_encoded_utf8) + else { + continue; + }; + + if let Some(object) = outbound.as_object_mut() { + object.insert("tag".to_string(), Value::String(decoded_tag)); + } + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SubscriptionServer { pub tag: String, @@ -274,3 +312,10 @@ pub fn redact_subscription_url(raw_url: &str) -> String { } } } + +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()) +} diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index ce184a6..d5202a1 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -96,7 +96,10 @@ impl JsonStorage { } pub fn read_local_singbox_config(&self) -> io::Result { - self.read_json_or_default(&self.paths.local_singbox_file) + let mut config: LocalSingBoxConfig = + self.read_json_or_default(&self.paths.local_singbox_file)?; + config.normalize_percent_encoded_tags(); + Ok(config) } pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> { @@ -104,7 +107,12 @@ impl JsonStorage { } pub fn read_singbox_subscription_cache(&self) -> io::Result> { - self.read_optional_json(&self.paths.singbox_subscription_cache_file) + let mut cache = self + .read_optional_json::(&self.paths.singbox_subscription_cache_file)?; + if let Some(cache) = cache.as_mut() { + cache.normalize_percent_encoded_tags(); + } + Ok(cache) } pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> { diff --git a/src-tauri/src/subscription.rs b/src-tauri/src/subscription.rs index b1e6bcd..b153ee3 100644 --- a/src-tauri/src/subscription.rs +++ b/src-tauri/src/subscription.rs @@ -1,4 +1,4 @@ -use crate::models::{SubscriptionCache, SubscriptionServer}; +use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer}; use base64::{engine::general_purpose, Engine}; use serde_json::{json, Map, Value}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -192,7 +192,10 @@ fn parse_vless_url(raw_url: &str) -> Result { } let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?; - let tag = parsed.fragment().unwrap_or("vless-out").to_string(); + let tag = parsed + .fragment() + .map(decode_percent_encoded_utf8) + .unwrap_or_else(|| "vless-out".to_string()); let uuid = parsed.username().trim().to_string(); let server = parsed.host_str().map(str::to_string).unwrap_or_default(); let server_port = parsed.port_or_known_default().unwrap_or(443); diff --git a/src-tauri/tests/storage_tests.rs b/src-tauri/tests/storage_tests.rs index ec4ba5c..78e6a98 100644 --- a/src-tauri/tests/storage_tests.rs +++ b/src-tauri/tests/storage_tests.rs @@ -113,6 +113,58 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() { cleanup(&root); } +#[test] +fn reads_percent_encoded_singbox_tags_as_utf8() { + let root = test_root("local-singbox-percent-tags"); + let storage = JsonStorage::new(root.clone()); + let encoded_tag = + "%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA"; + let decoded_tag = "Умный 🇳🇱->🇷🇺"; + + storage + .write_local_singbox_config(&LocalSingBoxConfig { + selected_server_tag: Some(encoded_tag.to_string()), + ..LocalSingBoxConfig::default() + }) + .expect("write local sing-box config"); + storage + .write_singbox_subscription_cache(&SubscriptionCache { + config: serde_json::json!({ + "outbounds": [ + { + "type": "vless", + "tag": encoded_tag, + "server": "nl.example.test", + "server_port": 443 + } + ] + }), + servers: vec![SubscriptionServer { + tag: encoded_tag.to_string(), + server_type: "vless".to_string(), + server: "nl.example.test".to_string(), + server_port: 443, + }], + user_info: serde_json::Map::new(), + fetched_at: "2026-07-07T10:00:00Z".to_string(), + }) + .expect("write subscription cache"); + + let config = storage + .read_local_singbox_config() + .expect("read local sing-box config"); + let cache = storage + .read_singbox_subscription_cache() + .expect("read subscription cache") + .expect("subscription cache"); + + assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string())); + assert_eq!(cache.servers[0].tag, decoded_tag); + assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag); + + cleanup(&root); +} + #[test] fn invalid_subscription_cache_falls_back_to_none() { let root = test_root("invalid-subscription-cache"); diff --git a/src-tauri/tests/subscription_tests.rs b/src-tauri/tests/subscription_tests.rs index 1fbab95..aac2f32 100644 --- a/src-tauri/tests/subscription_tests.rs +++ b/src-tauri/tests/subscription_tests.rs @@ -44,6 +44,19 @@ fn parses_base64_vless_link_list() { assert_eq!(outbound["packet_encoding"], "xudp"); } +#[test] +fn decodes_percent_encoded_vless_fragment_tag() { + let link = sample_vless_link( + "%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA", + ); + + let parsed = parse_subscription_body(&link).expect("vless link should parse"); + let outbound = &parsed.config["outbounds"][0]; + + assert_eq!(parsed.servers[0].tag, "Умный 🇳🇱->🇷🇺"); + assert_eq!(outbound["tag"], "Умный 🇳🇱->🇷🇺"); +} + #[test] fn rejects_body_without_supported_outbounds() { let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)