From 32f52c9c1e2496e13fd70fc93cdd934eef8c3c6d Mon Sep 17 00:00:00 2001 From: Mathious6 <91007976+Mathious6@users.noreply.github.com> Date: Mon, 31 Aug 2026 15:00:29 +0200 Subject: [PATCH 1/3] feat(export): add AYCD JSON format --- AGENTS.md | 5 +- README.md | 2 + src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/aycd.rs | 185 +++++++++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 19 ++++ src-tauri/src/export.rs | 18 +++- src-tauri/src/lib.rs | 2 + src/lib/ExportMenu.svelte | 35 +++++++ src/lib/RowMenu.svelte | 11 +++ src/lib/SubnetTable.svelte | 9 ++ src/routes/+page.svelte | 94 +++++++++++++++++-- 12 files changed, 369 insertions(+), 13 deletions(-) create mode 100644 src-tauri/src/aycd.rs create mode 100644 src/lib/ExportMenu.svelte diff --git a/AGENTS.md b/AGENTS.md index 6f6fd83..3743d5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,12 +22,13 @@ One window, not resizable. One workflow: Probe all, selected, or one `/24` → OK + Connect p50/p95 + TTFB p50/p95 + last probe. 5 s timeout, 5 starts/s, 32 in flight. 4. Export `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Untagged files start with the - country code. No country → `XX`. + country code. No country → `XX`. The Export menu also writes one AYCD JSON + file for the same scope, categorized by the TXT filename stem. 5. Filter sits with Open files, Export, and Probe all. Toolbar Probe and Export act on selected rows when present. Probe progress and the version sit in the bottom bar. 15 subnets per page. Right-click a selected row to Probe or Export the selection; otherwise those actions apply to that row. - Remove always applies to that row. + The Export menu includes AYCD JSON. Remove always applies to that row. Probe is the only action verb for a run. Anything else is out of scope until asked. diff --git a/README.md b/README.md index aa93dd4..0171198 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ usually already have it. Windows installers are unsigned. `[{tags}_]{CC}_{IP}_24_{qty}.txt`. Example: `isp-mobile_FR_192.0.2.0_24_42.txt`. No tags means the filename starts with the country code. No country means `XX`. + The adjacent export menu also offers **Export for AYCD (.json)**, which writes + one categorized JSON file for the same scope. Run a probe once, then pause. Back-to-back runs saturate your own uplink and inflate every subnet. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8cec02c..7a75d15 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2811,6 +2811,7 @@ dependencies = [ "tokio-rustls", "ureq", "url", + "uuid", "webpki-roots 1.0.9", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6da9b8d..7b52c31 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" +uuid = { version = "1", features = ["v4"] } ureq = { version = "2", features = ["json"] } tauri-plugin-dialog = "2" url = "2" diff --git a/src-tauri/src/aycd.rs b/src-tauri/src/aycd.rs new file mode 100644 index 0000000..9ccddc4 --- /dev/null +++ b/src-tauri/src/aycd.rs @@ -0,0 +1,185 @@ +use std::fs; +use std::path::Path; + +use serde::Serialize; +use uuid::Uuid; + +use crate::export; +use crate::session::StoredBucket; +use crate::tags::Store; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Proxy { + id: String, + all_index: i8, + category_index: i8, + category: String, + url: String, + port: String, + user: String, + pass: String, + #[serde(rename = "type")] + kind: &'static str, +} + +pub fn write(path: &Path, buckets: &[StoredBucket], tags: &Store) -> Result { + if buckets.is_empty() { + return Err("Import proxies before exporting.".into()); + } + let proxies: Vec = buckets + .iter() + .flat_map(|bucket| { + let category = export::category( + &tags.get(&bucket.subnet.cidr()), + bucket.country.as_deref(), + bucket, + ); + bucket.proxies.iter().map(move |proxy| Proxy { + id: Uuid::new_v4().to_string(), + all_index: -1, + category_index: -1, + category: category.clone(), + url: proxy.host.to_string(), + port: proxy.port.to_string(), + user: proxy.username.clone(), + pass: proxy.password.clone(), + kind: "HTTP", + }) + }) + .collect(); + let bytes = serde_json::to_vec(&proxies).map_err(|err| err.to_string())?; + let path = json_path(path); + write_mode(&path, &bytes).map_err(|err| io_error(&path, err))?; + Ok(proxies.len()) +} + +fn json_path(path: &Path) -> std::path::PathBuf { + if path.extension().and_then(|value| value.to_str()) == Some("json") { + path.to_owned() + } else { + let mut value = path.as_os_str().to_owned(); + value.push(".json"); + value.into() + } +} + +fn io_error(path: &Path, err: std::io::Error) -> String { + format!("Could not write {} ({})", path.display(), err.kind()) +} + +fn write_mode(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(bytes) + } + #[cfg(not(unix))] + { + fs::write(path, bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse::ProxyLine; + use crate::split::Subnet; + use crate::tags::Tag; + + fn bucket(host: &str, country: Option<&str>, password: &str) -> StoredBucket { + StoredBucket { + subnet: Subnet::from_host(host.parse().unwrap()), + proxies: vec![ProxyLine { + host: host.parse().unwrap(), + port: 8080, + username: "user".into(), + password: password.into(), + source: String::new(), + }], + country: country.map(str::to_owned), + last_probe: None, + } + } + + fn temp_path() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "proxybench-aycd-{}-{}.json", + std::process::id(), + Uuid::new_v4() + )) + } + + #[test] + fn write_serializes_aycd_schema_with_categories_and_unique_v4_ids() { + let path = temp_path(); + let tags_path = temp_path(); + let mut tags = Store::load(tags_path.clone()).unwrap(); + tags.set( + "192.0.2.0/24".into(), + vec![Tag::parse("Residential").unwrap()], + ) + .unwrap(); + let buckets = vec![ + bucket("192.0.2.10", Some("fr"), "p:ss"), + bucket("198.51.100.10", None, "pass"), + ]; + assert_eq!(write(&path, &buckets, &tags).unwrap(), 2); + let rows: Vec = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0]["allIndex"], -1); + assert_eq!(rows[0]["categoryIndex"], -1); + assert_eq!(rows[0]["category"], "residential_FR_192.0.2.0_24_1"); + assert_eq!(rows[0]["url"], "192.0.2.10"); + assert_eq!(rows[0]["port"], "8080"); + assert_eq!(rows[0]["user"], "user"); + assert_eq!(rows[0]["pass"], "p:ss"); + assert_eq!(rows[0]["type"], "HTTP"); + assert_eq!(rows[1]["category"], "XX_198.51.100.0_24_1"); + let first = rows[0]["id"].as_str().unwrap(); + let second = rows[1]["id"].as_str().unwrap(); + assert_ne!(first, second); + assert_eq!(Uuid::parse_str(first).unwrap().get_version_num(), 4); + assert_eq!(Uuid::parse_str(second).unwrap().get_version_num(), 4); + let _ = fs::remove_file(path); + let _ = fs::remove_file(tags_path); + } + + #[test] + fn write_appends_json_extension() { + let path = temp_path().with_extension("txt"); + let expected = json_path(&path); + assert!(expected.to_string_lossy().ends_with(".txt.json")); + let tags_path = temp_path(); + let tags = Store::load(tags_path.clone()).unwrap(); + write(&path, &[bucket("192.0.2.10", Some("FR"), "pass")], &tags).unwrap(); + assert!(expected.exists()); + let _ = fs::remove_file(expected); + let _ = fs::remove_file(tags_path); + } + + #[cfg(unix)] + #[test] + fn write_sets_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + let path = temp_path(); + let tags_path = temp_path(); + let tags = Store::load(tags_path.clone()).unwrap(); + write(&path, &[bucket("192.0.2.10", Some("FR"), "pass")], &tags).unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let _ = fs::remove_file(path); + let _ = fs::remove_file(tags_path); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 5789e12..fbf7a55 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,6 +4,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State}; +use crate::aycd; use crate::export; use crate::import::{self, InventoryStore, TagStore}; use crate::last_target; @@ -123,6 +124,24 @@ pub fn export_dir( export::write_dir(std::path::Path::new(&path), &buckets, &store) } +#[tauri::command] +pub fn export_aycd( + path: String, + cidrs: Option>, + session: State<'_, SessionStore>, + tags: State<'_, TagStore>, +) -> Result { + let buckets = { + let session = session.0.lock().map_err(|err| err.to_string())?; + if session.is_empty() { + return Err("Import proxies before exporting.".into()); + } + session.resolve_scope(cidrs)? + }; + let store = tags.0.lock().map_err(|err| err.to_string())?; + aycd::write(std::path::Path::new(&path), &buckets, &store) +} + #[tauri::command] pub fn session_rows( session: State<'_, SessionStore>, diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index 45e443c..bc8e0d1 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -5,6 +5,10 @@ use crate::session::StoredBucket; use crate::tags::{Store, Tag}; pub fn filename(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> String { + format!("{}.txt", category(tags, country, bucket)) +} + +pub fn category(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> String { let country = country .filter(|code| code.len() == 2 && code.bytes().all(|b| b.is_ascii_alphabetic())) .map(|code| code.to_ascii_uppercase()) @@ -12,8 +16,8 @@ pub fn filename(tags: &[Tag], country: Option<&str>, bucket: &StoredBucket) -> S let ip = bucket.subnet.network(); let qty = bucket.proxies.len(); match tag_stem(tags) { - Some(tags) => format!("{tags}_{country}_{ip}_24_{qty}.txt"), - None => format!("{country}_{ip}_24_{qty}.txt"), + Some(tags) => format!("{tags}_{country}_{ip}_24_{qty}"), + None => format!("{country}_{ip}_24_{qty}"), } } @@ -137,6 +141,16 @@ mod tests { assert_eq!(name, "isp-mobile_FR_51.194.38.0_24_42.txt"); } + #[test] + fn category_is_the_filename_without_the_extension() { + let bucket = bucket("51.194.38.2", 42); + let category = category(&tags(&["isp", "mobile"]), Some("fr"), &bucket); + assert_eq!( + filename(&tags(&["isp", "mobile"]), Some("fr"), &bucket), + format!("{category}.txt") + ); + } + #[test] fn filename_starts_with_country_when_tags_are_missing() { let name = filename(&[], None, &bucket("192.0.2.10", 1)); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 23e17e3..c5393dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod aycd; mod commands; mod country; mod export; @@ -42,6 +43,7 @@ pub fn run() { commands::last_target, commands::start_run, commands::export_dir, + commands::export_aycd, commands::session_rows, commands::remove_subnet ]) diff --git a/src/lib/ExportMenu.svelte b/src/lib/ExportMenu.svelte new file mode 100644 index 0000000..0965c62 --- /dev/null +++ b/src/lib/ExportMenu.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/lib/RowMenu.svelte b/src/lib/RowMenu.svelte index 162d15e..6199f50 100644 --- a/src/lib/RowMenu.svelte +++ b/src/lib/RowMenu.svelte @@ -5,6 +5,7 @@ locked, onProbe, onExport, + onExportAycd, onRemove, onDismiss, }: { @@ -13,6 +14,7 @@ locked: boolean; onProbe: () => void; onExport: () => void; + onExportAycd: () => void; onRemove: () => void; onDismiss: () => void; } = $props(); @@ -63,6 +65,15 @@ > Export + - +
+ + + {#if exportMenuOpen} + void exportAycd()} + onDismiss={closeExportMenu} + /> + {/if} +