Decode percent-encoded tags and filter release artifacts
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -2317,6 +2317,7 @@ name = "proxywarden"
|
||||
version = "1.0.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"percent-encoding",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -96,7 +96,10 @@ impl JsonStorage {
|
||||
}
|
||||
|
||||
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
|
||||
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<Option<SubscriptionCache>> {
|
||||
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
|
||||
let mut cache = self
|
||||
.read_optional_json::<SubscriptionCache>(&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<()> {
|
||||
|
||||
@@ -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<Value, SubscriptionError> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"}]}"#)
|
||||
|
||||
Reference in New Issue
Block a user