From 02219bd1deb95c6d382b9442cefa3c42f740888c Mon Sep 17 00:00:00 2001 From: FallenCrystal Date: Tue, 7 Jul 2026 00:23:03 +0800 Subject: [PATCH 01/81] feat: integrate Steam Workshop browsing and management functionality --- src-tauri/Cargo.toml | 2 + src-tauri/src/commands.rs | 639 ++++++++++++++++-- src-tauri/src/lib.rs | 19 +- src/App.tsx | 42 +- src/components/EditGroupModal.tsx | 129 +++- src/components/GroupCard.tsx | 10 + src/components/KnownUninstalledView.tsx | 331 +++++++++ src/components/Sidebar.tsx | 25 +- src/components/WorkshopBrowser.tsx | 453 +++++++++++++ src/components/workshop/DetailDrawer.tsx | 525 ++++++++++++++ src/components/workshop/ItemCard.tsx | 116 ++++ src/components/workshop/SectionCarousel.tsx | 110 +++ src/components/workshop/TagBrowserModal.tsx | 80 +++ src/components/workshop/TagSidebar.tsx | 91 +++ .../workshop/WorkshopDetailModal.tsx | 287 ++++++++ src/components/workshop/index.ts | 8 + src/components/workshop/ssrParser.ts | 383 +++++++++++ src/components/workshop/types.ts | 52 ++ src/hooks/useAddonManager.ts | 317 ++++++--- src/i18n.tsx | 184 ++++- src/index.css | 29 + src/types/addon.ts | 9 + 22 files changed, 3674 insertions(+), 167 deletions(-) create mode 100644 src/components/KnownUninstalledView.tsx create mode 100644 src/components/WorkshopBrowser.tsx create mode 100644 src/components/workshop/DetailDrawer.tsx create mode 100644 src/components/workshop/ItemCard.tsx create mode 100644 src/components/workshop/SectionCarousel.tsx create mode 100644 src/components/workshop/TagBrowserModal.tsx create mode 100644 src/components/workshop/TagSidebar.tsx create mode 100644 src/components/workshop/WorkshopDetailModal.tsx create mode 100644 src/components/workshop/index.ts create mode 100644 src/components/workshop/ssrParser.ts create mode 100644 src/components/workshop/types.ts diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c480df4..18a010b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,6 +7,8 @@ license = "" repository = "" edition = "2021" rust-version = "1.77.2" +default-run = "app" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6968ca8..33501fc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use serde::{Serialize, Deserialize}; -use tauri::{AppHandle, State, Manager}; +use tauri::{AppHandle, State, Manager, Emitter}; use regex::Regex; use crate::vpk::{extract_addon_metadata, generate_dummy_vpk}; @@ -16,6 +16,16 @@ pub struct Settings { pub enable_dummy_bypass: bool, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Group { + pub id: String, + pub name: String, + pub addons: Vec, + pub tags: Option>, + #[serde(rename = "workshopCollectionId")] + pub workshop_collection_id: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Addon { #[serde(rename = "vpkName")] @@ -37,7 +47,7 @@ pub struct Addon { #[serde(rename = "currentPath")] pub current_path: String, #[serde(rename = "dirType")] - pub dir_type: String, // "workshop" or "loading" + pub dir_type: String, #[serde(rename = "isEnabled")] pub is_enabled: bool, #[serde(rename = "steamDetails")] @@ -47,10 +57,48 @@ pub struct Addon { } #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Group { - pub id: String, - pub name: String, - pub addons: Vec, +pub struct LocalAddon { + #[serde(rename = "vpkName")] + pub vpk_name: String, + #[serde(rename = "workshopId")] + pub workshop_id: Option, + #[serde(rename = "filesCount")] + pub files_count: usize, + #[serde(rename = "fileSize")] + pub file_size: u64, + #[serde(rename = "parsedAt")] + pub parsed_at: String, + #[serde(rename = "currentPath")] + pub current_path: String, + #[serde(rename = "dirType")] + pub dir_type: String, + #[serde(rename = "isEnabled")] + pub is_enabled: bool, + #[serde(rename = "isDummy", default)] + pub is_dummy: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct KnownAddonEntry { + #[serde(rename = "vpkName")] + pub vpk_name: String, + #[serde(rename = "workshopId")] + pub workshop_id: Option, + #[serde(rename = "addonInfo")] + pub addon_info: serde_json::Value, + #[serde(rename = "hasImage")] + pub has_image: bool, + #[serde(rename = "imagePath")] + pub image_path: Option, + #[serde(rename = "steamDetails")] + pub steam_details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct LocalDatabase { + pub settings: Settings, + pub addons: HashMap, + pub groups: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -58,9 +106,11 @@ pub struct Database { pub settings: Settings, pub addons: HashMap, pub groups: Vec, + #[serde(rename = "knownUninstalledAddons")] + pub known_uninstalled_addons: HashMap, } -pub fn load_db(db_path: &Path, app_handle: &AppHandle) -> Database { +pub fn load_db(db_path: &Path, known_addons_path: &Path, app_handle: &AppHandle) -> Database { let runtime_dir = db_path.parent().unwrap_or(Path::new("")); let default_loading = runtime_dir.join("addons-loading").to_string_lossy().to_string(); let default_workshop = Path::new(&default_loading).join("workshop").to_string_lossy().to_string(); @@ -73,40 +123,206 @@ pub fn load_db(db_path: &Path, app_handle: &AppHandle) -> Database { }, addons: HashMap::new(), groups: Vec::new(), + known_uninstalled_addons: HashMap::new(), }; - if !db_path.exists() { - if let Ok(json) = serde_json::to_string_pretty(&default_db) { - let _ = fs::write(db_path, json); + let db_json_existed = db_path.exists(); + + let mut local_db: LocalDatabase = if db_path.exists() { + match fs::read_to_string(db_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_else(|_| { + if let Ok(old_db) = serde_json::from_str::(&content) { + let mut addons_map = HashMap::new(); + if let Some(addons_obj) = old_db["addons"].as_object() { + for (k, v) in addons_obj { + if let Ok(addon) = serde_json::from_value::(v.clone()) { + addons_map.insert(k.clone(), LocalAddon { + vpk_name: addon.vpk_name, + workshop_id: addon.workshop_id, + files_count: addon.files_count, + file_size: addon.file_size, + parsed_at: addon.parsed_at, + current_path: addon.current_path, + dir_type: addon.dir_type, + is_enabled: addon.is_enabled, + is_dummy: addon.is_dummy, + }); + } + } + } + let settings = serde_json::from_value(old_db["settings"].clone()).unwrap_or_default(); + let groups = serde_json::from_value(old_db["groups"].clone()).unwrap_or_default(); + LocalDatabase { settings, addons: addons_map, groups } + } else { + LocalDatabase::default() + } + }), + Err(_) => LocalDatabase::default(), } - return default_db; - } + } else { + LocalDatabase { + settings: default_db.settings.clone(), + addons: HashMap::new(), + groups: Vec::new(), + } + }; + + let known_addons: HashMap = if known_addons_path.exists() { + match fs::read_to_string(known_addons_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => HashMap::new(), + } + } else { + let mut migrated = HashMap::new(); + if db_path.exists() { + if let Ok(content) = fs::read_to_string(db_path) { + if let Ok(old_db) = serde_json::from_str::(&content) { + if let Some(addons_obj) = old_db["addons"].as_object() { + for (k, v) in addons_obj { + if let Ok(addon) = serde_json::from_value::(v.clone()) { + migrated.insert(k.clone(), KnownAddonEntry { + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + addon_info: addon.addon_info, + has_image: addon.has_image, + image_path: addon.image_path, + steam_details: addon.steam_details, + }); + } + } + } + } + } + } + if !migrated.is_empty() { + let _ = fs::write(known_addons_path, serde_json::to_string_pretty(&migrated).unwrap_or_default()); + } + migrated + }; let old_default_loading = app_handle.path().app_data_dir() .map(|p| p.join("addons-loading").to_string_lossy().to_string()) .unwrap_or_default(); - match fs::read_to_string(db_path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(mut data) => { - if data.settings.loading_dir.is_empty() || data.settings.loading_dir == old_default_loading { - data.settings.loading_dir = default_db.settings.loading_dir.clone(); - } - // Enforce workshop_dir is loading_dir/workshop - let loading_path = Path::new(&data.settings.loading_dir); - data.settings.workshop_dir = loading_path.join("workshop").to_string_lossy().to_string(); - data - } - Err(_) => default_db, - }, - Err(_) => default_db, + if local_db.settings.loading_dir.is_empty() || local_db.settings.loading_dir == old_default_loading { + local_db.settings.loading_dir = default_db.settings.loading_dir.clone(); + } + let loading_path = Path::new(&local_db.settings.loading_dir); + local_db.settings.workshop_dir = loading_path.join("workshop").to_string_lossy().to_string(); + + let mut merged_addons = HashMap::new(); + for (vpk_name, local_addon) in local_db.addons { + let entry = known_addons.get(&vpk_name); + merged_addons.insert(vpk_name.clone(), Addon { + vpk_name: local_addon.vpk_name, + workshop_id: local_addon.workshop_id, + addon_info: entry.map(|e| e.addon_info.clone()).unwrap_or(serde_json::Value::Null), + has_image: entry.map(|e| e.has_image).unwrap_or(false), + image_path: entry.and_then(|e| e.image_path.clone()), + files_count: local_addon.files_count, + file_size: local_addon.file_size, + parsed_at: local_addon.parsed_at, + current_path: local_addon.current_path, + dir_type: local_addon.dir_type, + is_enabled: local_addon.is_enabled, + steam_details: entry.and_then(|e| e.steam_details.clone()), + is_dummy: local_addon.is_dummy, + }); } + + let mut known_uninstalled_addons = HashMap::new(); + for (vpk_name, entry) in &known_addons { + if !merged_addons.contains_key(vpk_name) { + known_uninstalled_addons.insert(vpk_name.clone(), Addon { + vpk_name: entry.vpk_name.clone(), + workshop_id: entry.workshop_id.clone(), + addon_info: entry.addon_info.clone(), + has_image: entry.has_image, + image_path: entry.image_path.clone(), + files_count: 0, + file_size: 0, + parsed_at: "".to_string(), + current_path: "".to_string(), + dir_type: "none".to_string(), + is_enabled: false, + steam_details: entry.steam_details.clone(), + is_dummy: false, + }); + } + } + + let db = Database { + settings: local_db.settings, + addons: merged_addons, + groups: local_db.groups, + known_uninstalled_addons, + }; + + if !db_json_existed { + save_db_internal(db_path, known_addons_path, &db); + } + + db } -pub fn save_db(db_path: &Path, db: &Database) { - if let Ok(json) = serde_json::to_string_pretty(db) { +pub fn save_db_internal(db_path: &Path, known_addons_path: &Path, db: &Database) { + let mut local_addons = HashMap::new(); + for (k, addon) in &db.addons { + local_addons.insert(k.clone(), LocalAddon { + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + files_count: addon.files_count, + file_size: addon.file_size, + parsed_at: addon.parsed_at.clone(), + current_path: addon.current_path.clone(), + dir_type: addon.dir_type.clone(), + is_enabled: addon.is_enabled, + is_dummy: addon.is_dummy, + }); + } + let local_db = LocalDatabase { + settings: db.settings.clone(), + addons: local_addons, + groups: db.groups.clone(), + }; + if let Ok(json) = serde_json::to_string_pretty(&local_db) { let _ = fs::write(db_path, json); } + + let mut known_addons: HashMap = if known_addons_path.exists() { + match fs::read_to_string(known_addons_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => HashMap::new(), + } + } else { + HashMap::new() + }; + + for (vpk_name, addon) in &db.addons { + known_addons.insert(vpk_name.clone(), KnownAddonEntry { + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + addon_info: addon.addon_info.clone(), + has_image: addon.has_image, + image_path: addon.image_path.clone(), + steam_details: addon.steam_details.clone(), + }); + } + + for (vpk_name, addon) in &db.known_uninstalled_addons { + known_addons.insert(vpk_name.clone(), KnownAddonEntry { + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + addon_info: addon.addon_info.clone(), + has_image: addon.has_image, + image_path: addon.image_path.clone(), + steam_details: addon.steam_details.clone(), + }); + } + + if let Ok(json) = serde_json::to_string_pretty(&known_addons) { + let _ = fs::write(known_addons_path, json); + } } async fn fetch_steam_details(workshop_ids: &[String]) -> Result, String> { @@ -147,6 +363,7 @@ async fn fetch_steam_details(workshop_ids: &[String]) -> Result Result<(), String> { let workshop_dir = Path::new(&db.settings.workshop_dir); @@ -233,7 +450,8 @@ pub async fn scan_addons_internal( for file_info in files_on_disk { let vpk_name = file_info.vpk_name.clone(); - let cached = db.addons.get(&vpk_name).cloned(); + let cached = db.addons.get(&vpk_name).cloned() + .or_else(|| db.known_uninstalled_addons.get(&vpk_name).cloned()); let has_capitalized_keys = cached.as_ref().map_or(false, |addon| { addon.addon_info.as_object().map_or(false, |obj| { @@ -350,14 +568,51 @@ pub async fn scan_addons_internal( } } + // Update known_uninstalled_addons in memory: + let known_addons: HashMap = if known_addons_path.exists() { + match fs::read_to_string(known_addons_path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_default(), + Err(_) => HashMap::new(), + } + } else { + HashMap::new() + }; + + let mut uninstalled = HashMap::new(); + for (vpk_name, entry) in &known_addons { + if !active_addons.contains_key(vpk_name) { + uninstalled.insert(vpk_name.clone(), Addon { + vpk_name: entry.vpk_name.clone(), + workshop_id: entry.workshop_id.clone(), + addon_info: entry.addon_info.clone(), + has_image: entry.has_image, + image_path: entry.image_path.clone(), + files_count: 0, + file_size: 0, + parsed_at: "".to_string(), + current_path: "".to_string(), + dir_type: "none".to_string(), + is_enabled: false, + steam_details: entry.steam_details.clone(), + is_dummy: false, + }); + } + } + for (vpk_name, addon) in &db.known_uninstalled_addons { + if !active_addons.contains_key(vpk_name) && !uninstalled.contains_key(vpk_name) { + uninstalled.insert(vpk_name.clone(), addon.clone()); + } + } + db.addons = active_addons; + db.known_uninstalled_addons = uninstalled; for g in &mut db.groups { - g.addons.retain(|vpk| db.addons.contains_key(vpk)); + g.addons.retain(|vpk| db.addons.contains_key(vpk) || db.known_uninstalled_addons.contains_key(vpk) || vpk.ends_with(".vpk")); } db.groups.retain(|g| !g.addons.is_empty()); - save_db(db_path, db); + save_db_internal(db_path, known_addons_path, db); Ok(()) } @@ -493,6 +748,8 @@ fn auto_group_internal(db: &mut Database) { id: group_id, name: group_name, addons, + tags: None, + workshop_collection_id: None, }); } } @@ -529,6 +786,8 @@ fn auto_group_internal(db: &mut Database) { id: group_id, name: prefix, addons, + tags: None, + workshop_collection_id: None, }); } } @@ -555,9 +814,9 @@ pub async fn save_settings( db.settings.workshop_dir = workshop_dir; db.settings.loading_dir = loading_dir; db.settings.enable_dummy_bypass = enable_dummy_bypass; - save_db(&state.db_path, &db); + save_db_internal(&state.db_path, &state.known_addons_path, &db); - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -566,7 +825,7 @@ pub async fn get_addons( state: State<'_, crate::AppState>, ) -> Result { let mut db = state.db.lock().await; - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -638,7 +897,7 @@ pub async fn move_addons( } } - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -674,7 +933,7 @@ pub async fn toggle_addons( } } - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -759,7 +1018,7 @@ pub async fn rename_addon( return Err("Addon not found".to_string()); } - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -853,7 +1112,7 @@ pub async fn rename_addons( } } - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -863,6 +1122,8 @@ pub async fn group_action( name: Option, group_id: Option, vpk_names: Option>, + tags: Option>, + workshop_collection_id: Option, state: State<'_, crate::AppState>, ) -> Result { let mut db = state.db.lock().await; @@ -881,13 +1142,15 @@ pub async fn group_action( } let filtered_vpks: Vec = vpk_names.into_iter() - .filter(|n| db.addons.contains_key(n)) + .filter(|n| db.addons.contains_key(n) || db.known_uninstalled_addons.contains_key(n) || n.ends_with(".vpk")) .collect(); db.groups.push(Group { id: group_id, name, addons: filtered_vpks, + tags, + workshop_collection_id, }); } else if action == "delete" { @@ -903,7 +1166,7 @@ pub async fn group_action( } let valid_vpk_names: Vec = vpk_names.into_iter() - .filter(|n| db.addons.contains_key(n)) + .filter(|n| db.addons.contains_key(n) || db.known_uninstalled_addons.contains_key(n) || n.ends_with(".vpk")) .collect(); if let Some(target_group) = db.groups.iter_mut().find(|g| g.id == group_id) { @@ -916,7 +1179,6 @@ pub async fn group_action( return Err("Group not found".to_string()); } - } else if action == "remove-addons" { let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; let vpk_names = vpk_names.ok_or_else(|| "Missing vpkNames".to_string())?; @@ -925,12 +1187,26 @@ pub async fn group_action( group.addons.retain(|n| !vpk_names.contains(n)); } - } else if action == "rename-group" { + } else if action == "rename-group" || action == "update-group" { let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; - let name = name.ok_or_else(|| "Missing name".to_string())?; + + let valid_addon_names: HashSet = db.addons.keys().cloned() + .chain(db.known_uninstalled_addons.keys().cloned()) + .collect(); if let Some(group) = db.groups.iter_mut().find(|g| g.id == group_id) { - group.name = name; + if let Some(n) = name { + group.name = n; + } + group.tags = tags; + group.workshop_collection_id = workshop_collection_id; + + if let Some(vpks) = vpk_names { + let filtered_vpks: Vec = vpks.into_iter() + .filter(|n| valid_addon_names.contains(n) || n.ends_with(".vpk")) + .collect(); + group.addons = filtered_vpks; + } } } else if action == "auto-group" { @@ -941,8 +1217,8 @@ pub async fn group_action( db.groups.retain(|g| !g.addons.is_empty()); - save_db(&state.db_path, &db); - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + save_db_internal(&state.db_path, &state.known_addons_path, &db); + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } @@ -970,7 +1246,7 @@ pub async fn steam_sync( let mut db = state.db.lock().await; // First, scan addons to populate database with any new/removed files - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; let mut ids = Vec::new(); for addon in db.addons.values() { @@ -1002,8 +1278,273 @@ pub async fn steam_sync( } } - save_db(&state.db_path, &db); - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; + save_db_internal(&state.db_path, &state.known_addons_path, &db); + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; + Ok(db.clone()) +} + +#[tauri::command] +pub async fn fetch_workshop_html(url: String) -> Result { + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .build() + .map_err(|e| e.to_string())?; + let res = client.get(&url) + .send() + .await + .map_err(|e| format!("Failed to fetch URL: {}", e))?; + let body = res.text() + .await + .map_err(|e| format!("Failed to get response text: {}", e))?; + Ok(body) +} + +#[tauri::command] +pub async fn delete_addons( + vpk_names: Vec, + delete_file: bool, + remove_from_known: bool, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + if delete_file { + for vpk_name in &vpk_names { + if let Some(addon) = db.addons.get(vpk_name) { + let path = PathBuf::from(&addon.current_path); + if path.exists() { + let _ = fs::remove_file(&path); + } + let disabled_path = path.with_extension("vpk.disabled"); + if disabled_path.exists() { + let _ = fs::remove_file(&disabled_path); + } + } + } + } + + if remove_from_known { + let mut known_addons: HashMap = if state.known_addons_path.exists() { + fs::read_to_string(&state.known_addons_path) + .ok() + .and_then(|c| serde_json::from_str(&c).ok()) + .unwrap_or_default() + } else { + HashMap::new() + }; + + for vpk_name in &vpk_names { + known_addons.remove(vpk_name); + db.known_uninstalled_addons.remove(vpk_name); + } + + let _ = fs::write(&state.known_addons_path, serde_json::to_string_pretty(&known_addons).unwrap_or_default()); + } + + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; + Ok(db.clone()) +} + +#[tauri::command] +pub async fn download_addon( + workshop_id: String, + state: State<'_, crate::AppState>, + app_handle: AppHandle, +) -> Result { + let mut db = state.db.lock().await; + + let details_list = fetch_steam_details(&[workshop_id.clone()]).await?; + if details_list.is_empty() { + return Err("Failed to retrieve details for workshop item".to_string()); + } + let details = &details_list[0]; + + let file_url = details.get("file_url") + .and_then(|u| u.as_str()) + .ok_or_else(|| "Workshop item has no download URL (might be hidden or deleted)".to_string())?; + + let title = details.get("title").and_then(|t| t.as_str()).unwrap_or("Workshop Item"); + + let workshop_dir = PathBuf::from(&db.settings.workshop_dir); + if !workshop_dir.exists() { + fs::create_dir_all(&workshop_dir).map_err(|e| e.to_string())?; + } + let dest_filename = format!("{}.vpk", workshop_id); + let dest_path = workshop_dir.join(&dest_filename); + + println!("Downloading: {} (URL: {})", title, file_url); + let client = reqwest::Client::new(); + let mut response = client.get(file_url) + .send() + .await + .map_err(|e| format!("Download request failed: {}", e))?; + + let total_size = response.content_length().unwrap_or(0); + let mut file = fs::File::create(&dest_path).map_err(|e| format!("Failed to create local file: {}", e))?; + let mut downloaded = 0; + + #[derive(Serialize, Clone)] + struct DownloadProgress { + #[serde(rename = "workshopId")] + workshop_id: String, + percent: u32, + downloaded: u64, + total: u64, + } + + while let Some(chunk) = response.chunk().await.map_err(|e| format!("Download chunk failed: {}", e))? { + use std::io::Write; + file.write_all(&chunk).map_err(|e| format!("Failed to write chunk: {}", e))?; + downloaded += chunk.len() as u64; + + let percent = if total_size > 0 { + (downloaded as f64 / total_size as f64 * 100.0) as u32 + } else { + 0 + }; + + let _ = app_handle.emit("download-progress", DownloadProgress { + workshop_id: workshop_id.clone(), + percent, + downloaded, + total: total_size, + }); + } + + let mut known_addons: HashMap = if state.known_addons_path.exists() { + fs::read_to_string(&state.known_addons_path) + .ok() + .and_then(|c| serde_json::from_str(&c).ok()) + .unwrap_or_default() + } else { + HashMap::new() + }; + + let has_image = details.get("preview_url").is_some(); + let image_path = details.get("preview_url").and_then(|u| u.as_str()).map(|s| s.to_string()); + + known_addons.insert(dest_filename.clone(), KnownAddonEntry { + vpk_name: dest_filename.clone(), + workshop_id: Some(workshop_id.clone()), + addon_info: serde_json::Value::Null, + has_image, + image_path, + steam_details: Some(details.clone()), + }); + + let _ = fs::write(&state.known_addons_path, serde_json::to_string_pretty(&known_addons).unwrap_or_default()); + + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; + Ok(db.clone()) +} + +async fn fetch_collection_details_internal(collection_id: &str) -> Result, String> { + let url = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"; + let mut params = Vec::new(); + params.push(("collectioncount".to_string(), "1".to_string())); + params.push(("publishedfileids[0]".to_string(), collection_id.to_string())); + + let client = reqwest::Client::new(); + let res = client.post(url) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Failed to query collection details: {}", e))?; + + if !res.status().is_success() { + return Err(format!("Steam Collection API responded with status {}", res.status())); + } + + let json: serde_json::Value = res.json() + .await + .map_err(|e| format!("Failed to parse collection JSON: {}", e))?; + + let details_list = json["response"]["collectiondetails"] + .as_array() + .ok_or_else(|| "Invalid collectiondetails format".to_string())?; + + if details_list.is_empty() { + return Err("Collection not found".to_string()); + } + + let mut ids = Vec::new(); + if let Some(children) = details_list[0]["children"].as_array() { + for child in children { + if let Some(id) = child["publishedfileid"].as_str() { + ids.push(id.to_string()); + } + } + } + + Ok(ids) +} + +#[tauri::command] +pub async fn fetch_collection(collection_id: String) -> Result { + let child_ids = fetch_collection_details_internal(&collection_id).await?; + + let mut query_ids = vec![collection_id.clone()]; + query_ids.extend(child_ids.clone()); + + let details = fetch_steam_details(&query_ids).await?; + + let mut collection_details = serde_json::Value::Null; + let mut items = Vec::new(); + + for detail in details { + if let Some(id) = detail.get("publishedfileid").and_then(|id| id.as_str()) { + if id == collection_id { + collection_details = detail; + } else { + items.push(detail); + } + } + } + + Ok(serde_json::json!({ + "collection": collection_details, + "items": items, + })) +} + +#[tauri::command] +pub async fn add_known_addon( + workshop_id: String, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + let details_list = fetch_steam_details(&[workshop_id.clone()]).await?; + if details_list.is_empty() { + return Err("Failed to retrieve details for workshop item".to_string()); + } + let details = &details_list[0]; + + let mut known_addons: HashMap = if state.known_addons_path.exists() { + fs::read_to_string(&state.known_addons_path) + .ok() + .and_then(|c| serde_json::from_str(&c).ok()) + .unwrap_or_default() + } else { + HashMap::new() + }; + + let dest_filename = format!("{}.vpk", workshop_id); + let has_image = details.get("preview_url").is_some(); + let image_path = details.get("preview_url").and_then(|u| u.as_str()).map(|s| s.to_string()); + + known_addons.insert(dest_filename.clone(), KnownAddonEntry { + vpk_name: dest_filename.clone(), + workshop_id: Some(workshop_id.clone()), + addon_info: serde_json::Value::Null, + has_image, + image_path, + steam_details: Some(details.clone()), + }); + + let _ = fs::write(&state.known_addons_path, serde_json::to_string_pretty(&known_addons).unwrap_or_default()); + + scan_addons_internal(&mut db, &state.db_path, &state.known_addons_path, &state.cache_dir).await?; Ok(db.clone()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1e4ea6..60093fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ pub mod commands; pub struct AppState { pub db_path: PathBuf, + pub known_addons_path: PathBuf, pub cache_dir: PathBuf, pub db: Mutex, } @@ -15,7 +16,7 @@ pub struct AppState { pub fn run() { tauri::Builder::default() .setup(|app| { - let runtime_dir = std::env::current_exe() + let mut runtime_dir = std::env::current_exe() .ok() .and_then(|p| { p.parent().map(|d| { @@ -29,6 +30,13 @@ pub fn run() { }) .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + if cfg!(debug_assertions) && runtime_dir.ends_with("src-tauri") { + if let Some(parent) = runtime_dir.parent() { + runtime_dir = parent.to_path_buf(); + } + } + + if !runtime_dir.exists() { let _ = std::fs::create_dir_all(&runtime_dir); } @@ -38,15 +46,17 @@ pub fn run() { } let db_path = runtime_dir.join("db.json"); + let known_addons_path = runtime_dir.join("known_addons.json"); let cache_dir = runtime_dir.join("cache"); if !cache_dir.exists() { let _ = std::fs::create_dir_all(&cache_dir); } - let db = commands::load_db(&db_path, app.handle()); + let db = commands::load_db(&db_path, &known_addons_path, app.handle()); app.manage(AppState { db_path, + known_addons_path, cache_dir, db: Mutex::new(db), }); @@ -102,6 +112,11 @@ pub fn run() { commands::open_workshop, commands::open_url, commands::steam_sync, + commands::fetch_workshop_html, + commands::delete_addons, + commands::download_addon, + commands::fetch_collection, + commands::add_known_addon, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.tsx b/src/App.tsx index 9d2ca91..055579e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,11 +20,14 @@ import { WorkshopActionWarningModal } from './components/WorkshopActionWarningMo import { StatsBar } from './components/StatsBar'; import { GroupHeader } from './components/GroupHeader'; import { BatchActionBar } from './components/BatchActionBar'; +import { KnownUninstalledView } from './components/KnownUninstalledView'; +import { WorkshopBrowser } from './components/WorkshopBrowser'; function App() { const { t } = useTranslation(); const { addons, + knownUninstalledAddons, groups, settings, loading, @@ -44,6 +47,7 @@ function App() { setIsSelectMode, isSubmitting, setIsSubmitting, + downloadProgress, // Modals state detailModal, @@ -90,6 +94,8 @@ function App() { handleBatchRename, handleBatchAddToGroup, handleFilterTabChange, + downloadAddon, + deleteAddons, // Derived values filteredItems, @@ -132,6 +138,7 @@ function App() { autoGrouping={autoGrouping} disabledCount={disabledCount} totalAddonsCount={totalAddonsCount} + knownUninstalledCount={Object.keys(knownUninstalledAddons).length} /> {/* Main Panel */} @@ -142,6 +149,28 @@ function App() { isSubmitting={isSubmitting} onConfirm={saveSettings} /> + ) : currentFilterTab === 'known-uninstalled' ? ( + + ) : currentFilterTab === 'workshop-browser' ? ( + { + const vpkNames = itemIds.map(id => `${id}.vpk`); + await handleCreateGroup(name, vpkNames); + }} + isSubmitting={isSubmitting} + /> ) : ( <> setEditGroupModal({ open: true, groupId: currentGroup.id, name: currentGroup.name })} + onRenameGroup={() => setEditGroupModal({ + open: true, + groupId: currentGroup.id, + name: currentGroup.name, + tags: currentGroup.tags || [], + workshopCollectionId: currentGroup.workshopCollectionId || '' + })} onDeleteGroup={() => handleDeleteGroup(currentGroup.id)} onGroupActionBatch={(actionType) => groupActionBatch(currentGroup.id, actionType)} /> @@ -328,9 +363,12 @@ function App() { open={editGroupModal.open} groupId={editGroupModal.groupId} currentName={editGroupModal.name} + currentTags={editGroupModal.tags} + currentCollectionId={editGroupModal.workshopCollectionId} + addonsInGroup={currentGroup ? currentGroup.addons.map(name => addons[name] || knownUninstalledAddons[name]).filter(Boolean) : []} isSubmitting={isSubmitting} onCancel={() => { - setEditGroupModal({ open: false, groupId: '', name: '' }); + setEditGroupModal({ open: false, groupId: '', name: '', tags: [], workshopCollectionId: '' }); setIsSubmitting(false); }} onConfirm={handleRenameGroup} diff --git a/src/components/EditGroupModal.tsx b/src/components/EditGroupModal.tsx index 673e163..7eac212 100644 --- a/src/components/EditGroupModal.tsx +++ b/src/components/EditGroupModal.tsx @@ -1,12 +1,18 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { Addon } from '../types/addon'; +import { getAddonCategories } from '../utils/addonHelpers'; +import { Sparkles, AlertTriangle } from 'lucide-react'; interface EditGroupModalProps { open: boolean; groupId: string; currentName: string; + currentTags?: string[]; + currentCollectionId?: string; + addonsInGroup: Addon[]; onCancel: () => void; - onConfirm: (groupId: string, newName: string) => void; + onConfirm: (groupId: string, newName: string, tags: string[], collectionId?: string) => void; isSubmitting?: boolean; } @@ -14,34 +20,68 @@ export const EditGroupModal: React.FC = ({ open, groupId, currentName, + currentTags = [], + currentCollectionId = '', + addonsInGroup, onCancel, onConfirm, isSubmitting = false, }) => { const { t } = useTranslation(); const [name, setName] = useState(''); + const [tagsInput, setTagsInput] = useState(''); + const [collectionId, setCollectionId] = useState(''); useEffect(() => { if (open) { setName(currentName); + setTagsInput(currentTags.join(', ')); + setCollectionId(currentCollectionId); } - }, [open, currentName]); + }, [open, currentName, currentTags, currentCollectionId]); if (!open) return null; + const handleAutoAllocateTags = () => { + const categories = new Set(); + addonsInGroup.forEach(addon => { + getAddonCategories(addon).forEach(cat => categories.add(cat)); + }); + setTagsInput(Array.from(categories).join(', ')); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!name.trim() || isSubmitting) return; - onConfirm(groupId, name.trim()); + + const tags = tagsInput + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + const targetCollectionId = collectionId.trim() || undefined; + + // If collection ID was added/changed, we can prompt or alert + if (targetCollectionId !== (currentCollectionId || '') && targetCollectionId) { + const confirmSync = window.confirm( + '设置创意工坊合集后,保存时将自动从 Steam 创意工坊拉取该合集内的所有组件,并丢弃当前分组的所有其它自定义分类组件。确认继续吗?' + ); + if (!confirmSync) return; + } + + onConfirm(groupId, name.trim(), tags, targetCollectionId); }; + const isCollectionChanged = collectionId.trim() !== (currentCollectionId || ''); + return (
-
e.stopPropagation()}> -

{t('editGroupModal.title')}

+ e.stopPropagation()} style={{ maxWidth: '480px', borderRadius: '28px', padding: '24px' }}> +

编辑分组

-
- + {/* Group Name */} +
+ = ({ onChange={(e) => setName(e.target.value)} required disabled={isSubmitting} + style={{ borderRadius: '12px' }} />
-
+ {/* Group Tags */} +
+
+ + +
+ setTagsInput(e.target.value)} + placeholder="例如: 战役, 武器模型, 皮肤" + disabled={isSubmitting} + style={{ borderRadius: '12px' }} + /> +
+ + {/* Steam Workshop Collection ID */} +
+ + setCollectionId(e.target.value)} + placeholder="例如: 3560901999" + disabled={isSubmitting} + style={{ borderRadius: '12px' }} + /> +
+ + {/* Warning Alert */} + {isCollectionChanged && collectionId.trim() && ( +
+ +
+ 安全警告:设置合集 ID 后,保存将自动覆盖该分组的文件列表,所有不属于该合集的自定义归类组件将被移出当前分组。 +
+
+ )} + +
-
diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index d04731f..97a301e 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -121,6 +121,16 @@ export const GroupCard: React.FC = ({

{groupDesc}

+ + {group.tags && group.tags.length > 0 && ( +
+ {group.tags.map(tag => ( + + {tag} + + ))} +
+ )}
{t('groupCard.totalSize', { size: formatBytes(groupSize) })} diff --git a/src/components/KnownUninstalledView.tsx b/src/components/KnownUninstalledView.tsx new file mode 100644 index 0000000..b26c4b8 --- /dev/null +++ b/src/components/KnownUninstalledView.tsx @@ -0,0 +1,331 @@ +import React, { useState } from 'react'; +import { Download, Trash2, Globe, Search, RefreshCw, AlertCircle } from 'lucide-react'; +import { Addon } from '../types/addon'; +import { getAddonAuthor, getAddonCategories, formatBytes } from '../utils/addonHelpers'; +import { invoke } from '@tauri-apps/api/core'; + +interface KnownUninstalledViewProps { + knownUninstalledAddons: Record; + downloadProgress: Record; + onDownload: (workshopId: string) => void; + onDelete: (vpkNames: string[], deleteFile: boolean, removeFromKnown: boolean) => void; + isSubmitting: boolean; + onOpenLink: (url: string) => void; +} + +export const KnownUninstalledView: React.FC = ({ + knownUninstalledAddons, + downloadProgress, + onDownload, + onDelete, + isSubmitting, + onOpenLink, +}) => { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + const [isSelectMode, setIsSelectMode] = useState(false); + + const addonsList = Object.values(knownUninstalledAddons); + + // Search filter + const filteredAddons = addonsList.filter((addon) => { + const q = searchQuery.toLowerCase(); + const title = (addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName || '').toLowerCase(); + const desc = (addon.steamDetails?.description || addon.addonInfo?.addondescription || '').toLowerCase(); + const author = getAddonAuthor(addon).toLowerCase(); + const wId = (addon.workshopId || ''); + return title.includes(q) || desc.includes(q) || author.includes(q) || wId.includes(q); + }); + + const handleSelectToggle = (id: string) => { + setSelectedIds((prev) => { + const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]; + if (next.length > 0 && !isSelectMode) { + setIsSelectMode(true); + } + return next; + }); + }; + + const handleSelectAll = () => { + const allIds = filteredAddons.map((ad) => ad.vpkName); + const allSelected = allIds.every((id) => selectedIds.includes(id)); + if (allSelected) { + setSelectedIds((prev) => prev.filter((id) => !allIds.includes(id))); + } else { + setSelectedIds((prev) => { + const next = [...prev]; + allIds.forEach((id) => { + if (!next.includes(id)) next.push(id); + }); + return next; + }); + } + }; + + const handleClearSelection = () => { + setSelectedIds([]); + setIsSelectMode(false); + }; + + const handleBatchDownload = async () => { + const idsToDownload = selectedIds + .map((vpkName) => knownUninstalledAddons[vpkName]) + .filter(Boolean) + .map((addon) => addon.workshopId) + .filter(Boolean) as string[]; + + if (idsToDownload.length === 0) return; + + for (const wId of idsToDownload) { + onDownload(wId); + } + handleClearSelection(); + }; + + const handleBatchDelete = () => { + onDelete(selectedIds, false, true); + handleClearSelection(); + }; + + return ( +
+
+
+

+ 未安装但已知的 Addon +

+

+ 管理之前扫描过、在合集中或手动加入但当前未在本地文件夹中的组件。 +

+
+ +
+ +
+
+ + {/* Action Bar / Search */} +
+
+ + setSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '12px 16px 12px 48px', + borderRadius: '28px', + border: '1px solid var(--md-sys-color-outline-variant)', + backgroundColor: 'var(--md-sys-color-surface-container-high)', + color: 'var(--md-sys-color-on-surface)', + outline: 'none', + fontSize: '14px' + }} + /> +
+
+ + {/* Floating Batch Actions */} + {isSelectMode && selectedIds.length > 0 && ( +
+ 已选中 {selectedIds.length} 个项目 +
+ + + + +
+
+ )} + + {/* Grid of Addons */} + {filteredAddons.length === 0 ? ( +
+ +

没有找到匹配 of 已知组件

+
+ ) : ( +
+ {filteredAddons.map((addon) => { + const isDownloading = addon.workshopId ? downloadProgress[addon.workshopId] !== undefined : false; + const progress = addon.workshopId ? (downloadProgress[addon.workshopId] || 0) : 0; + const title = addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName; + const author = getAddonAuthor(addon); + const categories = getAddonCategories(addon); + + return ( +
isSelectMode && handleSelectToggle(addon.vpkName)} + > + {/* Checkbox for selection */} + {isSelectMode && ( +
+ {}} // handled by card onClick + style={{ width: '20px', height: '20px', cursor: 'pointer' }} + /> +
+ )} + + {/* Card Thumbnail */} +
+ {addon.imagePath ? ( + {title} { + (e.target as HTMLImageElement).src = ''; + }} + /> + ) : ( +
+ 暂无图片 +
+ )} + {addon.workshopId && ( +
+ ID: {addon.workshopId} +
+ )} +
+ + {/* Card Content */} +
+

+ {title} +

+ +

+ 作者: {author} +

+ + {/* Chips */} +
+ {categories.map((c) => ( + + {c} + + ))} + {addon.steamDetails?.file_size && ( + + 大小: {formatBytes(parseInt(addon.steamDetails.file_size))} + + )} +
+ + {/* Description summary */} + {addon.steamDetails?.description && ( +

+ {addon.steamDetails.description} +

+ )} + + {/* Actions */} + {!isSelectMode && ( +
+ {addon.workshopId ? ( + + ) : ( +
+ 本地组件无法下载 +
+ )} + + {addon.workshopId && ( + + )} + + +
+ )} +
+
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1719513..ec31bf7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Layers, Grid, FolderOpen, Folder, Lock, FolderPlus, Plus, Sparkles, Settings } from 'lucide-react'; +import { Layers, Grid, FolderOpen, Folder, Lock, FolderPlus, Plus, Sparkles, Settings, Download, Globe } from 'lucide-react'; import { Addon, Group } from '../types/addon'; import { useTranslation } from 'react-i18next'; @@ -14,6 +14,7 @@ interface SidebarProps { autoGrouping: boolean; disabledCount: number; totalAddonsCount: number; + knownUninstalledCount: number; } export const Sidebar: React.FC = ({ @@ -27,6 +28,7 @@ export const Sidebar: React.FC = ({ autoGrouping, disabledCount, totalAddonsCount, + knownUninstalledCount, }) => { const { t } = useTranslation(); const loadingCount = Object.values(addons).filter(a => a.dirType === 'loading').length; @@ -89,6 +91,27 @@ export const Sidebar: React.FC = ({ {disabledCount} + + + +
{t('sidebar.myGroups')}
diff --git a/src/components/WorkshopBrowser.tsx b/src/components/WorkshopBrowser.tsx new file mode 100644 index 0000000..471a4e3 --- /dev/null +++ b/src/components/WorkshopBrowser.tsx @@ -0,0 +1,453 @@ +/** + * WorkshopBrowser — main container for the built-in Steam Workshop browser. + * + * Splits into two views: + * • Homepage — section carousels (trending, most-subscribed, …) + * • Browse — filterable grid with tag sidebar + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { useTranslation } from 'react-i18next'; +import { + Search, ChevronLeft, ChevronRight, Loader2, + Home, Compass, User, Tag, +} from 'lucide-react'; + +import { + WorkshopItem, + HomepageSection, + TagCategory, + CollectionData, + WorkshopBrowserProps, +} from './workshop/types'; +import { + parseSSRItems, + parseHomepageSections, + parseTagCategories, +} from './workshop/ssrParser'; +import { ItemCard } from './workshop/ItemCard'; +import { TagBrowserModal } from './workshop/TagBrowserModal'; +import { SectionCarousel } from './workshop/SectionCarousel'; +import { WorkshopDetailModal } from './workshop/WorkshopDetailModal'; + +// ── Browse sort options ─────────────────────────────────────────────────────── + +const SORT_OPTIONS = [ + { value: 'trend', labelKey: 'workshop.browse.sortTrend' }, + { value: 'textsearch', labelKey: 'workshop.browse.sortTextSearch' }, + { value: 'totalprofiles', labelKey: 'workshop.browse.sortTotalProfiles' }, + { value: 'mostrecent', labelKey: 'workshop.browse.sortMostRecent' }, + { value: 'toprated', labelKey: 'workshop.browse.sortTopRated' }, + { value: 'lastupdated', labelKey: 'workshop.browse.sortLastUpdated' }, +] as const; + +// ── Main Component ──────────────────────────────────────────────────────────── + +export const WorkshopBrowser: React.FC = ({ + addons, + knownUninstalledAddons, + downloadProgress, + onDownload, + onOpenLink, + onImportCollection, + isSubmitting, +}) => { + const { t } = useTranslation(); + + // View mode + const [viewMode, setViewMode] = useState<'home' | 'browse' | 'search'>('home'); + const [query, setQuery] = useState(''); // input field value + const [committedQuery, setCommittedQuery] = useState(''); // actually fetched + const [sort, setSort] = useState('trend'); + const [section, setSection] = useState('readytouseitems'); + const [page, setPage] = useState(1); + const [creatorId, setCreatorId] = useState(null); + const [creatorName, setCreatorName] = useState(null); + const [activeTag, setActiveTag] = useState(null); + const [activeTagName, setActiveTagName] = useState(null); + + // Data + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + const [homepageSections, setHomepageSections] = useState([]); + const [homepageLoading, setHomepageLoading] = useState(false); + const [tagCategories, setTagCategories] = useState([]); + + // Detail modal + const [selectedItem, setSelectedItem] = useState(null); + const [selectedCollection, setSelectedCollection] = useState(null); + const [tagModalOpen, setTagModalOpen] = useState(false); + + // ── Fetch homepage ───────────────────────────────────────────────────────── + + const fetchHomepage = useCallback(async () => { + setHomepageLoading(true); + try { + const html: string = await invoke('fetch_workshop_html', { + url: 'https://steamcommunity.com/app/550/workshop/', + }); + setHomepageSections(parseHomepageSections(html)); + setTagCategories(parseTagCategories(html)); + } catch (err) { + console.error('Failed to fetch homepage:', err); + } finally { + setHomepageLoading(false); + } + }, []); + + useEffect(() => { + if (viewMode === 'home') fetchHomepage(); + }, [viewMode, fetchHomepage]); + + // ── Fetch browse items ───────────────────────────────────────────────────── + + const fetchItems = useCallback(async () => { + setLoading(true); + setError(null); + try { + let url = 'https://steamcommunity.com/workshop/browse/?appid=550'; + if (creatorId) { + url += `&browsesort=myfiles&creatorid=${creatorId}&p=${page}`; + } else { + url += `&searchtext=${encodeURIComponent(committedQuery)}&browsesort=${sort}§ion=${section}&p=${page}`; + if (activeTag) { + url += `&requiredtags[]=${encodeURIComponent(activeTagName || activeTag)}`; + } + } + const html: string = await invoke('fetch_workshop_html', { url }); + setItems(parseSSRItems(html, 'workshop_query')); + } catch (err) { + console.error(err); + setError(`${t('common.error')}: ${err}`); + } finally { + setLoading(false); + } + }, [committedQuery, sort, section, page, creatorId, activeTag, activeTagName, t]); + + useEffect(() => { + if (viewMode === 'browse' || viewMode === 'search') fetchItems(); + }, [viewMode, fetchItems]); + + // ── Navigation helpers ───────────────────────────────────────────────────── + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!query.trim()) return; + setCreatorId(null); + setCreatorName(null); + setActiveTag(null); + setActiveTagName(null); + setPage(1); + setCommittedQuery(query.trim()); + setViewMode('search'); + }; + + const handleCreatorClick = (id: string, name: string) => { + setCreatorId(id); + setCreatorName(name); + setPage(1); + setSelectedItem(null); + setSelectedCollection(null); + setViewMode('browse'); + }; + + const handleClearCreator = () => { setCreatorId(null); setCreatorName(null); setPage(1); }; + const handleClearTag = () => { setActiveTag(null); setActiveTagName(null); setPage(1); }; + + const handleTagClick = (tagId: string, tagName: string) => { + setActiveTag(tagId); + setActiveTagName(tagName); + setCreatorId(null); + setCreatorName(null); + setPage(1); + setViewMode('browse'); + }; + + const handleViewAllSection = (sec: HomepageSection) => { + setSort(sec.browseParams.sort); + setSection(sec.browseParams.section); + setPage(1); + setCreatorId(null); setCreatorName(null); + setActiveTag(null); setActiveTagName(null); + setViewMode('browse'); + }; + + const enterBrowseMode = () => { + setViewMode('browse'); + setCreatorId(null); setCreatorName(null); + setActiveTag(null); setActiveTagName(null); + setPage(1); setSort('trend'); setSection('readytouseitems'); + }; + + // ── Detail helpers ───────────────────────────────────────────────────────── + + /** Map raw Steam API detail → WorkshopItem for the modal */ + const mapSteamDetailToItem = (d: any): WorkshopItem => ({ + workshopId: d.publishedfileid || '', + title: (d.title || '').trim(), + imagePath: d.preview_url || '', + authorName: d.creator_name || '', + authorId: d.creator || '', + authorUrl: d.creator ? `https://steamcommunity.com/profiles/${d.creator}` : '', + stars: d.star_rating ?? 0, + shortDescription: d.short_description || d.description || '', + fileSize: d.file_size ? `${(parseInt(d.file_size) / 1024 / 1024).toFixed(1)} MB` : undefined, + tags: d.tags ? d.tags.map((t: any) => t.display_name || t.tag || '') : [], + subscriptions: d.subscriptions ? parseInt(d.subscriptions) : undefined, + timeCreated: d.time_created ? parseInt(d.time_created) : undefined, + timeUpdated: d.time_updated ? parseInt(d.time_updated) : undefined, + }); + + const viewItemDetails = async (workshopId: string) => { + try { + const data: any = await invoke('fetch_collection', { collectionId: workshopId }); + const raw = data.collection; + if (raw && raw.publishedfileid) { + setSelectedItem(mapSteamDetailToItem(raw)); + } + } catch (err) { + alert(t('workshop.detail.fetchFailed', { err: String(err) })); + } + }; + + const viewCollectionDetails = async (collectionId: string) => { + try { + const data: any = await invoke('fetch_collection', { collectionId }); + const raw = data.collection; + const rawItems: any[] = data.items || []; + if (raw && raw.publishedfileid) { + setSelectedCollection({ + title: (raw.title || '').trim(), + description: raw.description || '', + imagePath: raw.preview_url || '', + creatorName: raw.creator_name || '', + creatorId: raw.creator || '', + items: rawItems.map(mapSteamDetailToItem), + }); + } + } catch (err) { + alert(t('workshop.detail.fetchFailed', { err: String(err) })); + } + }; + + // ── Render: Homepage ─────────────────────────────────────────────────────── + + const renderHomepage = () => { + if (homepageLoading) { + return ( +
+ +

{t('workshop.home.loading')}

+
+ ); + } + if (homepageSections.length === 0) { + return ( +
+

{t('workshop.home.loadFailed')}

+ +
+ ); + } + return ( +
+ {homepageSections.map((sec) => ( + viewItemDetails(item.workshopId)} + onViewAll={handleViewAllSection} + /> + ))} +
+ ); + }; + + // ── Render: Browse view ──────────────────────────────────────────────────── + + const renderBrowseView = () => ( +
+ {/* Active filter chips */} + {(creatorName || activeTagName) && ( +
+ {creatorName && ( +
+ + {t('workshop.item.author', { author: creatorName })} + +
+ )} + {activeTagName && ( +
+ + {t('workshop.tags.label', { name: activeTagName })} + +
+ )} +
+ )} + + {/* Loading / Error / Items */} + {loading ? ( +
+ +

{t('workshop.browse.loading')}

+
+ ) : error ? ( +
+

{error}

+ +
+ ) : items.length === 0 ? ( +
+

{t('workshop.browse.empty')}

+
+ ) : ( + <> +
+ {items.map((item) => ( + section === 'collections' ? viewCollectionDetails(item.workshopId) : viewItemDetails(item.workshopId)} + /> + ))} +
+ {/* Pagination */} +
+ + {t('workshop.browse.page', { page })} + +
+ + )} +
+ ); + + // ── Main render ──────────────────────────────────────────────────────────── + + return ( +
+ {/* Top navigation bar */} +
+
+ + +
+ + +
+ + setQuery(e.target.value)} + style={{ width: '100%', padding: '8px 14px 8px 40px', borderRadius: '100px', border: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'var(--md-sys-color-surface-container-high)', color: 'var(--md-sys-color-on-surface)', outline: 'none', fontSize: '13px' }} + /> +
+ + + + {viewMode === 'browse' && !creatorId && ( + <> + +
+ + +
+ + )} + + {/* Tag browser button */} + {tagCategories.length > 0 && ( + + )} +
+ + {/* Content area */} +
+ {viewMode === 'home' ? renderHomepage() : renderBrowseView()} +
+ + {/* Detail modal */} + { setSelectedItem(null); setSelectedCollection(null); }} + onDownload={onDownload} + onOpenLink={onOpenLink} + onImportCollection={onImportCollection} + addons={addons} + knownUninstalledAddons={knownUninstalledAddons} + downloadProgress={downloadProgress} + isSubmitting={isSubmitting} + /> + + {/* Tag browser modal */} + setTagModalOpen(false)} + onTagClick={handleTagClick} + /> +
+ ); +}; diff --git a/src/components/workshop/DetailDrawer.tsx b/src/components/workshop/DetailDrawer.tsx new file mode 100644 index 0000000..e68aa9d --- /dev/null +++ b/src/components/workshop/DetailDrawer.tsx @@ -0,0 +1,525 @@ +/** Workshop item/collection detail drawer */ + +import React from 'react'; +import { Download, Globe, FolderPlus, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { CollectionData } from './types'; + +interface DetailDrawerProps { + selectedItem: any | null; + selectedCollection: CollectionData | null; + selectedItemLoading: boolean; + selectedCollectionLoading: boolean; + downloadProgress: Record; + isSubmitting: boolean; + onClose: () => void; + onDownload: (workshopId: string) => void; + onOpenLink: (url: string) => void; + onCreatorClick: (id: string, name: string) => void; + onTagClick: (tagId: string, tagName: string) => void; + onAddToKnownList: (item: any) => void; + onImportCollection: (name: string, itemIds: string[]) => void; +} + +export const DetailDrawer: React.FC = ({ + selectedItem, + selectedCollection, + selectedItemLoading, + selectedCollectionLoading, + downloadProgress, + isSubmitting, + onClose, + onDownload, + onOpenLink, + onCreatorClick, + onTagClick, + onAddToKnownList, + onImportCollection, +}) => { + const { t } = useTranslation(); + + const handleImportCollectionGroup = () => { + if (!selectedCollection) return; + const name = selectedCollection.collection.title || t('workshop.detail.defaultCollectionName'); + const itemIds = selectedCollection.items.map((item) => item.publishedfileid); + onImportCollection(name, itemIds); + alert(t('workshop.detail.importSuccess', { count: itemIds.length, name })); + }; + + const handleDownloadAllCollection = () => { + if (!selectedCollection) return; + const itemIds = selectedCollection.items.map((item) => item.publishedfileid); + itemIds.forEach((id) => onDownload(id)); + alert(t('workshop.detail.downloadAllSuccess', { count: itemIds.length })); + }; + + if (!selectedItem && !selectedCollection && !selectedItemLoading && !selectedCollectionLoading) { + return null; + } + + return ( +
+ + + {selectedItemLoading || selectedCollectionLoading ? ( +
+ +

{t('workshop.detail.loading')}

+
+ ) : selectedItem ? ( + + ) : selectedCollection ? ( + + ) : null} +
+ ); +}; + +// ── Item detail sub-component ────────────────────────────────────────────────── + +interface ItemDetailProps { + item: any; + downloadProgress: Record; + isSubmitting: boolean; + onDownload: (workshopId: string) => void; + onOpenLink: (url: string) => void; + onCreatorClick: (id: string, name: string) => void; + onTagClick: (tagId: string, tagName: string) => void; + onAddToKnownList: (item: any) => void; +} + +const ItemDetail: React.FC = ({ + item, + downloadProgress, + isSubmitting, + onDownload, + onOpenLink, + onCreatorClick, + onTagClick, + onAddToKnownList, +}) => { + const { t } = useTranslation(); + + return ( +
+ {item.title} +

+ {item.title} +

+
+ + {t('workshop.detail.author')}: + + +
+
+ {item.tags && + item.tags.map((t: any) => ( + onTagClick(t.tag, t.tag)} + style={{ + padding: '2px 8px', + borderRadius: '8px', + fontSize: '11px', + fontWeight: 500, + backgroundColor: 'var(--md-sys-color-secondary-container)', + color: 'var(--md-sys-color-on-secondary-container)', + cursor: 'pointer', + }} + > + {t.tag} + + ))} +
+
+

+ {t('workshop.detail.description')} +

+

+ {item.description} +

+
+
+ + +
+ + +
+
+
+ ); +}; + +// ── Collection detail sub-component ──────────────────────────────────────────── + +interface CollectionDetailProps { + collection: CollectionData; + onOpenLink: (url: string) => void; + onImportGroup: () => void; + onDownloadAll: () => void; +} + +const CollectionDetail: React.FC = ({ + collection, + onOpenLink, + onImportGroup, + onDownloadAll, +}) => { + const { t } = useTranslation(); + + return ( +
+ {collection.collection.title} +

+ {collection.collection.title} +

+ + {t('workshop.detail.collectionAuthor', { + author: collection.collection.creator_name || 'Steam', + })} + +
+

+ {collection.collection.description} +

+
+
+

+ {t('workshop.detail.collectionItems', { count: collection.items.length })} +

+
+ {collection.items.map((item) => ( +
+ +
+
+ {item.title} +
+
+ ID: {item.publishedfileid} +
+
+
+ ))} +
+
+
+ + +
+ + +
+
+
+ ); +}; diff --git a/src/components/workshop/ItemCard.tsx b/src/components/workshop/ItemCard.tsx new file mode 100644 index 0000000..a7f5326 --- /dev/null +++ b/src/components/workshop/ItemCard.tsx @@ -0,0 +1,116 @@ +/** Workshop item card component */ + +import React from 'react'; +import { Star } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { WorkshopItem } from './types'; + +interface ItemCardProps { + item: WorkshopItem; + section: string; + addons: Record; + knownUninstalledAddons: Record; + onClick: () => void; +} + +export const ItemCard: React.FC = ({ + item, + section, + addons, + knownUninstalledAddons, + onClick, +}) => { + const { t } = useTranslation(); + const isDownloaded = addons[item.workshopId + '.vpk'] !== undefined; + const isKnown = knownUninstalledAddons[item.workshopId + '.vpk'] !== undefined; + + return ( +
+
+
+ {item.title} +
+
+ {isDownloaded && ( + {t('workshop.badges.downloaded')} + )} + {!isDownloaded && isKnown && ( + + {t('workshop.badges.known')} + + )} + + {section === 'collections' ? t('workshop.badges.collection') : t('workshop.badges.addon')} + +
+
+

+ {item.title} +

+
+ {t('workshop.item.author', { author: item.authorName })} +
+ {item.shortDescription && ( +

+ {item.shortDescription} +

+ )} +
+ {item.fileSize && {t('workshop.item.size', { size: item.fileSize })}} + {item.stars > 0 && ( +
+ + {item.stars} +
+ )} + {item.subscriptions !== undefined && item.subscriptions > 0 && ( + {t('workshop.item.subscriptions', { count: item.subscriptions.toLocaleString() })} + )} +
+ {item.tags && item.tags.length > 0 && ( +
+ {item.tags.slice(0, 3).map((t) => ( + + {t} + + ))} +
+ )} +
+
+
+ ); +}; diff --git a/src/components/workshop/SectionCarousel.tsx b/src/components/workshop/SectionCarousel.tsx new file mode 100644 index 0000000..a7b7bc0 --- /dev/null +++ b/src/components/workshop/SectionCarousel.tsx @@ -0,0 +1,110 @@ +/** Homepage section carousel component */ + +import React from 'react'; +import { ChevronRight, Clock, Flame, Package, Users, RefreshCw, Star } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { HomepageSection, WorkshopItem } from './types'; +import { ItemCard } from './ItemCard'; + +const ICON_MAP: Record = { + Clock: , + Flame: , + Package: , + Users: , + RefreshCw: , + Star: , +}; + +interface SectionCarouselProps { + section: HomepageSection; + sectionType: string; + addons: Record; + knownUninstalledAddons: Record; + onItemClick: (item: WorkshopItem) => void; + onViewAll: (section: HomepageSection) => void; +} + +export const SectionCarousel: React.FC = ({ + section, + sectionType, + addons, + knownUninstalledAddons, + onItemClick, + onViewAll, +}) => { + const { t } = useTranslation(); + + if (section.items.length === 0) return null; + + return ( +
+
+
+
+ {ICON_MAP[section.icon as string] || } +
+
+

+ {t(section.title)} +

+

+ {t(section.subtitle)} +

+
+
+ +
+
+ {section.items.slice(0, 6).map((item) => ( + onItemClick(item)} + /> + ))} +
+
+ ); +}; diff --git a/src/components/workshop/TagBrowserModal.tsx b/src/components/workshop/TagBrowserModal.tsx new file mode 100644 index 0000000..6c4855c --- /dev/null +++ b/src/components/workshop/TagBrowserModal.tsx @@ -0,0 +1,80 @@ +/** Tag browser modal — replaces the sidebar tag panel */ + +import React from 'react'; +import { X, Tag } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { TagCategory } from './types'; + +interface TagBrowserModalProps { + open: boolean; + categories: TagCategory[]; + activeTag: string | null; + onClose: () => void; + onTagClick: (tagId: string, tagName: string) => void; +} + +export const TagBrowserModal: React.FC = ({ + open, + categories, + activeTag, + onClose, + onTagClick, +}) => { + const { t } = useTranslation(); + if (!open || categories.length === 0) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ {t('workshop.tags.browse')} +

+ +
+ + {categories.map((cat) => ( +
+
+ {cat.name} +
+
+ {cat.tags.map((tag) => ( + + ))} +
+
+ ))} +
+
+ ); +}; diff --git a/src/components/workshop/TagSidebar.tsx b/src/components/workshop/TagSidebar.tsx new file mode 100644 index 0000000..296acee --- /dev/null +++ b/src/components/workshop/TagSidebar.tsx @@ -0,0 +1,91 @@ +/** Tag category sidebar for filtering workshop items */ + +import React from 'react'; +import { Tag } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { TagCategory } from './types'; + +interface TagSidebarProps { + categories: TagCategory[]; + activeTag: string | null; + onTagClick: (tagId: string, tagName: string) => void; +} + +export const TagSidebar: React.FC = ({ + categories, + activeTag, + onTagClick, +}) => { + const { t } = useTranslation(); + + if (categories.length === 0) return null; + + return ( +
+

+ {t('workshop.tags.browse')} +

+ {categories.map((cat) => ( +
+
+ {cat.name} +
+
+ {cat.tags.map((tag) => ( + + ))} +
+
+ ))} +
+ ); +}; diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx new file mode 100644 index 0000000..056c632 --- /dev/null +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -0,0 +1,287 @@ +import React from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { X, Download, ExternalLink, CheckCircle, PlusCircle, FileText, FolderPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { WorkshopItem, WorkshopBrowserProps } from './types'; +import { CacheImage } from '../CacheImage'; + +interface CollectionData { + title: string; + description: string; + imagePath: string; + creatorName: string; + creatorId: string; + items: WorkshopItem[]; +} + +interface WorkshopDetailModalProps { + open: boolean; + item: WorkshopItem | null; + collection: CollectionData | null; + onClose: () => void; + onDownload: WorkshopBrowserProps['onDownload']; + onOpenLink: WorkshopBrowserProps['onOpenLink']; + onImportCollection: WorkshopBrowserProps['onImportCollection']; + addons: Record; + knownUninstalledAddons: Record; + downloadProgress: Record; + isSubmitting: boolean; +} + +export const WorkshopDetailModal: React.FC = ({ + open, + item, + collection, + onClose, + onDownload, + onOpenLink, + onImportCollection, + addons, + knownUninstalledAddons, + downloadProgress, + isSubmitting, +}) => { + const { t } = useTranslation(); + if (!open || (!item && !collection)) return null; + + // ── Collection detail ──────────────────────────────────────────────────────── + if (collection) { + const allWorkshopIds = collection.items.map((i) => i.workshopId); + const allDownloaded = allWorkshopIds.every((id) => addons[id]); + const allKnown = allWorkshopIds.every((id) => knownUninstalledAddons[id]); + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{t('workshop.detail.collectionTitle')}

+ +
+ +
+ {/* Left: preview */} +
+
+ {collection.imagePath ? ( + + ) : ( + + )} +
+ +
+
+ {t('workshop.detail.collectionAuthor')} + {collection.creatorName} +
+
+ {t('workshop.detail.collectionItems')} + {t('workshop.detail.collectionItemsCount', { count: collection.items.length })} +
+
+
+ + {/* Right: description + items */} +
+

{collection.title}

+ {collection.description && ( + <> +
{t('workshop.detail.descriptionLabel')}
+
{collection.description}
+ + )} + +
+ {t('workshop.detail.itemsInCollection')} +
+
+ {collection.items.map((ci) => { + const isKnown = !!knownUninstalledAddons[ci.workshopId]; + const isDownloaded = !!addons[ci.workshopId]; + return ( +
+ {ci.imagePath ? ( + + ) : ( +
+ +
+ )} +
+
{ci.title}
+
{ci.authorName}
+
+ {isDownloaded && } + {!isDownloaded && isKnown && {t('workshop.badges.known')}} +
+ ); + })} +
+
+
+ + {/* Bottom actions */} +
+
+ + +
+ +
+
+
+ ); + } + + // ── Item detail ────────────────────────────────────────────────────────────── + if (!item) return null; + + const isKnown = !!knownUninstalledAddons[item.workshopId]; + const isDownloaded = !!addons[item.workshopId]; + const downloading = downloadProgress[item.workshopId] !== undefined; + const progress = downloadProgress[item.workshopId]; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{t('workshop.detail.title')}

+ +
+ +
+ {/* Left: preview + metadata */} +
+
+ {item.imagePath ? ( + + ) : ( + + )} +
+ +
+ {item.fileSize && ( +
+ {t('workshop.detail.itemSize')} + {item.fileSize} +
+ )} + {item.workshopId && ( +
+ {t('workshop.detail.workshopId')} + {item.workshopId} +
+ )} +
+ {t('workshop.detail.status')} + + {isDownloaded ? t('workshop.badges.downloaded') : isKnown ? t('workshop.badges.known') : t('workshop.badges.notDownloaded')} + +
+
+
+ + {/* Right: title, author, tags, description */} +
+

{item.title}

+ + + + {item.tags && item.tags.length > 0 && ( +
+ {item.tags.map((tag, i) => ( + {tag} + ))} +
+ )} + + {item.shortDescription && ( + <> +
{t('workshop.detail.descriptionLabel')}
+
{item.shortDescription}
+ + )} +
+
+ + {/* Bottom actions */} +
+
+ + {!isDownloaded && ( + + )} + { e.preventDefault(); onOpenLink(`steam://url/CommunityFilePage/${item.workshopId}`); }} className="btn btn-text" style={{ display: 'inline-flex', gap: '6px' }}> + + {t('workshop.detail.openInSteam')} + + { e.preventDefault(); onOpenLink(`https://steamcommunity.com/sharedfiles/filedetails/?id=${item.workshopId}`); }} className="btn btn-text" style={{ display: 'inline-flex', gap: '6px' }}> + {t('workshop.detail.viewOnSteam')} + +
+
+
+
+ ); +}; diff --git a/src/components/workshop/index.ts b/src/components/workshop/index.ts new file mode 100644 index 0000000..f966190 --- /dev/null +++ b/src/components/workshop/index.ts @@ -0,0 +1,8 @@ +/** Workshop component exports */ + +export { ItemCard } from './ItemCard'; +export { TagBrowserModal } from './TagBrowserModal'; +export { SectionCarousel } from './SectionCarousel'; +export { WorkshopDetailModal } from './WorkshopDetailModal'; +export { parseSSRItems, parseHomepageSections, parseTagCategories } from './ssrParser'; +export * from './types'; diff --git a/src/components/workshop/ssrParser.ts b/src/components/workshop/ssrParser.ts new file mode 100644 index 0000000..1364771 --- /dev/null +++ b/src/components/workshop/ssrParser.ts @@ -0,0 +1,383 @@ +/** SSR parsing utilities for Steam Workshop pages */ + +import { formatBytes } from '../../utils/addonHelpers'; +import { WorkshopItem, HomepageSection, TagCategory } from './types'; + +// ── Helper: find balanced {…} from a start position in HTML ─────────────────── + +function extractBalancedJson(html: string, startIdx: number): string | null { + if (html[startIdx] !== '{') return null; + let depth = 0; + let inStr = false; + let escaped = false; + for (let i = startIdx; i < html.length; i++) { + const ch = html[i]; + if (escaped) { escaped = false; continue; } + if (ch === '\\') { escaped = true; continue; } + if (ch === '"') { inStr = !inStr; continue; } + if (inStr) continue; + if (ch === '{') depth++; + if (ch === '}') { depth--; if (depth === 0) return html.slice(startIdx, i + 1); } + } + return null; +} + +// ── Helper: extract SSR renderContext & queryData ───────────────────────────── + +function extractSSRQueryData(html: string): any | null { + try { + // Pattern 1: window.SSR.renderContext=JSON.parse("…"); + // Steam Workshop uses this format. The JSON string contains escaped quotes. + const jpPrefix = 'window.SSR.renderContext=JSON.parse("'; + const jpIdx = html.indexOf(jpPrefix); + if (jpIdx !== -1) { + const jsonStart = jpIdx + jpPrefix.length; + // Find closing "); — scan for unescaped " followed by ); + let jsonEnd = -1; + let esc = false; + for (let i = jsonStart; i < html.length; i++) { + if (esc) { esc = false; continue; } + if (html[i] === '\\') { esc = true; continue; } + if (html[i] === '"' && html[i + 1] === ')' && html[i + 2] === ';') { + jsonEnd = i; + break; + } + } + if (jsonEnd !== -1) { + const rawJson = html.slice(jsonStart, jsonEnd); + // Unescape JS string escapes: \" → ", \\ → \ + const rcJson = rawJson.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + const rc = JSON.parse(rcJson); + const qd = rc?.queryData; + return typeof qd === 'string' ? JSON.parse(qd) : qd; + } + } + + // Pattern 2: window.SSR.renderContext={…}; + const directPrefix = 'window.SSR.renderContext='; + const directIdx = html.indexOf(directPrefix); + if (directIdx !== -1) { + const braceStart = directIdx + directPrefix.length; + if (html[braceStart] === '{') { + const jsonStr = extractBalancedJson(html, braceStart); + if (jsonStr) { + const rc = new Function(`return ${jsonStr}`)(); + const qd = rc?.queryData; + return typeof qd === 'string' ? JSON.parse(qd) : qd; + } + } + } + + return null; + } catch { + return null; + } +} + +// ── Helper: extract items from a single SSR query entry ─────────────────────── + +function extractItemsFromQuery(query: any, domMap: Map): WorkshopItem[] { + const items: WorkshopItem[] = []; + const data = query?.state?.data; + if (!data) return items; + + // Two data formats: + // Homepage (workshop_query): numeric keys → { "0": {publishedfileid, ...}, "1": ... } + // Browse (workshop_browse): data.results → [{publishedfileid, ...}, ...] + const rawItems: any[] = []; + if (Array.isArray(data.results)) { + rawItems.push(...data.results); + } else { + const keys = Object.keys(data).filter((k) => !isNaN(Number(k))); + for (const k of keys) { + if (data[k]) rawItems.push(data[k]); + } + } + + for (const item of rawItems) { + if (!item?.publishedfileid) continue; + const workshopId = item.publishedfileid; + const domData = domMap.get(workshopId) || { authorName: '', authorUrl: '', stars: 0 }; + let authorId = ''; + if (domData.authorUrl) { + const idMatch = domData.authorUrl.match(/(?:profiles|id)\/([^\/]+)/); + if (idMatch) authorId = idMatch[1]; + } + // Prefer SSR creator_player_link_details for author info (browse page) + let authorName = domData.authorName; + let authorUrl = domData.authorUrl; + if (!authorName && item.creator) { + // creator is steamid64, build profile URL + authorUrl = `https://steamcommunity.com/profiles/${item.creator}`; + } + // Check query-level creator_player_link_details + const creatorDetails = query?.state?.data?.creator_player_link_details; + if (!authorName && creatorDetails && item.creator) { + const cd = creatorDetails[item.creator]; + if (cd?.persona_name) { + authorName = cd.persona_name; + } + } + items.push({ + workshopId, + title: (item.title || '').trim(), + imagePath: item.preview_url || '', + authorName, + authorId, + authorUrl, + stars: item.star_rating !== undefined ? item.star_rating : domData.stars, + shortDescription: item.short_description || '', + fileSize: item.file_size ? formatBytes(parseInt(item.file_size)) : undefined, + tags: item.tags ? item.tags.map((t: any) => t.display_name || t.tag) : [], + subscriptions: item.subscriptions, + timeCreated: item.time_created, + timeUpdated: item.time_updated, + }); + } + return items; +} + +// ── Helper: parse DOM cards into a lookup map ───────────────────────────────── + +function parseDOMCardMap(html: string): Map { + const map = new Map(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const cardXPath = + "//div[contains(@class, ' Panel') and starts-with(./div/a/@href, 'https://steamcommunity.com/sharedfiles/filedetails/?id=')]"; + const snapshot = doc.evaluate(cardXPath, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + + for (let i = 0; i < snapshot.snapshotLength; i++) { + const card = snapshot.snapshotItem(i) as HTMLElement; + const linkVal = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[1]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + const m = linkVal.match(/id=(\d+)/); + if (!m) continue; + const workshopId = m[1]; + const authorUrl = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + let authorName = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/text()", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + if (/^by\s+/i.test(authorName)) authorName = authorName.replace(/^by\s+/i, ''); + const stars = parseInt( + doc.evaluate("count(.//svg[contains(@class, 'SVGIcon_Star_Filled')])", + card, null, XPathResult.NUMBER_TYPE, null, + ).numberValue.toString(), + ) || 0; + map.set(workshopId, { authorName, authorUrl, stars }); + } + return map; +} + +// ── Helper: parse workshop items from a single DOM card ─────────────────────── + +function parseDOMFallbackItems(html: string): WorkshopItem[] { + const items: WorkshopItem[] = []; + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const cardXPath = + "//div[contains(@class, ' Panel') and starts-with(./div/a/@href, 'https://steamcommunity.com/sharedfiles/filedetails/?id=')]"; + const snapshot = doc.evaluate(cardXPath, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + + for (let i = 0; i < snapshot.snapshotLength; i++) { + const card = snapshot.snapshotItem(i) as HTMLElement; + const linkVal = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[1]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + const m = linkVal.match(/id=(\d+)/); + if (!m) continue; + const workshopId = m[1]; + let title = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[2]", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + if (!title) { + title = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[1]", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + } + const imagePath = doc.evaluate(".//img/@src", card, null, XPathResult.STRING_TYPE, null).stringValue; + const authorUrl = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + let authorName = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/text()", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + if (/^by\s+/i.test(authorName)) authorName = authorName.replace(/^by\s+/i, ''); + let authorId = ''; + if (authorUrl) { + const idMatch = authorUrl.match(/(?:profiles|id)\/([^\/]+)/); + if (idMatch) authorId = idMatch[1]; + } + const stars = parseInt( + doc.evaluate("count(.//svg[contains(@class, 'SVGIcon_Star_Filled')])", + card, null, XPathResult.NUMBER_TYPE, null, + ).numberValue.toString(), + ) || 0; + items.push({ + workshopId, + title: title || '', + imagePath, + authorName: authorName || '', + authorId, + authorUrl, + stars, + }); + } + return items; +} + +// ── Main: parse SSR workshop items (for browse page) ───────────────────────── + +export function parseSSRItems( + html: string, + queryKeyPrefix: string, +): WorkshopItem[] { + const domMap = parseDOMCardMap(html); + + // Try SSR queryData — also try 'workshop_browse' as fallback + const queryDataObj = extractSSRQueryData(html); + if (queryDataObj?.queries) { + for (const prefix of [queryKeyPrefix, 'workshop_browse', 'workshop_query']) { + const matchingQueries = queryDataObj.queries.filter( + (q: any) => q?.queryKey?.[0] === prefix, + ); + if (matchingQueries.length > 0) { + const items = extractItemsFromQuery(matchingQueries[0], domMap); + if (items.length > 0) return items; + } + } + } + + // Fallback to pure DOM + return parseDOMFallbackItems(html); +} + +/** Parse the homepage SSR to extract all homepage sections. + * Parses the SSR queryData once, then distributes items to each section. */ +export function parseHomepageSections(html: string): HomepageSection[] { + const sectionDefs: Array<{ + id: string; + titleKey: string; + subtitleKey: string; + icon: string; + queryIndex: number; + browseParams: HomepageSection['browseParams']; + }> = [ + { + id: 'past-week', + titleKey: 'workshop.home.pastWeek', + subtitleKey: 'workshop.home.pastWeekDesc', + icon: 'Clock', + queryIndex: 0, + browseParams: { sort: 'trend', section: 'readytouseitems', days: 7 }, + }, + { + id: 'trending', + titleKey: 'workshop.home.trending', + subtitleKey: 'workshop.home.trendingDesc', + icon: 'Flame', + queryIndex: 1, + browseParams: { sort: 'trend', section: 'readytouseitems', days: 90 }, + }, + { + id: 'most-required', + titleKey: 'workshop.home.mostRequired', + subtitleKey: 'workshop.home.mostRequiredDesc', + icon: 'Package', + queryIndex: 4, + browseParams: { sort: 'trend', section: 'readytouseitems' }, + }, + { + id: 'most-subscribed', + titleKey: 'workshop.home.mostSubscribed', + subtitleKey: 'workshop.home.mostSubscribedDesc', + icon: 'Users', + queryIndex: 2, + browseParams: { sort: 'totalprofiles', section: 'readytouseitems' }, + }, + { + id: 'recently-updated', + titleKey: 'workshop.home.recentlyUpdated', + subtitleKey: 'workshop.home.recentlyUpdatedDesc', + icon: 'RefreshCw', + queryIndex: 3, + browseParams: { sort: 'mostrecent', section: 'readytouseitems' }, + }, + { + id: 'newest', + titleKey: 'workshop.home.newest', + subtitleKey: 'workshop.home.newestDesc', + icon: 'Star', + queryIndex: 5, + browseParams: { sort: 'mostrecent', section: 'readytouseitems' }, + }, + ]; + + // Parse SSR data once + const domMap = parseDOMCardMap(html); + const queryDataObj = extractSSRQueryData(html); + + // Collect all workshop_query entries from SSR + const allQueries: any[] = []; + if (queryDataObj?.queries) { + for (const q of queryDataObj.queries) { + if (q?.queryKey?.[0] === 'workshop_query') { + allQueries.push(q); + } + } + } + + // Distribute items to sections by queryIndex + const sections: HomepageSection[] = []; + for (const def of sectionDefs) { + const query = allQueries[def.queryIndex]; + const items = query ? extractItemsFromQuery(query, domMap) : []; + // Skip sections with no data + if (items.length === 0) continue; + sections.push({ + id: def.id, + title: def.titleKey, + subtitle: def.subtitleKey, + icon: def.icon, + items, + browseParams: def.browseParams, + }); + } + + return sections; +} + +/** Parse declared_tags_v5 from homepage SSR to get the category tree */ +export function parseTagCategories(html: string): TagCategory[] { + try { + const queryDataObj = extractSSRQueryData(html); + if (!queryDataObj?.queries) return []; + + const tagsQuery = queryDataObj.queries.find( + (q: any) => q?.queryKey?.[0] === 'declared_tags_v5', + ); + if (!tagsQuery?.state?.data?.readytouse_tags) return []; + + return tagsQuery.state.data.readytouse_tags.map((cat: any) => ({ + name: cat.name, + tags: cat.tags.map((t: any) => ({ + id: t.id, + name: t.name, + display_name: t.display_name, + })), + })); + } catch { + return []; + } +} diff --git a/src/components/workshop/types.ts b/src/components/workshop/types.ts new file mode 100644 index 0000000..fc51bd8 --- /dev/null +++ b/src/components/workshop/types.ts @@ -0,0 +1,52 @@ +/** Shared types for the Workshop browser */ + +export interface WorkshopItem { + workshopId: string; + title: string; + imagePath: string; + authorName: string; + authorId: string; + authorUrl: string; + stars: number; + shortDescription?: string; + fileSize?: string; + tags?: string[]; + subscriptions?: number; + timeCreated?: number; + timeUpdated?: number; +} + +export interface CollectionData { + collection: any; + items: any[]; +} + +/** A single homepage section (e.g. "一周内物品", "最热门") */ +export interface HomepageSection { + id: string; + title: string; + subtitle: string; + icon: React.ReactNode; + items: WorkshopItem[]; + browseParams: { + sort: string; + section: string; + days?: number; + }; +} + +/** Tag category from declared_tags_v5 */ +export interface TagCategory { + name: string; + tags: { id: string; name: string; display_name: string }[]; +} + +export interface WorkshopBrowserProps { + addons: Record; + knownUninstalledAddons: Record; + downloadProgress: Record; + onDownload: (workshopId: string) => void; + onOpenLink: (url: string) => void; + onImportCollection: (name: string, itemIds: string[]) => void; + isSubmitting: boolean; +} diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index 4afd16e..a60880b 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { Addon, Group, Settings, Toast } from '../types/addon'; +import { listen } from '@tauri-apps/api/event'; +import { Addon, Group, Settings, Toast, DatabasePayload } from '../types/addon'; import { getAddonAuthor, getAddonCategories, getSuggestedVpkName, getAddonInfoValue } from '../utils/addonHelpers'; import { useTranslation } from 'react-i18next'; @@ -11,11 +12,12 @@ export type RenderedItem = export function useAddonManager() { const { t } = useTranslation(); const [addons, setAddons] = useState>({}); + const [knownUninstalledAddons, setKnownUninstalledAddons] = useState>({}); const [groups, setGroups] = useState([]); const [settings, setSettings] = useState({ workshopDir: '', loadingDir: '', enableDummyBypass: false }); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [currentFilterTab, setCurrentFilterTab] = useState('all'); // all, workshop, loading, disabled, groups + const [currentFilterTab, setCurrentFilterTab] = useState('all'); // all, workshop, loading, disabled, groups, known-uninstalled, workshop-browser const [selectedGroupId, setSelectedGroupId] = useState(null); const [selectedCategory, setSelectedCategory] = useState('All'); const [sortBy, setSortBy] = useState('title'); // title, size, id @@ -25,13 +27,14 @@ export function useAddonManager() { const [selectedVpkNames, setSelectedVpkNames] = useState([]); const [isSelectMode, setIsSelectMode] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [downloadProgress, setDownloadProgress] = useState>({}); // Modals state const [detailModal, setDetailModal] = useState<{ open: boolean; addon: Addon | null }>({ open: false, addon: null }); const [moveWarningModal, setMoveWarningModal] = useState<{ open: boolean; vpkName: string; currentDirType: string; workshopId: string }>({ open: false, vpkName: '', currentDirType: '', workshopId: '' }); const [renameModal, setRenameModal] = useState<{ open: boolean; currentName: string; title: string; suggestedName: string }>({ open: false, currentName: '', title: '', suggestedName: '' }); const [groupModal, setGroupModal] = useState<{ open: boolean }>({ open: false }); - const [editGroupModal, setEditGroupModal] = useState<{ open: boolean; groupId: string; name: string }>({ open: false, groupId: '', name: '' }); + const [editGroupModal, setEditGroupModal] = useState<{ open: boolean; groupId: string; name: string; tags: string[]; workshopCollectionId: string }>({ open: false, groupId: '', name: '', tags: [], workshopCollectionId: '' }); const [settingsModal, setSettingsModal] = useState<{ open: boolean; loadingDir: string }>({ open: false, loadingDir: '' }); const [linkConfirmModal, setLinkConfirmModal] = useState<{ open: boolean; url: string }>({ open: false, url: '' }); const [confirmModal, setConfirmModal] = useState<{ open: boolean; title: string; message: string; onConfirm: (() => void) | null }>({ open: false, title: '', message: '', onConfirm: null }); @@ -46,6 +49,42 @@ export function useAddonManager() { }, 4000); }; + // Listen to download progress + useEffect(() => { + let unlisten: (() => void) | undefined; + const setupListener = async () => { + unlisten = await listen<{ workshopId: string; percent: number }>('download-progress', (event) => { + setDownloadProgress((prev) => ({ + ...prev, + [event.payload.workshopId]: event.payload.percent, + })); + if (event.payload.percent === 100) { + setTimeout(() => { + setDownloadProgress((prev) => { + const next = { ...prev }; + delete next[event.payload.workshopId]; + return next; + }); + }, 3000); + } + }); + }; + setupListener(); + return () => { + if (unlisten) unlisten(); + }; + }, []); + + // Update local states from DatabasePayload + const updateLocalState = (data: DatabasePayload) => { + setAddons(data.addons || {}); + setGroups(data.groups || []); + setKnownUninstalledAddons(data.knownUninstalledAddons || {}); + if (data.settings) { + setSettings(data.settings); + } + }; + const executeWithWorkshopCheck = async (addonsList: Addon[], actionName: string, proceed: () => void) => { const workshopAddons = addonsList.filter(ad => ad.dirType === 'workshop'); if (workshopAddons.length > 0) { @@ -55,7 +94,7 @@ export function useAddonManager() { } catch (err) { addToast(t('toasts.autoMoveFailed', { err: String(err) }), 'error'); setIsSubmitting(false); - return; // Stop if move fails + return; } if (sessionStorage.getItem('skipWorkshopWarning') !== 'true' && !settings.enableDummyBypass) { @@ -77,10 +116,8 @@ export function useAddonManager() { const fetchData = async (showToastMessage = false) => { setLoading(true); try { - const data: { addons?: Record; groups?: Group[]; settings?: Settings } = await invoke('get_addons'); - setAddons(data.addons || {}); - setGroups(data.groups || []); - setSettings(data.settings || { workshopDir: '', loadingDir: '', enableDummyBypass: false }); + const data: DatabasePayload = await invoke('get_addons'); + updateLocalState(data); if (showToastMessage) { addToast(t('toasts.dbRefreshSuccess'), 'success'); } @@ -98,7 +135,6 @@ export function useAddonManager() { const handleOpenLink = async (url: string) => { if (!url) return; - if (url.startsWith('http://') || url.startsWith('https://')) { setLinkConfirmModal({ open: true, url }); } else { @@ -113,17 +149,22 @@ export function useAddonManager() { // Toggle addon enable/disable status const toggleAddon = async (vpkName: string, currentStatus: boolean) => { if (isSubmitting) return; - const addon = addons[vpkName]; + const addon = addons[vpkName] || knownUninstalledAddons[vpkName]; if (!addon) return; + + // If it's not installed, we can't toggle it (needs download) + if (addon.dirType === 'none') { + addToast('该组件未安装,请先下载安装', 'error'); + return; + } + setIsSubmitting(true); executeWithWorkshopCheck([addon], currentStatus ? '禁用组件' : '启用组件', async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { vpkNames: [vpkName], enabled: !currentStatus }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('toggle_addons', { vpkNames: [vpkName], enabled: !currentStatus }); + updateLocalState(data); addToast(currentStatus ? t('toasts.addonDisabled') : t('toasts.addonEnabled'), 'success'); - // Update detail modal state in-place if open if (detailModal.open && detailModal.addon?.vpkName === vpkName) { setDetailModal(prev => ({ ...prev, @@ -140,13 +181,16 @@ export function useAddonManager() { const toggleGroupAddons = async (addonsList: Addon[], enabled: boolean) => { if (isSubmitting) return; + // Filter out uninstalled addons in the group + const installedList = addonsList.filter(ad => ad.dirType !== 'none'); + if (installedList.length === 0) return; + setIsSubmitting(true); - executeWithWorkshopCheck(addonsList, enabled ? '批量启用组件' : '批量禁用组件', async () => { + executeWithWorkshopCheck(installedList, enabled ? '批量启用组件' : '批量禁用组件', async () => { try { - const vpkNames = addonsList.map(ad => ad.vpkName); - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { vpkNames, enabled }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const vpkNames = installedList.map(ad => ad.vpkName); + const data: DatabasePayload = await invoke('toggle_addons', { vpkNames, enabled }); + updateLocalState(data); addToast(enabled ? t('toasts.groupEnabled') : t('toasts.groupDisabled'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -162,9 +206,8 @@ export function useAddonManager() { setIsSubmitting(true); const targetDirType = currentDirType === 'workshop' ? 'loading' : 'workshop'; try { - const data: { addons?: Record; groups?: Group[] } = await invoke('move_addons', { vpkNames: [vpkName], targetDirType }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('move_addons', { vpkNames: [vpkName], targetDirType }); + updateLocalState(data); addToast(targetDirType === 'loading' ? t('toasts.moveSuccessLoading') : t('toasts.moveSuccessWorkshop'), 'success'); } catch (err) { addToast(t('toasts.moveFailed', { err: String(err) }), 'error'); @@ -194,9 +237,8 @@ export function useAddonManager() { const syncSteamDetails = async () => { setSyncingSteam(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('steam_sync'); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('steam_sync'); + updateLocalState(data); addToast(t('toasts.steamSyncSuccess'), 'success'); } catch (err) { addToast(t('toasts.steamSyncFailed', { err: String(err) }), 'error'); @@ -209,9 +251,8 @@ export function useAddonManager() { const runAutoGrouping = async () => { setAutoGrouping(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { action: 'auto-group' }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('group_action', { action: 'auto-group' }); + updateLocalState(data); addToast(t('toasts.autoClassifySuccess'), 'success'); } catch (err) { addToast(t('toasts.autoClassifyFailed', { err: String(err) }), 'error'); @@ -225,13 +266,11 @@ export function useAddonManager() { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[]; settings?: Settings } = await invoke('save_settings', { + const data: DatabasePayload = await invoke('save_settings', { loadingDir, enableDummyBypass }); - setAddons(data.addons || {}); - setGroups(data.groups || []); - setSettings(data.settings || { workshopDir: '', loadingDir: '', enableDummyBypass: false }); + updateLocalState(data); setSettingsModal({ open: false, loadingDir: '' }); addToast(t('toasts.settingsSaveSuccess'), 'success'); } catch (err) { @@ -250,12 +289,11 @@ export function useAddonManager() { setIsSubmitting(true); executeWithWorkshopCheck([addon], t('common.rename'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('rename_addon', { + const data: DatabasePayload = await invoke('rename_addon', { vpkName: currentName, newVpkName }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); setRenameModal({ open: false, currentName: '', title: '', suggestedName: '' }); addToast(t('toasts.renameSuccess'), 'success'); } catch (err) { @@ -267,17 +305,18 @@ export function useAddonManager() { }; // Group Management APIs - const handleCreateGroup = async (name: string, selectedAddons: string[]) => { + const handleCreateGroup = async (name: string, selectedAddons: string[], tags?: string[], workshopCollectionId?: string) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'create', name, - vpkNames: selectedAddons + vpkNames: selectedAddons, + tags, + workshopCollectionId }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); setGroupModal({ open: false }); addToast(t('toasts.createGroupSuccess'), 'success'); } catch (err) { @@ -296,9 +335,8 @@ export function useAddonManager() { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { action: 'delete', groupId }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('group_action', { action: 'delete', groupId }); + updateLocalState(data); if (selectedGroupId === groupId) { setSelectedGroupId(null); setCurrentFilterTab('all'); @@ -313,19 +351,21 @@ export function useAddonManager() { }); }; - const handleRenameGroup = async (groupId: string, name: string) => { + const handleRenameGroup = async (groupId: string, name: string, tags?: string[], workshopCollectionId?: string, vpkNames?: string[]) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { - action: 'rename-group', + const data: DatabasePayload = await invoke('group_action', { + action: 'update-group', groupId, - name + name, + tags, + workshopCollectionId, + vpkNames }); - setAddons(data.addons || {}); - setGroups(data.groups || []); - setEditGroupModal({ open: false, groupId: '', name: '' }); - addToast(t('toasts.renameGroupSuccess'), 'success'); + updateLocalState(data); + setEditGroupModal({ open: false, groupId: '', name: '', tags: [], workshopCollectionId: '' }); + addToast(t('toasts.renameGroupSuccess') || '修改分组成功', 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); } finally { @@ -336,19 +376,19 @@ export function useAddonManager() { const groupActionBatch = async (groupId: string, actionType: 'enable' | 'disable' | 'move-load') => { const group = groups.find(g => g.id === groupId); if (!group) return; - if (isSubmitting) return; setIsSubmitting(true); try { if (actionType === 'enable' || actionType === 'disable') { const enabled = actionType === 'enable'; - const groupAddons = group.addons.map(name => addons[name]).filter(Boolean); - executeWithWorkshopCheck(groupAddons, enabled ? t('batchActionBar.enable') : t('batchActionBar.disable'), async () => { + const groupAddons = group.addons.map(name => addons[name] || knownUninstalledAddons[name]).filter(Boolean); + const installedList = groupAddons.filter(ad => ad.dirType !== 'none'); + executeWithWorkshopCheck(installedList, enabled ? t('batchActionBar.enable') : t('batchActionBar.disable'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { vpkNames: group.addons, enabled }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const vpkNames = installedList.map(ad => ad.vpkName); + const data: DatabasePayload = await invoke('toggle_addons', { vpkNames, enabled }); + updateLocalState(data); addToast(enabled ? t('toasts.groupEnabled') : t('toasts.groupDisabled'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -358,13 +398,14 @@ export function useAddonManager() { }); } else if (actionType === 'move-load') { const targetDirType = 'loading'; - const isFromWorkshop = group.addons.some(name => addons[name]?.dirType === 'workshop'); + const groupAddons = group.addons.map(name => addons[name]).filter(Boolean); + const isFromWorkshop = groupAddons.some(ad => ad.dirType === 'workshop'); const executeMove = async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('move_addons', { vpkNames: group.addons, targetDirType }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const vpkNames = groupAddons.map(ad => ad.vpkName); + const data: DatabasePayload = await invoke('move_addons', { vpkNames, targetDirType }); + updateLocalState(data); addToast(t('toasts.batchMoveSuccess'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -380,7 +421,7 @@ export function useAddonManager() { setConfirmModal({ open: true, title: t('moveWarningModal.title'), - message: (t('confirmModal.batchMoveMsg', { count: group.addons.filter(name => addons[name]?.dirType === 'workshop').length }) as string[]).join('\n\n'), + message: (t('confirmModal.batchMoveMsg', { count: groupAddons.filter(ad => ad.dirType === 'workshop').length }) as string[]).join('\n\n'), onConfirm: executeMove }); } @@ -398,13 +439,12 @@ export function useAddonManager() { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'remove-addons', groupId, vpkNames: [vpkName] }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.removeFromGroupSuccess'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -417,13 +457,12 @@ export function useAddonManager() { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'add-addons', groupId, vpkNames: [vpkName] }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.addToGroupSuccess'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: (err as Error).message }), 'error'); @@ -446,6 +485,66 @@ export function useAddonManager() { }); }; + // Download addon + const downloadAddon = async (workshopId: string) => { + try { + setDownloadProgress(prev => ({ ...prev, [workshopId]: 0 })); + const data: DatabasePayload = await invoke('download_addon', { workshopId }); + updateLocalState(data); + addToast('下载组件成功', 'success'); + } catch (err) { + addToast(`下载失败: ${err}`, 'error'); + } finally { + setDownloadProgress(prev => { + const next = { ...prev }; + delete next[workshopId]; + return next; + }); + } + }; + + // Delete addon(s) + const deleteAddons = async (vpkNames: string[], deleteFile: boolean, removeFromKnown: boolean) => { + if (isSubmitting) return; + + // Check if any of these vpkNames are local non-workshop items + const nonWorkshopItems = vpkNames.filter(name => { + const ad = addons[name] || knownUninstalledAddons[name]; + return ad && !ad.workshopId; + }); + + const executeDelete = async () => { + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('delete_addons', { vpkNames, deleteFile, removeFromKnown }); + updateLocalState(data); + addToast('删除组件成功', 'success'); + handleClearSelection(); + } catch (err) { + addToast(`删除失败: ${err}`, 'error'); + } finally { + setIsSubmitting(false); + } + }; + + if (nonWorkshopItems.length > 0 && deleteFile) { + // Show warning for non-workshop items + setConfirmModal({ + open: true, + title: '删除本地非创意工坊组件', + message: `您选择的组件中包含 ${nonWorkshopItems.length} 个本地非创意工坊组件。永久删除这些文件后将无法恢复,确定要删除吗?\n\n${nonWorkshopItems.slice(0, 5).join('\n')}${nonWorkshopItems.length > 5 ? '\n...' : ''}`, + onConfirm: executeDelete + }); + } else { + setConfirmModal({ + open: true, + title: '删除组件', + message: `确定要删除选中的 ${vpkNames.length} 个组件吗?`, + onConfirm: executeDelete + }); + } + }; + // Selection management const handleSelectToggle = (vpkName: string) => { setSelectedVpkNames((prev) => { @@ -509,16 +608,20 @@ export function useAddonManager() { const handleBatchToggle = async (enabled: boolean) => { if (selectedVpkNames.length === 0) return; if (isSubmitting) return; + + const selectedAddonsList = selectedVpkNames.map(name => addons[name] || knownUninstalledAddons[name]).filter(Boolean); + const installedList = selectedAddonsList.filter(ad => ad.dirType !== 'none'); + if (installedList.length === 0) return; + setIsSubmitting(true); - const selectedAddonsList = selectedVpkNames.map(name => addons[name]).filter(Boolean); - executeWithWorkshopCheck(selectedAddonsList, enabled ? t('toasts.addonEnabled') : t('toasts.addonDisabled'), async () => { + executeWithWorkshopCheck(installedList, enabled ? t('toasts.addonEnabled') : t('toasts.addonDisabled'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { - vpkNames: selectedVpkNames, + const vpkNames = installedList.map(ad => ad.vpkName); + const data: DatabasePayload = await invoke('toggle_addons', { + vpkNames, enabled }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(enabled ? t('toasts.batchToggleSuccessEnable') : t('toasts.batchToggleSuccessDisable'), 'success'); handleClearSelection(); } catch (err) { @@ -532,18 +635,22 @@ export function useAddonManager() { const handleBatchMove = async () => { if (selectedVpkNames.length === 0) return; if (isSubmitting) return; - setIsSubmitting(true); + const selectedAddonsList = selectedVpkNames.map(name => addons[name]).filter(Boolean); - const workshopAddons = selectedAddonsList.filter(ad => ad.dirType === 'workshop'); + const installedList = selectedAddonsList.filter(ad => ad.dirType !== 'none'); + if (installedList.length === 0) return; + + const workshopAddons = installedList.filter(ad => ad.dirType === 'workshop'); + setIsSubmitting(true); const executeMove = async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('move_addons', { - vpkNames: selectedVpkNames, + const vpkNames = installedList.map(ad => ad.vpkName); + const data: DatabasePayload = await invoke('move_addons', { + vpkNames, targetDirType: 'loading' }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.batchMoveSuccess'), 'success'); handleClearSelection(); } catch (err) { @@ -611,11 +718,10 @@ export function useAddonManager() { const selectedAddonsList = selectedVpkNames.map(name => addons[name]).filter(Boolean); executeWithWorkshopCheck(selectedAddonsList, t('common.rename'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('rename_addons', { + const data: DatabasePayload = await invoke('rename_addons', { renames: renamesList }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.batchRenameSuccess', { count: renamesList.length }), 'success'); handleClearSelection(); } catch (err) { @@ -633,13 +739,12 @@ export function useAddonManager() { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'add-addons', groupId, vpkNames: selectedVpkNames }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.batchAddGroupSuccess'), 'success'); handleClearSelection(); } catch (err) { @@ -651,7 +756,9 @@ export function useAddonManager() { // Filter and sort addons const getFilteredAddons = () => { - let items = Object.values(addons).filter(item => !item.isDummy); + let items = currentFilterTab === 'known-uninstalled' + ? Object.values(knownUninstalledAddons) + : Object.values(addons).filter(item => !item.isDummy); // Search query filter if (searchQuery) { @@ -675,19 +782,21 @@ export function useAddonManager() { }); } - // Tab filter - if (currentFilterTab === 'workshop') { - items = items.filter(item => item.dirType === 'workshop'); - } else if (currentFilterTab === 'loading') { - items = items.filter(item => item.dirType === 'loading'); - } else if (currentFilterTab === 'disabled') { - items = items.filter(item => !item.isEnabled); - } else if (currentFilterTab === 'groups' && selectedGroupId) { - const group = groups.find(g => g.id === selectedGroupId); - if (group) { - items = items.filter(item => group.addons.includes(item.vpkName)); - } else { - items = []; + // Tab filter (if not known-uninstalled) + if (currentFilterTab !== 'known-uninstalled') { + if (currentFilterTab === 'workshop') { + items = items.filter(item => item.dirType === 'workshop'); + } else if (currentFilterTab === 'loading') { + items = items.filter(item => item.dirType === 'loading'); + } else if (currentFilterTab === 'disabled') { + items = items.filter(item => !item.isEnabled); + } else if (currentFilterTab === 'groups' && selectedGroupId) { + const group = groups.find(g => g.id === selectedGroupId); + if (group) { + items = items.filter(item => group.addons.includes(item.vpkName)); + } else { + items = []; + } } } @@ -709,7 +818,7 @@ export function useAddonManager() { }; const getRenderedItems = (filteredAddons: Addon[]): RenderedItem[] => { - if (currentFilterTab === 'groups') { + if (currentFilterTab === 'groups' || currentFilterTab === 'known-uninstalled') { return filteredAddons.map(item => ({ type: 'addon' as const, id: item.vpkName, data: item })); } @@ -790,7 +899,7 @@ export function useAddonManager() { ? groups.find(g => g.id === selectedGroupId) : null; - const renameAddonObj = renameModal.currentName ? addons[renameModal.currentName] : undefined; + const renameAddonObj = renameModal.currentName ? (addons[renameModal.currentName] || knownUninstalledAddons[renameModal.currentName]) : undefined; const renameGroupObj = renameModal.currentName ? groups.find(g => g.addons.includes(renameModal.currentName)) : undefined; const handleFilterTabChange = (tab: string, groupId: string | null) => { @@ -800,6 +909,7 @@ export function useAddonManager() { return { addons, + knownUninstalledAddons, groups, settings, loading, @@ -822,6 +932,7 @@ export function useAddonManager() { setIsSelectMode, isSubmitting, setIsSubmitting, + downloadProgress, // Modals state detailModal, @@ -871,6 +982,8 @@ export function useAddonManager() { handleBatchRename, handleBatchAddToGroup, handleFilterTabChange, + downloadAddon, + deleteAddons, // Derived values filteredItems, diff --git a/src/i18n.tsx b/src/i18n.tsx index 11ce356..03720ca 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -22,7 +22,8 @@ const zh = { rescan: "重新扫描", scanning: "正在扫描...", error: "错误", - success: "成功" + success: "成功", + search: "搜索" }, categories: { All: "全部", @@ -287,6 +288,95 @@ const zh = { batchRenameNoNeed: "选中的组件已是推荐文件名,无需重命名", batchRenameSuccess: "批量重命名成功 (重命名了 {{count}} 个文件)", batchAddGroupSuccess: "批量加入分组成功" + }, + workshop: { + nav: { + home: "首页", + browse: "浏览", + searchPlaceholder: "搜索创意工坊..." + }, + home: { + pastWeek: "一周内物品", + pastWeekDesc: "过去 1 周内", + trending: "最热门", + trendingDesc: "过去 3 个月内评价最高", + mostRequired: "被最多其他项目需要", + mostRequiredDesc: "其他项目正常运行需要得最多的项目", + mostSubscribed: "最多人订阅", + mostSubscribedDesc: "订阅人数最多的物品", + recentlyUpdated: "最近更新", + recentlyUpdatedDesc: "最近有更新的物品", + newest: "最新", + newestDesc: "最新发布的物品", + viewAll: "查看全部", + loading: "正在加载创意工坊首页...", + loadFailed: "无法加载首页数据,请尝试浏览模式。", + switchToBrowse: "切换到浏览模式" + }, + browse: { + sortTrend: "最热门", + sortTextSearch: "相关度", + sortTotalProfiles: "最多订阅", + sortMostRecent: "最新发布", + sortTopRated: "最受好评", + sortLastUpdated: "最新更新", + sectionAddons: "组件", + sectionCollections: "合集", + loading: "正在加载创意工坊列表...", + retry: "重试", + empty: "未找到创意工坊项目,请尝试更换关键词。", + page: "第 {{page}} 页" + }, + tags: { + browse: "分类浏览", + filter: "分类筛选", + label: "标签: {{name}}" + }, + badges: { + downloaded: "已下载", + known: "已知", + notDownloaded: "未下载", + addon: "组件", + collection: "合集" + }, + item: { + author: "作者: {{author}}", + size: "大小: {{size}}", + subscriptions: "订阅: {{count}}" + }, + detail: { + loading: "正在加载详细数据...", + title: "组件详情", + author: "作者", + authorPage: "作者页", + description: "描述", + descriptionLabel: "描述", + download: "下载组件并安装", + downloaded: "已下载", + downloading: "正在下载 ({{progress}}%)", + addToKnown: "加入已知组件列表", + addToKnownList: "加入已知列表", + steamProtocol: "Steam 协议打开", + openInSteam: "在 Steam 中打开", + openInBrowser: "浏览器打开", + viewOnSteam: "在 Steam 中查看", + defaultCollectionName: "创意工坊合集", + importSuccess: "已将合集中的 {{count}} 个组件导入为分组 \"{{name}}\"。", + downloadAllSuccess: "已启动下载合集中的 {{count}} 个组件!", + collectionTitle: "合集详情", + collectionAuthor: "合集作者", + collectionItems: "合集内组件", + collectionItemsCount: "{{count}} 个组件", + itemsInCollection: "合集内组件", + itemSize: "文件大小", + workshopId: "创意工坊 ID", + status: "状态", + importAsGroup: "导入该合集为分组", + downloadAll: "批量下载 ({{count}})", + addedToKnown: "成功加入已知组件列表!", + addFailed: "添加失败: {{err}}", + fetchFailed: "无法获取详细信息: {{err}}" + } } }; @@ -310,7 +400,8 @@ const en = { rescan: "Rescan", scanning: "Scanning...", error: "Error", - success: "Success" + success: "Success", + search: "Search" }, categories: { All: "All", @@ -575,6 +666,95 @@ const en = { batchRenameNoNeed: "Selected addons are already in recommended filename format, no rename needed", batchRenameSuccess: "Batch rename successful (renamed {{count}} files)", batchAddGroupSuccess: "Batch added to group successfully" + }, + workshop: { + nav: { + home: "Home", + browse: "Browse", + searchPlaceholder: "Search Workshop..." + }, + home: { + pastWeek: "Items This Week", + pastWeekDesc: "Past 1 week", + trending: "Trending", + trendingDesc: "Top rated in past 3 months", + mostRequired: "Most Required by Others", + mostRequiredDesc: "Items most needed by other items to function", + mostSubscribed: "Most Subscribed", + mostSubscribedDesc: "Most subscribed items", + recentlyUpdated: "Recently Updated", + recentlyUpdatedDesc: "Items with recent updates", + newest: "Newest", + newestDesc: "Most recently published items", + viewAll: "View All", + loading: "Loading Workshop homepage...", + loadFailed: "Unable to load homepage data, try browse mode.", + switchToBrowse: "Switch to Browse Mode" + }, + browse: { + sortTrend: "Trending", + sortTextSearch: "Relevance", + sortTotalProfiles: "Most Subscribed", + sortMostRecent: "Most Recent", + sortTopRated: "Top Rated", + sortLastUpdated: "Last Updated", + sectionAddons: "Addons", + sectionCollections: "Collections", + loading: "Loading Workshop items...", + retry: "Retry", + empty: "No Workshop items found, try different keywords.", + page: "Page {{page}}" + }, + tags: { + browse: "Browse by Category", + filter: "Filter by Tags", + label: "Tag: {{name}}" + }, + badges: { + downloaded: "Downloaded", + known: "Known", + notDownloaded: "Not Downloaded", + addon: "Addon", + collection: "Collection" + }, + item: { + author: "By: {{author}}", + size: "Size: {{size}}", + subscriptions: "Subs: {{count}}" + }, + detail: { + loading: "Loading details...", + title: "Item Details", + author: "Author", + authorPage: "Author Page", + description: "Description", + descriptionLabel: "Description", + download: "Download & Install", + downloaded: "Downloaded", + downloading: "Downloading ({{progress}}%)", + addToKnown: "Add to Known Items", + addToKnownList: "Add to Known List", + steamProtocol: "Open in Steam", + openInSteam: "Open in Steam", + openInBrowser: "Open in Browser", + viewOnSteam: "View on Steam", + defaultCollectionName: "Workshop Collection", + importSuccess: "Imported {{count}} items from collection as group \"{{name}}\".", + downloadAllSuccess: "Started downloading {{count}} items from collection!", + collectionTitle: "Collection Details", + collectionAuthor: "Collection Author", + collectionItems: "Items in Collection", + collectionItemsCount: "{{count}} items", + itemsInCollection: "Items in Collection", + itemSize: "File Size", + workshopId: "Workshop ID", + status: "Status", + importAsGroup: "Import as Group", + downloadAll: "Download All ({{count}})", + addedToKnown: "Added to known items list!", + addFailed: "Failed to add: {{err}}", + fetchFailed: "Unable to fetch details: {{err}}" + } } }; diff --git a/src/index.css b/src/index.css index 4cf16e8..ba3e4de 100644 --- a/src/index.css +++ b/src/index.css @@ -1205,3 +1205,32 @@ input:checked + .slider:before { } } + +/* Spinner animation for Loader2 */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.animate-spin { + animation: spin 1s linear infinite; +} + +/* Overlay scrollbars to prevent layout shift */ +.workshop-browser * { + scrollbar-width: thin; + scrollbar-color: var(--md-sys-color-outline-variant) transparent; +} +.workshop-browser *::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.workshop-browser *::-webkit-scrollbar-track { + background: transparent; +} +.workshop-browser *::-webkit-scrollbar-thumb { + background: var(--md-sys-color-outline-variant); + border-radius: 3px; +} +.workshop-browser *::-webkit-scrollbar-thumb:hover { + background: var(--md-sys-color-outline); +} diff --git a/src/types/addon.ts b/src/types/addon.ts index 08f5dd8..7e650a6 100644 --- a/src/types/addon.ts +++ b/src/types/addon.ts @@ -60,6 +60,8 @@ export interface Group { id: string; name: string; addons: string[]; + tags?: string[]; + workshopCollectionId?: string; } export interface Settings { @@ -68,6 +70,13 @@ export interface Settings { enableDummyBypass: boolean; } +export interface DatabasePayload { + settings: Settings; + addons: Record; + groups: Group[]; + knownUninstalledAddons: Record; +} + export interface Toast { id: string; message: string; From 0e277fb8483270842583e2f90acf385b4d592e64 Mon Sep 17 00:00:00 2001 From: FallenCrystal Date: Tue, 7 Jul 2026 01:00:48 +0800 Subject: [PATCH 02/81] style: adjust 'view all' button size and position on workshop home & update .gitignore --- .gitignore | 4 ++ src/components/workshop/SectionCarousel.tsx | 76 +++++++++++---------- src/index.css | 2 +- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/.gitignore b/.gitignore index df061d2..17caa53 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ src-tauri/target/ *.njsproj *.sln *.sw? + +# Left4Addons specific ignores +.playwright-mcp +known_addons.json diff --git a/src/components/workshop/SectionCarousel.tsx b/src/components/workshop/SectionCarousel.tsx index a7b7bc0..d340c47 100644 --- a/src/components/workshop/SectionCarousel.tsx +++ b/src/components/workshop/SectionCarousel.tsx @@ -7,12 +7,12 @@ import { HomepageSection, WorkshopItem } from './types'; import { ItemCard } from './ItemCard'; const ICON_MAP: Record = { - Clock: , - Flame: , - Package: , - Users: , - RefreshCw: , - Star: , + Clock: , + Flame: , + Package: , + Users: , + RefreshCw: , + Star: , }; interface SectionCarouselProps { @@ -41,30 +41,46 @@ export const SectionCarousel: React.FC = ({
-
+
- {ICON_MAP[section.icon as string] || } + {ICON_MAP[section.icon as string] || }
-

- {t(section.title)} -

+
+

+ {t(section.title)} +

+ +

@@ -72,20 +88,6 @@ export const SectionCarousel: React.FC = ({

-
Date: Tue, 7 Jul 2026 02:17:31 +0800 Subject: [PATCH 03/81] feat(workshop): UI/UX improvements for workshop browser - Add loading animation feedback when clicking on a workshop item - Align section carousel cards precisely with their section titles - Fix hover scaling artifacts and clipping on addon cards by adding a 1px solid focus ring and z-index - Add top padding to browse grid to prevent scaled cards from being clipped by scroll container - Keep sort and filter buttons visible when searching from browse view - Automatically switch to browse view when focusing the search input on the home page - Reset to default browse view when the search box is cleared --- src/components/WorkshopBrowser.tsx | 37 ++++++++++++++++++--- src/components/workshop/ItemCard.tsx | 11 ++++-- src/components/workshop/SectionCarousel.tsx | 4 +++ src/index.css | 3 +- 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/WorkshopBrowser.tsx b/src/components/WorkshopBrowser.tsx index 471a4e3..3b2ee9d 100644 --- a/src/components/WorkshopBrowser.tsx +++ b/src/components/WorkshopBrowser.tsx @@ -79,6 +79,7 @@ export const WorkshopBrowser: React.FC = ({ const [selectedItem, setSelectedItem] = useState(null); const [selectedCollection, setSelectedCollection] = useState(null); const [tagModalOpen, setTagModalOpen] = useState(false); + const [loadingDetailId, setLoadingDetailId] = useState(null); // ── Fetch homepage ───────────────────────────────────────────────────────── @@ -134,7 +135,14 @@ export const WorkshopBrowser: React.FC = ({ const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!query.trim()) return; + if (!query.trim()) { + setCommittedQuery(''); + if (viewMode === 'search') { + setViewMode('browse'); + setPage(1); + } + return; + } setCreatorId(null); setCreatorName(null); setActiveTag(null); @@ -201,6 +209,7 @@ export const WorkshopBrowser: React.FC = ({ }); const viewItemDetails = async (workshopId: string) => { + setLoadingDetailId(workshopId); try { const data: any = await invoke('fetch_collection', { collectionId: workshopId }); const raw = data.collection; @@ -209,10 +218,13 @@ export const WorkshopBrowser: React.FC = ({ } } catch (err) { alert(t('workshop.detail.fetchFailed', { err: String(err) })); + } finally { + setLoadingDetailId(null); } }; const viewCollectionDetails = async (collectionId: string) => { + setLoadingDetailId(collectionId); try { const data: any = await invoke('fetch_collection', { collectionId }); const raw = data.collection; @@ -229,6 +241,8 @@ export const WorkshopBrowser: React.FC = ({ } } catch (err) { alert(t('workshop.detail.fetchFailed', { err: String(err) })); + } finally { + setLoadingDetailId(null); } }; @@ -264,6 +278,7 @@ export const WorkshopBrowser: React.FC = ({ knownUninstalledAddons={knownUninstalledAddons} onItemClick={(item) => viewItemDetails(item.workshopId)} onViewAll={handleViewAllSection} + loadingDetailId={loadingDetailId} /> ))}
@@ -311,7 +326,7 @@ export const WorkshopBrowser: React.FC = ({
) : ( <> -
+
{items.map((item) => ( = ({ addons={addons} knownUninstalledAddons={knownUninstalledAddons} onClick={() => section === 'collections' ? viewCollectionDetails(item.workshopId) : viewItemDetails(item.workshopId)} + isLoading={loadingDetailId === item.workshopId} /> ))}
@@ -368,7 +384,20 @@ export const WorkshopBrowser: React.FC = ({ type="text" placeholder={t('workshop.nav.searchPlaceholder')} value={query} - onChange={(e) => setQuery(e.target.value)} + onChange={(e) => { + const val = e.target.value; + setQuery(val); + if (val.trim() === '' && viewMode === 'search') { + setCommittedQuery(''); + setViewMode('browse'); + setPage(1); + } + }} + onFocus={() => { + if (viewMode === 'home') { + enterBrowseMode(); + } + }} style={{ width: '100%', padding: '8px 14px 8px 40px', borderRadius: '100px', border: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'var(--md-sys-color-surface-container-high)', color: 'var(--md-sys-color-on-surface)', outline: 'none', fontSize: '13px' }} />
@@ -377,7 +406,7 @@ export const WorkshopBrowser: React.FC = ({ - {viewMode === 'browse' && !creatorId && ( + {(viewMode === 'browse' || viewMode === 'search') && !creatorId && ( <> onToggle(addon.vpkName, addon.isEnabled)} + onChange={() => onToggle(addon.id, addon.isEnabled)} disabled={isSubmitting} /> @@ -163,7 +163,7 @@ export const AddonCard: React.FC = ({ {groups.map(g => ( diff --git a/src/components/EditGroupModal.test.tsx b/src/components/EditGroupModal.test.tsx index bdc02fc..b4b8ed3 100644 --- a/src/components/EditGroupModal.test.tsx +++ b/src/components/EditGroupModal.test.tsx @@ -19,7 +19,7 @@ describe('EditGroupModal', () => { const mockAddons: Addon[] = [ { - vpkName: 'addon1.vpk', + id: 'addon1.vpk', vpkName: 'addon1.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx index f0f2396..b04e4e6 100644 --- a/src/components/ErrorBoundary.test.tsx +++ b/src/components/ErrorBoundary.test.tsx @@ -1,7 +1,7 @@ import { describe, test, expect, vi, beforeAll, afterEach } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { ErrorBoundary } from './ErrorBoundary'; -import React from 'react'; +// import React from 'react'; const ThrowError = () => { throw new Error('Test Render Error'); diff --git a/src/components/GroupCard.test.tsx b/src/components/GroupCard.test.tsx index 217db6c..8ea2732 100644 --- a/src/components/GroupCard.test.tsx +++ b/src/components/GroupCard.test.tsx @@ -13,7 +13,7 @@ describe('GroupCard', () => { const mockAddons: Addon[] = [ { - vpkName: 'addon1.vpk', + id: 'addon1.vpk', vpkName: 'addon1.vpk', dirType: 'loading', isEnabled: true, fileSize: 1024 * 1024 * 10, // 10 MB @@ -24,7 +24,7 @@ describe('GroupCard', () => { }, }, { - vpkName: 'addon2.vpk', + id: 'addon2.vpk', vpkName: 'addon2.vpk', dirType: 'loading', isEnabled: true, fileSize: 1024 * 1024 * 5, // 5 MB diff --git a/src/components/GroupModal.test.tsx b/src/components/GroupModal.test.tsx index 33b1851..70eb4b3 100644 --- a/src/components/GroupModal.test.tsx +++ b/src/components/GroupModal.test.tsx @@ -6,7 +6,7 @@ import { Addon } from '../types/addon'; describe('GroupModal', () => { const mockAddons: Record = { 'addon1.vpk': { - vpkName: 'addon1.vpk', + id: 'addon1.vpk', vpkName: 'addon1.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -14,7 +14,7 @@ describe('GroupModal', () => { steamDetails: { title: 'First Addon' }, }, 'addon2.vpk': { - vpkName: 'addon2.vpk', + id: 'addon2.vpk', vpkName: 'addon2.vpk', dirType: 'loading', isEnabled: true, fileSize: 200, diff --git a/src/components/GroupModal.tsx b/src/components/GroupModal.tsx index 4e4848b..ba23068 100644 --- a/src/components/GroupModal.tsx +++ b/src/components/GroupModal.tsx @@ -37,11 +37,11 @@ export const GroupModal: React.FC = ({ onConfirm(name, selectedAddons); }; - const handleCheckboxChange = (vpkName: string, checked: boolean) => { + const handleCheckboxChange = (id: string, checked: boolean) => { if (checked) { - setSelectedAddons(prev => [...prev, vpkName]); + setSelectedAddons(prev => [...prev, id]); } else { - setSelectedAddons(prev => prev.filter(item => item !== vpkName)); + setSelectedAddons(prev => prev.filter(item => item !== id)); } }; @@ -83,10 +83,10 @@ export const GroupModal: React.FC = ({ }}> {Object.values(addons).map(addon => { const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; - const isChecked = selectedAddons.includes(addon.vpkName); + const isChecked = selectedAddons.includes(addon.id); return (
+
+ ); +}; diff --git a/src/components/AddonCard.tsx b/src/components/AddonCard.tsx index 18cb572..b4d80a9 100644 --- a/src/components/AddonCard.tsx +++ b/src/components/AddonCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FolderPlus, ExternalLink, Move, Edit3, FileText } from 'lucide-react'; +import { FolderPlus, ExternalLink, Move, Edit3, FileText, Download } from 'lucide-react'; import { Addon, Group } from '../types/addon'; import { formatBytes, getAddonCategories, getAddonUrl, getAddonAuthor, getAddonInfoValue } from '../utils/addonHelpers'; import { CacheImage } from './CacheImage'; @@ -15,10 +15,12 @@ interface AddonCardProps { onMoveClick: (addon: Addon) => void; onRenameClick: (addon: Addon) => void; onDetailClick: (addon: Addon) => void; + onDownload?: (workshopId: string) => void; isSelectMode: boolean; isSelected: boolean; onSelectToggle: (id: string) => void; isSubmitting?: boolean; + downloadProgress?: Record; } export const AddonCard: React.FC = ({ @@ -31,19 +33,24 @@ export const AddonCard: React.FC = ({ onMoveClick, onRenameClick, onDetailClick, + onDownload, isSelectMode, isSelected, onSelectToggle, isSubmitting = false, + downloadProgress = {}, }) => { const { t } = useTranslation(); const categories = getAddonCategories(addon); const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; const author = getAddonAuthor(addon); - const desc = addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || getAddonInfoValue(addon, 'addontagline') || 'No description provided.'; - + const desc = addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || getAddonInfoValue(addon, 'addontagline') || ''; + const itemGroup = groups.find(g => g.addons.includes(addon.id)); const addonUrl = getAddonUrl(addon); + const isUninstalled = addon.dirType === 'none'; + const isDownloading = addon.workshopId ? downloadProgress[addon.workshopId] !== undefined : false; + const downloadPercent = addon.workshopId ? (downloadProgress[addon.workshopId] ?? 0) : 0; const handleLinkClick = (e: React.MouseEvent, url: string) => { e.preventDefault(); @@ -51,9 +58,9 @@ export const AddonCard: React.FC = ({ }; return ( -
+
{/* Checkbox Wrapper */} -
{ e.stopPropagation(); @@ -67,7 +74,7 @@ export const AddonCard: React.FC = ({
{/* Clickable Area for Detail Modal or Selection Toggle */} -
{ if (isSelectMode) { @@ -80,9 +87,9 @@ export const AddonCard: React.FC = ({ >
{addon.imagePath ? ( - @@ -98,20 +105,30 @@ export const AddonCard: React.FC = ({
- - {addon.isEnabled ? t('addonCard.enabled') : t('addonCard.disabled')} - - - {addon.dirType === 'loading' ? t('addonCard.manualInstall') : t('addonCard.workshop')} - + {isUninstalled ? ( + + {t('addonCard.uninstalled')} + + ) : ( + <> + + {addon.isEnabled ? t('addonCard.enabled') : t('addonCard.disabled')} + + + {addon.dirType === 'loading' ? t('addonCard.manualInstall') : t('addonCard.workshop')} + + + )}

{title}

- -
- {t('addonCard.author', { author })} -
+ + {author && author !== 'Unknown Author' && ( +
+ {t('addonCard.author', { author })} +
+ )} {itemGroup && (
@@ -120,12 +137,14 @@ export const AddonCard: React.FC = ({
)} -

{desc}

- -
- {t('addonCard.fileSize', { size: formatBytes(addon.fileSize) })} - {addon.filesCount > 0 && {t('addonCard.containsFiles', { count: addon.filesCount })}} -
+ {desc &&

{desc}

} + + {(addon.fileSize > 0 || addon.filesCount > 0) && ( +
+ {addon.fileSize > 0 && {t('addonCard.fileSize', { size: formatBytes(addon.fileSize) })}} + {addon.filesCount > 0 && {t('addonCard.containsFiles', { count: addon.filesCount })}} +
+ )}
{categories.map(c => ( @@ -142,99 +161,138 @@ export const AddonCard: React.FC = ({ {!isSelectMode && (
- {/* Enable/Disable Toggle */} - - -
- {/* Group assign dropdown */} -
- -
- {groups.map(g => ( - - ))} - {itemGroup && ( - )} - {groups.length === 0 && !itemGroup && ( - + {addon.workshopId && ( +
+ +
+ + +
+
)}
-
+ + ) : ( + // Installed: normal footer + <> + - {/* Open Link with Fallbacks (Dropdown or direct link) */} - {addon.workshopId ? ( -
- -
- - + ))} + {itemGroup && ( + + )} + {groups.length === 0 && !itemGroup && ( + + )} +
+
+ + {addon.workshopId ? ( +
+ +
+ + + +
+
+ ) : addonUrl ? ( + - -
-
- ) : addonUrl ? ( - - ) : null} - - {/* Move between load and workshop dirs (Only from workshop to loading, no moving back) */} - {addon.dirType === 'workshop' && ( - - )} + )} - {/* Rename */} - -
+ +
+ + )}
)}
diff --git a/src/components/BatchActionBar.tsx b/src/components/BatchActionBar.tsx index 771e78d..a28cc1d 100644 --- a/src/components/BatchActionBar.tsx +++ b/src/components/BatchActionBar.tsx @@ -1,5 +1,5 @@ -import { CheckSquare, Unlock, Lock, FolderOpen, Edit3, FolderPlus, X } from 'lucide-react'; -import { Addon, Group } from '../types/addon'; +import { CheckSquare, Unlock, Lock, FolderOpen, Edit3, FolderPlus, Library, X } from 'lucide-react'; +import { Addon, Group, MasterCollection } from '../types/addon'; import { useTranslation } from 'react-i18next'; interface BatchActionBarProps { @@ -7,11 +7,15 @@ interface BatchActionBarProps { filteredItems: Addon[]; addons: Record; groups: Group[]; + masterCollections?: MasterCollection[]; + selectedGroupIds?: string[]; onSelectAll: (visibleItems: Addon[]) => void; + onSelectAllGroups?: (groups: Group[]) => void; onBatchToggle: (enabled: boolean) => void; onBatchMove: () => void; onBatchRename: () => void; - onBatchAddToGroup: (groupId: string) => void; + onBatchAddToGroup: () => void; + onBatchAddToMasterCollection?: () => void; onClearSelection: () => void; } @@ -20,98 +24,133 @@ export function BatchActionBar({ filteredItems, addons, groups, + masterCollections = [], + selectedGroupIds = [], onSelectAll, + onSelectAllGroups, onBatchToggle, onBatchMove, onBatchRename, onBatchAddToGroup, + onBatchAddToMasterCollection, onClearSelection, }: BatchActionBarProps) { const { t } = useTranslation(); - const isAllSelected = filteredItems.every(item => selectedIds.includes(item.id)); + + // Determine what's selected + const hasAddonSelection = selectedIds.length > 0; + const hasGroupSelection = selectedGroupIds.length > 0; + const totalSelected = selectedIds.length + selectedGroupIds.length; + const hasWorkshopSelected = selectedIds.some(name => addons[name]?.dirType === 'workshop'); + // Check if we're in master collection view (only groups selected) + const isGroupOnlySelection = hasGroupSelection && !hasAddonSelection; + return (
- {t('batchActionBar.selectedCount', { count: selectedIds.length })} + {t('batchActionBar.selectedCount', { count: totalSelected })}
- - - - - - - {hasWorkshopSelected && ( - )} - - -
- -
- {groups.map(g => ( - + + + + {hasWorkshopSelected && ( + - ))} - {groups.length === 0 && ( - )} -
-
- + + + + )} + + {/* Group-specific actions */} + {hasGroupSelection && onBatchAddToMasterCollection && ( + + )} + + + )} + - -
-
- - + + + + )} + {uninstalledWithWorkshopId.length > 0 && onDownloadUninstalled && ( + + )} +
+ -
diff --git a/src/components/GroupModal.test.tsx b/src/components/GroupModal.test.tsx index 70eb4b3..c54014c 100644 --- a/src/components/GroupModal.test.tsx +++ b/src/components/GroupModal.test.tsx @@ -1,33 +1,12 @@ import { describe, test, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { GroupModal } from './GroupModal'; -import { Addon } from '../types/addon'; describe('GroupModal', () => { - const mockAddons: Record = { - 'addon1.vpk': { - id: 'addon1.vpk', vpkName: 'addon1.vpk', - dirType: 'loading', - isEnabled: true, - fileSize: 100, - filesCount: 1, - steamDetails: { title: 'First Addon' }, - }, - 'addon2.vpk': { - id: 'addon2.vpk', vpkName: 'addon2.vpk', - dirType: 'loading', - isEnabled: true, - fileSize: 200, - filesCount: 2, - addonInfo: { addontitle: 'Second Addon' }, - }, - }; - test('renders nothing when open is false', () => { const { container } = render( @@ -35,37 +14,32 @@ describe('GroupModal', () => { expect(container.firstChild).toBeNull(); }); - test('renders fields and handles checkbox selection', () => { + test('renders name input and handles cancel', () => { const onCancel = vi.fn(); const onConfirm = vi.fn(); render( ); expect(screen.getByText('创建新附件分组')).toBeDefined(); - - // Renders the checkboxes - expect(screen.getByText('First Addon')).toBeDefined(); - expect(screen.getByText('Second Addon')).toBeDefined(); + expect(screen.getByPlaceholderText('例如:Early Days 战役地图包')).toBeDefined(); // Check cancel button fireEvent.click(screen.getByText('取消')); expect(onCancel).toHaveBeenCalledTimes(1); }); - test('submitting forms with inputs triggers onConfirm', () => { + test('submitting form with name triggers onConfirm', () => { const onConfirm = vi.fn(); render( @@ -74,23 +48,17 @@ describe('GroupModal', () => { const nameInput = screen.getByPlaceholderText('例如:Early Days 战役地图包') as HTMLInputElement; fireEvent.change(nameInput, { target: { value: 'My Campaign Pack' } }); - // Select first addon - const firstCheckbox = screen.getAllByRole('checkbox')[0] as HTMLInputElement; - fireEvent.click(firstCheckbox); - expect(firstCheckbox.checked).toBe(true); - const form = nameInput.closest('form'); if (form) { fireEvent.submit(form); - expect(onConfirm).toHaveBeenCalledWith('My Campaign Pack', ['addon1.vpk']); + expect(onConfirm).toHaveBeenCalledWith('My Campaign Pack'); } }); - test('submits button is disabled if fields are incomplete', () => { + test('submit button is disabled if name is empty', () => { render( @@ -101,12 +69,6 @@ describe('GroupModal', () => { const nameInput = screen.getByPlaceholderText('例如:Early Days 战役地图包') as HTMLInputElement; fireEvent.change(nameInput, { target: { value: 'My Campaign Pack' } }); - // Still disabled because no addons are selected - expect(submitButton.disabled).toBe(true); - - // Select first addon - const firstCheckbox = screen.getAllByRole('checkbox')[0] as HTMLInputElement; - fireEvent.click(firstCheckbox); expect(submitButton.disabled).toBe(false); }); }); diff --git a/src/components/GroupModal.tsx b/src/components/GroupModal.tsx index ba23068..5a1c121 100644 --- a/src/components/GroupModal.tsx +++ b/src/components/GroupModal.tsx @@ -1,31 +1,25 @@ import React, { useState, useEffect } from 'react'; -import { Addon } from '../types/addon'; import { useTranslation } from 'react-i18next'; -import { getAddonInfoValue } from '../utils/addonHelpers'; interface GroupModalProps { open: boolean; - addons: Record; onCancel: () => void; - onConfirm: (name: string, selectedAddons: string[]) => void; + onConfirm: (name: string) => void; isSubmitting?: boolean; } export const GroupModal: React.FC = ({ open, - addons, onCancel, onConfirm, isSubmitting = false, }) => { const { t } = useTranslation(); const [name, setName] = useState(''); - const [selectedAddons, setSelectedAddons] = useState([]); useEffect(() => { if (open) { setName(''); - setSelectedAddons([]); } }, [open]); @@ -33,24 +27,16 @@ export const GroupModal: React.FC = ({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!name || selectedAddons.length === 0 || isSubmitting) return; - onConfirm(name, selectedAddons); - }; - - const handleCheckboxChange = (id: string, checked: boolean) => { - if (checked) { - setSelectedAddons(prev => [...prev, id]); - } else { - setSelectedAddons(prev => prev.filter(item => item !== id)); - } + if (!name.trim() || isSubmitting) return; + onConfirm(name.trim()); }; return (
-
e.stopPropagation()} >

{t('groupModal.title')}

@@ -60,68 +46,28 @@ export const GroupModal: React.FC = ({
- setName(e.target.value)} required disabled={isSubmitting} + autoFocus />
-
- -
- {Object.values(addons).map(addon => { - const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; - const isChecked = selectedAddons.includes(addon.id); - return ( - - ); - })} -
-
-
- -
diff --git a/src/components/KnownUninstalledView.tsx b/src/components/KnownUninstalledView.tsx index ef2695c..66414f5 100644 --- a/src/components/KnownUninstalledView.tsx +++ b/src/components/KnownUninstalledView.tsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react'; -import { Download, Trash2, Globe, Search, RefreshCw, AlertCircle } from 'lucide-react'; +import React from 'react'; +import { Download, Globe, Trash2, Search, RefreshCw, AlertCircle, FileText } from 'lucide-react'; import { Addon } from '../types/addon'; -import { getAddonAuthor, getAddonCategories, formatBytes } from '../utils/addonHelpers'; +import { getAddonAuthor, getAddonCategories, formatBytes, getAddonInfoValue } from '../utils/addonHelpers'; +import { CacheImage } from './CacheImage'; +import { useTranslation } from 'react-i18next'; interface KnownUninstalledViewProps { knownUninstalledAddons: Record; @@ -10,6 +12,13 @@ interface KnownUninstalledViewProps { onDelete: (ids: string[], deleteFile: boolean, removeFromKnown: boolean) => void; isSubmitting: boolean; onOpenLink: (url: string) => void; + isSelectMode?: boolean; + selectedIds?: string[]; + onSelectToggle?: (id: string) => void; + onSelectAll?: (items: Addon[]) => void; + onBatchDownload?: (workshopIds: string[]) => void; + searchQuery?: string; + onSearchQueryChange?: (query: string) => void; } export const KnownUninstalledView: React.FC = ({ @@ -19,104 +28,50 @@ export const KnownUninstalledView: React.FC = ({ onDelete, isSubmitting, onOpenLink, + isSelectMode = false, + selectedIds = [], + onSelectToggle, + onSelectAll: _onSelectAll, + onBatchDownload: _onBatchDownload, + searchQuery: externalSearchQuery, + onSearchQueryChange, }) => { - const [searchQuery, setSearchQuery] = useState(''); - const [selectedIds, setSelectedIds] = useState([]); - const [isSelectMode, setIsSelectMode] = useState(false); - + const { t } = useTranslation(); + const searchQuery = externalSearchQuery || ''; + const setSearchQuery = onSearchQueryChange || (() => {}); const addonsList = Object.values(knownUninstalledAddons); - // Search filter const filteredAddons = addonsList.filter((addon) => { const q = searchQuery.toLowerCase(); - const title = (addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName || '').toLowerCase(); - const desc = (addon.steamDetails?.description || addon.addonInfo?.addondescription || '').toLowerCase(); + const title = (addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName || '').toLowerCase(); + const desc = (addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || '').toLowerCase(); const author = getAddonAuthor(addon).toLowerCase(); const wId = (addon.workshopId || ''); return title.includes(q) || desc.includes(q) || author.includes(q) || wId.includes(q); }); - const handleSelectToggle = (id: string) => { - setSelectedIds((prev) => { - const next = prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]; - if (next.length > 0 && !isSelectMode) { - setIsSelectMode(true); - } - return next; - }); - }; - - const handleSelectAll = () => { - const allIds = filteredAddons.map((ad) => ad.id); - const allSelected = allIds.every((id) => selectedIds.includes(id)); - if (allSelected) { - setSelectedIds((prev) => prev.filter((id) => !allIds.includes(id))); - } else { - setSelectedIds((prev) => { - const next = [...prev]; - allIds.forEach((id) => { - if (!next.includes(id)) next.push(id); - }); - return next; - }); - } - }; - - const handleClearSelection = () => { - setSelectedIds([]); - setIsSelectMode(false); - }; - - const handleBatchDownload = async () => { - const idsToDownload = selectedIds - .map((id) => knownUninstalledAddons[id]) - .filter((addon) => addon !== undefined) - .map((addon) => addon.workshopId) - .filter(Boolean) as string[]; - - if (idsToDownload.length === 0) return; - - for (const wId of idsToDownload) { - onDownload(wId); - } - handleClearSelection(); - }; - - const handleBatchDelete = () => { - onDelete(selectedIds, false, true); - handleClearSelection(); - }; - return ( -
+

- 未安装但已知的 Addon + {t('sidebar.disabledAddons') === '被禁用的组件 (.disabled)' ? '未安装但已知的 Addon' : 'Known Uninstalled Addons'}

- 管理之前扫描过、在合集中或手动加入但当前未在本地文件夹中的组件。 + {t('sidebar.disabledAddons') === '被禁用的组件 (.disabled)' + ? '管理之前扫描过、在合集中或手动加入但当前未在本地文件夹中的组件。' + : 'Manage addons previously scanned, from collections, or manually added but not currently on disk.'}

- -
- -
- {/* Action Bar / Search */} + {/* Search */}
setSearchQuery(e.target.value)} style={{ @@ -133,193 +88,148 @@ export const KnownUninstalledView: React.FC = ({
- {/* Floating Batch Actions */} - {isSelectMode && selectedIds.length > 0 && ( -
- 已选中 {selectedIds.length} 个项目 -
- - - - -
-
- )} - - {/* Grid of Addons */} + {/* Grid */} {filteredAddons.length === 0 ? (
-

没有找到匹配 of 已知组件

+

{t('common.emptyStateTitle')}

) : ( -
+
{filteredAddons.map((addon) => { const isDownloading = addon.workshopId ? downloadProgress[addon.workshopId] !== undefined : false; const progress = addon.workshopId ? (downloadProgress[addon.workshopId] || 0) : 0; - const title = addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName; + const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; const author = getAddonAuthor(addon); const categories = getAddonCategories(addon); + const isSelected = selectedIds.includes(addon.id); return ( -
isSelectMode && handleSelectToggle(addon.id)} +
- {/* Checkbox for selection */} - {isSelectMode && ( -
- {}} // handled by card onClick - style={{ width: '20px', height: '20px', cursor: 'pointer' }} - /> + {/* Checkbox Wrapper */} + {isSelectMode && onSelectToggle && ( +
{ + e.stopPropagation(); + onSelectToggle(addon.id); + }} + title={isSelected ? t('addonCard.deselect') : t('addonCard.select')} + > + {isSelected ? ( + + ) : null}
)} - {/* Card Thumbnail */} -
- {addon.imagePath ? ( - {title} { - (e.target as HTMLImageElement).src = ''; - }} - /> - ) : ( -
- 暂无图片 -
- )} - {addon.workshopId && ( -
- ID: {addon.workshopId} -
- )} -
+
{ + if (isSelectMode && onSelectToggle) { + onSelectToggle(addon.id); + } + }} + > +
+ {addon.imagePath ? ( + + +
+ } + /> + ) : ( +
+ +
+ )} +
- {/* Card Content */} -
-

- {title} -

+
+ {t('addonCard.disabled')} + + {t('sidebar.disabledAddons') === '被禁用的组件 (.disabled)' ? '未安装' : 'Uninstalled'} + +
-

- 作者: {author} -

+
+

{title}

- {/* Chips */} -
- {categories.map((c) => ( - - {c} - - ))} - {addon.steamDetails?.file_size && ( - - 大小: {formatBytes(parseInt(addon.steamDetails.file_size))} - - )} -
+
+ {t('addonCard.author', { author })} +
- {/* Description summary */} - {addon.steamDetails?.description && ( -

- {addon.steamDetails.description} +

+ {addon.steamDetails?.description || ''}

- )} - {/* Actions */} - {!isSelectMode && ( -
+
+ {addon.steamDetails?.file_size && ( + {t('addonCard.fileSize', { size: formatBytes(parseInt(addon.steamDetails.file_size)) })} + )} +
+ +
+ {categories.map(c => ( + {t(`categories.${c}`, c)} + ))} + {addon.workshopId && ( + + ID: {addon.workshopId} + + )} +
+
+
+ + {!isSelectMode && ( +
+
+
{addon.workshopId ? ( - - ) : ( -
- 本地组件无法下载 -
- )} + ) : null} {addon.workshopId && ( - )} -
- )} -
+
+ )}
); })} diff --git a/src/components/MasterCollectionHeader.tsx b/src/components/MasterCollectionHeader.tsx new file mode 100644 index 0000000..1875653 --- /dev/null +++ b/src/components/MasterCollectionHeader.tsx @@ -0,0 +1,84 @@ +import { Edit3, Trash2 } from 'lucide-react'; +import { MasterCollection, Group } from '../types/addon'; +import { useTranslation } from 'react-i18next'; + +interface MasterCollectionHeaderProps { + currentMasterCollection: MasterCollection; + groupsInCollection: Group[]; + onRenameCollection: () => void; + onDeleteCollection: () => void; +} + +export function MasterCollectionHeader({ + currentMasterCollection, + groupsInCollection, + onRenameCollection, + onDeleteCollection, +}: MasterCollectionHeaderProps) { + const { t } = useTranslation(); + + const displayName = currentMasterCollection.nameKey + ? t(currentMasterCollection.nameKey, currentMasterCollection.name) + : currentMasterCollection.name; + + const totalAddons = groupsInCollection.reduce((sum, g) => sum + (g.addons?.length || 0), 0); + + return ( +
+
+
+

{displayName}

+

+ {t('masterCollections.desc', { count: groupsInCollection.length })} + {totalAddons > 0 && ` · ${t('masterCollections.totalAddons', { count: totalAddons })}`} +

+
+ {!currentMasterCollection.isSystem && ( +
+ + +
+ )} +
+ + {groupsInCollection.length > 0 && ( +
+ {groupsInCollection.map(g => ( + + {g.name} ({g.addons?.length || 0}) + + ))} +
+ )} +
+ ); +} diff --git a/src/components/MasterCollectionModal.tsx b/src/components/MasterCollectionModal.tsx new file mode 100644 index 0000000..4fcf824 --- /dev/null +++ b/src/components/MasterCollectionModal.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface MasterCollectionModalProps { + open: boolean; + onCancel: () => void; + onConfirm: (name: string) => void; + isSubmitting?: boolean; +} + +export const MasterCollectionModal: React.FC = ({ + open, + onCancel, + onConfirm, + isSubmitting = false, +}) => { + const { t } = useTranslation(); + const [name, setName] = useState(''); + + useEffect(() => { + if (open) { + setName(''); + } + }, [open]); + + if (!open) return null; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || isSubmitting) return; + onConfirm(name.trim()); + }; + + return ( +
+ e.stopPropagation()} + > +

{t('masterCollections.createTitle')}

+

+ {t('masterCollections.createDesc')} +

+ +
+ + setName(e.target.value)} + required + disabled={isSubmitting} + autoFocus + /> +
+ +
+ + +
+ +
+ ); +}; diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index a6871ab..3d50f1d 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -29,22 +29,25 @@ describe('Sidebar', () => { }, ]; + const defaultProps = { + addons: mockAddons, + groups: mockGroups, + masterCollections: [], + currentFilterTab: 'all', + selectedGroupId: null, + selectedMasterCollectionId: null, + onFilterTabChange: vi.fn(), + onOpenGroupModal: vi.fn(), + onOpenMasterCollectionModal: vi.fn(), + onAutoGrouping: vi.fn(), + autoGrouping: false, + disabledCount: 1, + totalAddonsCount: 2, + knownUninstalledCount: 3, + }; + test('renders menu items and badges correctly', () => { - render( - - ); + render(); expect(screen.getByText('全部组件')).toBeDefined(); expect(screen.getByText('全部组件').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('2'); @@ -66,21 +69,7 @@ describe('Sidebar', () => { }); test('renders noGroups text when groups list is empty', () => { - render( - - ); + render(); expect(screen.getByText('暂无分组,可自动归类或手动创建。')).toBeDefined(); }); @@ -88,21 +77,7 @@ describe('Sidebar', () => { test('calls onFilterTabChange when menu items are clicked', () => { const onFilterTabChange = vi.fn(); - render( - - ); + render(); fireEvent.click(screen.getByText('手动安装 (Addons)')); expect(onFilterTabChange).toHaveBeenCalledWith('loading', null); @@ -117,16 +92,9 @@ describe('Sidebar', () => { render( ); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ec31bf7..24e889f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,15 +1,18 @@ import React from 'react'; -import { Layers, Grid, FolderOpen, Folder, Lock, FolderPlus, Plus, Sparkles, Settings, Download, Globe } from 'lucide-react'; -import { Addon, Group } from '../types/addon'; +import { Layers, Grid, FolderOpen, Folder, Lock, FolderPlus, Plus, Sparkles, Settings, Download, Globe, Library } from 'lucide-react'; +import { Addon, Group, MasterCollection } from '../types/addon'; import { useTranslation } from 'react-i18next'; interface SidebarProps { addons: Record; groups: Group[]; + masterCollections: MasterCollection[]; currentFilterTab: string; selectedGroupId: string | null; + selectedMasterCollectionId: string | null; onFilterTabChange: (tab: string, groupId: string | null) => void; onOpenGroupModal: () => void; + onOpenMasterCollectionModal: () => void; onAutoGrouping: () => void; autoGrouping: boolean; disabledCount: number; @@ -20,10 +23,13 @@ interface SidebarProps { export const Sidebar: React.FC = ({ addons, groups, + masterCollections, currentFilterTab, selectedGroupId, + selectedMasterCollectionId, onFilterTabChange, onOpenGroupModal, + onOpenMasterCollectionModal, onAutoGrouping, autoGrouping, disabledCount, @@ -34,6 +40,13 @@ export const Sidebar: React.FC = ({ const loadingCount = Object.values(addons).filter(a => a.dirType === 'loading').length; const workshopCount = Object.values(addons).filter(a => a.dirType === 'workshop').length; + const getMasterCollectionDisplayName = (mc: MasterCollection) => { + if (mc.nameKey) { + return t(mc.nameKey, mc.name); + } + return mc.name; + }; + return (
@@ -47,7 +60,7 @@ export const Sidebar: React.FC = ({
- - - - - - + {/* Master Collections Section */} +
{t('sidebar.masterCollections')}
+ +
+ {masterCollections.length === 0 ? ( +
+ {t('sidebar.noMasterCollections')} +
+ ) : ( + masterCollections.map(mc => ( + + )) + )} +
+ + {/* Groups Section */}
{t('sidebar.myGroups')}
- +
- {groups.length === 0 ? ( + {groups.filter(g => !g.masterCollectionIds || g.masterCollectionIds.length === 0).length === 0 ? (
{t('sidebar.noGroups')}
) : ( - groups.map(group => ( + groups.filter(g => !g.masterCollectionIds || g.masterCollectionIds.length === 0).map(group => (
- - - + + -
-
- {t('detailModal.addonSize')} - {formatBytes(addon.fileSize)} -
+ {displaySize && ( +
+ {t('detailModal.addonSize')} + {displaySize} +
+ )} {addon.filesCount > 0 && (
{t('detailModal.filesInVpk')} @@ -138,14 +158,24 @@ export const DetailModal: React.FC = ({
{t('detailModal.directory')} - {addon.dirType === 'loading' ? t('detailModal.manualInstall') : t('detailModal.workshop')} + {isUninstalled + ? t('addonCard.uninstalled') + : addon.dirType === 'loading' + ? t('detailModal.manualInstall') + : t('detailModal.workshop')}
{t('detailModal.currentStatus')} - - {addon.isEnabled ? t('detailModal.enabled') : t('detailModal.disabled')} - + {isUninstalled ? ( + + {t('addonCard.uninstalled')} + + ) : ( + + {addon.isEnabled ? t('detailModal.enabled') : t('detailModal.disabled')} + + )}
{addon.workshopId && (
@@ -283,12 +313,30 @@ export const DetailModal: React.FC = ({
- + {isUninstalled ? ( + addon.workshopId && onDownload && ( + + ) + ) : ( + + )} {addon.dirType === 'workshop' && ( +
+
+
+ ); +}; diff --git a/src/components/CustomSelect.test.tsx b/src/components/CustomSelect.test.tsx new file mode 100644 index 0000000..a8d2eb6 --- /dev/null +++ b/src/components/CustomSelect.test.tsx @@ -0,0 +1,47 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CustomSelect } from './CustomSelect'; + +describe('CustomSelect', () => { + const options = [ + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2' }, + ]; + + test('renders current value label', () => { + render( + + ); + expect(screen.getByText('Option 1')).toBeDefined(); + expect(screen.queryByText('Option 2')).toBeNull(); + }); + + test('opens dropdown on click and triggers onChange', () => { + const onChange = vi.fn(); + render( + + ); + + // Click trigger to open dropdown + fireEvent.click(screen.getByText('Option 1')); + + // Option 2 should now be visible + const opt2Element = screen.getByText('Option 2'); + expect(opt2Element).toBeDefined(); + + // Click Option 2 + fireEvent.click(opt2Element); + expect(onChange).toHaveBeenCalledWith('opt2'); + + // Dropdown should be closed now + expect(screen.queryByText('Option 2')).toBeNull(); + }); +}); diff --git a/src/components/CustomSelect.tsx b/src/components/CustomSelect.tsx new file mode 100644 index 0000000..338e1e1 --- /dev/null +++ b/src/components/CustomSelect.tsx @@ -0,0 +1,135 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { ChevronDown } from 'lucide-react'; + +interface Option { + value: string; + label: string; +} + +interface CustomSelectProps { + options: Option[]; + value: string; + onChange: (value: string) => void; + minWidth?: string; + style?: React.CSSProperties; +} + +export const CustomSelect: React.FC = ({ + options, + value, + onChange, + minWidth = '120px', + style, +}) => { + const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + + const selectedOption = options.find((opt) => opt.value === value) || options[0]; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleSelect = (val: string) => { + onChange(val); + setIsOpen(false); + }; + + return ( +
+
setIsOpen(!isOpen)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 12px', + borderRadius: '100px', + border: '1px solid var(--md-sys-color-outline-variant)', + backgroundColor: 'var(--md-sys-surface-container-low)', + color: 'var(--md-sys-color-on-surface)', + fontSize: '12px', + cursor: 'pointer', + transition: 'all 0.2s ease', + boxSizing: 'border-box', + height: '100%' + }} + > + + {selectedOption ? selectedOption.label : ''} + + +
+ + {isOpen && ( +
+ {options.map((opt) => { + const isSelected = opt.value === value; + return ( +
handleSelect(opt.value)} + style={{ + padding: '8px 12px', + fontSize: '12px', + color: isSelected ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-surface)', + backgroundColor: isSelected ? 'var(--md-sys-surface-container-highest)' : 'transparent', + cursor: 'pointer', + transition: 'background-color 0.15s ease', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' + }} + > + {opt.label} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/components/EditGroupModal.test.tsx b/src/components/EditGroupModal.test.tsx index b4b8ed3..e87dcc9 100644 --- a/src/components/EditGroupModal.test.tsx +++ b/src/components/EditGroupModal.test.tsx @@ -120,9 +120,8 @@ describe('EditGroupModal', () => { } }); - test('prompts confirmation when collection ID changes and submits only on confirm', () => { + test('prompts confirmation when collection ID changes and submits only on confirm', async () => { const onConfirm = vi.fn(); - confirmMock.mockReturnValue(false); render( { const form = colInput.closest('form'); if (form) { fireEvent.submit(form); - expect(confirmMock).toHaveBeenCalledTimes(1); expect(onConfirm).not.toHaveBeenCalled(); - // Rerender with confirmMock returning true - confirmMock.mockReturnValue(true); - fireEvent.submit(form); - expect(confirmMock).toHaveBeenCalledTimes(2); + // The confirm screen is showing + expect(await screen.findByText('确认修改')).toBeDefined(); + + // Click cancel should go back + const cancelButton = screen.getByText('取消'); + fireEvent.click(cancelButton); + expect(screen.queryByText('确认修改')).toBeNull(); + + // Submit again to show confirmation + const saveButton = screen.getByText('保存'); + fireEvent.click(saveButton); + expect(await screen.findByText('确认修改')).toBeDefined(); + + // Click confirm should call onConfirm + const confirmButton = screen.getByText('确认'); + fireEvent.click(confirmButton); expect(onConfirm).toHaveBeenCalledWith('g1', 'My Group', [], '999'); } }); diff --git a/src/components/EditGroupModal.tsx b/src/components/EditGroupModal.tsx index 7eac212..1b26dd9 100644 --- a/src/components/EditGroupModal.tsx +++ b/src/components/EditGroupModal.tsx @@ -31,14 +31,16 @@ export const EditGroupModal: React.FC = ({ const [name, setName] = useState(''); const [tagsInput, setTagsInput] = useState(''); const [collectionId, setCollectionId] = useState(''); + const [showConfirmSave, setShowConfirmSave] = useState(false); useEffect(() => { if (open) { setName(currentName); setTagsInput(currentTags.join(', ')); setCollectionId(currentCollectionId); + setShowConfirmSave(false); } - }, [open, currentName, currentTags, currentCollectionId]); + }, [open, groupId]); if (!open) return null; @@ -54,26 +56,66 @@ export const EditGroupModal: React.FC = ({ e.preventDefault(); if (!name.trim() || isSubmitting) return; - const tags = tagsInput - .split(',') - .map(s => s.trim()) - .filter(Boolean); - const targetCollectionId = collectionId.trim() || undefined; - // If collection ID was added/changed, we can prompt or alert + // If collection ID was added/changed, we prompt if (targetCollectionId !== (currentCollectionId || '') && targetCollectionId) { - const confirmSync = window.confirm( - '设置创意工坊合集后,保存时将自动从 Steam 创意工坊拉取该合集内的所有组件,并丢弃当前分组的所有其它自定义分类组件。确认继续吗?' - ); - if (!confirmSync) return; + setShowConfirmSave(true); + return; } + const tags = tagsInput + .split(',') + .map(s => s.trim()) + .filter(Boolean); + onConfirm(groupId, name.trim(), tags, targetCollectionId); }; const isCollectionChanged = collectionId.trim() !== (currentCollectionId || ''); + if (showConfirmSave) { + return ( +
+
e.stopPropagation()}> +

+ + 确认修改 +

+
+ 设置创意工坊合集后,保存时将自动从 Steam 创意工坊拉取该合集内的所有组件,并丢弃当前分组的所有其它自定义分类组件。确认继续吗? +
+
+ + +
+
+
+ ); + } + return (
e.stopPropagation()} style={{ maxWidth: '480px', borderRadius: '28px', padding: '24px' }}> diff --git a/src/components/KnownUninstalledView.tsx b/src/components/KnownUninstalledView.tsx index 7161e96..80b0a5c 100644 --- a/src/components/KnownUninstalledView.tsx +++ b/src/components/KnownUninstalledView.tsx @@ -69,7 +69,7 @@ export const KnownUninstalledView: React.FC = ({ {/* Search */}
-
+
void; + onConfirm: (value: string) => void; + isSubmitting?: boolean; +} + +export const PromptModal: React.FC = ({ + open, + title, + message, + placeholder, + defaultValue = '', + onCancel, + onConfirm, + isSubmitting = false, +}) => { + const { t } = useTranslation(); + const [value, setValue] = useState(''); + + useEffect(() => { + if (open) { + setValue(defaultValue); + } + }, [open, defaultValue]); + + if (!open) return null; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + onConfirm(value); + }; + + return ( +
+ e.stopPropagation()} + > +

{title}

+ {message && ( +

+ {message} +

+ )} + +
+ setValue(e.target.value)} + required + disabled={isSubmitting} + autoFocus + style={{ width: '100%', borderRadius: '12px' }} + /> +
+ +
+ + +
+ +
+ ); +}; diff --git a/src/components/TopBar.test.tsx b/src/components/TopBar.test.tsx index 4c0034b..a9dd422 100644 --- a/src/components/TopBar.test.tsx +++ b/src/components/TopBar.test.tsx @@ -29,7 +29,7 @@ describe('TopBar', () => { ); // Search query input - const searchInput = screen.getByPlaceholderText('搜索组件名称, 描述, 作者或创意工坊 ID...') as HTMLInputElement; + const searchInput = screen.getByPlaceholderText('名称, 描述, 作者或创意工坊ID') as HTMLInputElement; expect(searchInput.value).toBe('tank'); // Search query input change @@ -45,12 +45,16 @@ describe('TopBar', () => { fireEvent.click(screen.getByText('战役')); expect(onCategoryChange).toHaveBeenCalledWith('Campaign'); - // Sort select - const select = screen.getByRole('combobox') as HTMLSelectElement; - expect(select.value).toBe('size'); + // Sort select trigger + const selectTrigger = screen.getByText('大小'); + expect(selectTrigger).toBeDefined(); - // Select change - fireEvent.change(select, { target: { value: 'title' } }); + // Open dropdown + fireEvent.click(selectTrigger); + + // Click option + const titleOption = screen.getByText('名称'); + fireEvent.click(titleOption); expect(onSortByChange).toHaveBeenCalledWith('title'); // Batch Manage button diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 44bb0d4..0db4a4c 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Search, RefreshCw, CheckSquare, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { CustomSelect } from './CustomSelect'; interface TopBarProps { searchQuery: string; @@ -44,39 +45,37 @@ export const TopBar: React.FC = ({ />
-
-
- {categoriesList.map(cat => ( - - ))} -
- -
+
+ {categoriesList.map(cat => ( + + ))} +
- + onChange={onSortByChange} + minWidth="120px" + />
); }; diff --git a/src/components/workshop/DetailDrawer.tsx b/src/components/workshop/DetailDrawer.tsx index 36fe595..728dc80 100644 --- a/src/components/workshop/DetailDrawer.tsx +++ b/src/components/workshop/DetailDrawer.tsx @@ -1,9 +1,10 @@ /** Workshop item/collection detail drawer */ -import React from 'react'; +import React, { useState } from 'react'; import { Download, Globe, FolderPlus, Loader2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { CollectionData } from './types'; +import { AlertModal } from '../AlertModal'; interface DetailDrawerProps { selectedItem: any | null; @@ -37,20 +38,33 @@ export const DetailDrawer: React.FC = ({ onImportCollection, }) => { const { t } = useTranslation(); + const [alertInfo, setAlertInfo] = useState<{ open: boolean; title: string; message: string }>({ + open: false, + title: '', + message: '', + }); const handleImportCollectionGroup = () => { if (!selectedCollection) return; const name = selectedCollection.collection.title || t('workshop.detail.defaultCollectionName'); const itemIds = selectedCollection.items.map((item) => item.publishedfileid); onImportCollection(name, itemIds); - alert(t('workshop.detail.importSuccess', { count: itemIds.length, name })); + setAlertInfo({ + open: true, + title: t('common.success') || '成功', + message: t('workshop.detail.importSuccess', { count: itemIds.length, name }), + }); }; const handleDownloadAllCollection = () => { if (!selectedCollection) return; const itemIds = selectedCollection.items.map((item) => item.publishedfileid); itemIds.forEach((id) => onDownload(id)); - alert(t('workshop.detail.downloadAllSuccess', { count: itemIds.length })); + setAlertInfo({ + open: true, + title: t('common.success') || '成功', + message: t('workshop.detail.downloadAllSuccess', { count: itemIds.length }), + }); }; if (!selectedItem && !selectedCollection && !selectedItemLoading && !selectedCollectionLoading) { @@ -58,8 +72,9 @@ export const DetailDrawer: React.FC = ({ } return ( -
+
= ({
-
+ setAlertInfo({ open: false, title: '', message: '' })} + /> + ); }; diff --git a/src/i18n.tsx b/src/i18n.tsx index 246336a..c4c033a 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -54,10 +54,10 @@ const zh = { newMasterCollection: "新建大合集", }, topbar: { - searchPlaceholder: "搜索组件名称, 描述, 作者或创意工坊 ID...", - sortByTitle: "按名称排序", - sortBySize: "按大小排序", - sortById: "按创意工坊ID排序", + searchPlaceholder: "名称, 描述, 作者或创意工坊ID", + sortByTitle: "名称", + sortBySize: "大小", + sortById: "创意工坊ID", exitBatch: "退出批量", batchManage: "批量管理", batchManageTooltip: "批量管理附件组件", @@ -471,10 +471,10 @@ const en = { newMasterCollection: "New Master Collection", }, topbar: { - searchPlaceholder: "Search addon name, description, author or workshop ID...", - sortByTitle: "Sort by Name", - sortBySize: "Sort by Size", - sortById: "Sort by Workshop ID", + searchPlaceholder: "Name, description, author or workshop ID", + sortByTitle: "Name", + sortBySize: "Size", + sortById: "Workshop ID", exitBatch: "Exit Batch", batchManage: "Batch Manage", batchManageTooltip: "Batch manage addon components", diff --git a/src/index.css b/src/index.css index 90f5bc0..ffa7831 100644 --- a/src/index.css +++ b/src/index.css @@ -216,8 +216,9 @@ body { /* Top Action Bar */ .top-bar { - height: 80px; - padding: 0 32px; + min-height: 80px; + height: auto; + padding: 16px 32px; border-bottom: 1px solid var(--md-sys-color-outline-variant); display: flex; align-items: center; @@ -235,7 +236,8 @@ body { border: 1px solid var(--md-sys-color-outline-variant); border-radius: 100px; padding: 8px 20px; - width: 280px; + width: 320px; + flex-shrink: 0; gap: 12px; transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); } @@ -259,10 +261,20 @@ body { color: var(--md-sys-color-outline); } +.categories-container { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 1; + padding: 0 16px; +} + .top-bar-actions { display: flex; align-items: center; gap: 12px; + flex-shrink: 0; } /* Cards Grid Scrollable Container */ @@ -1298,3 +1310,57 @@ input:checked + .slider:before { .workshop-browser.is-scroll-interacting .addon-card:hover .addon-card-image { transform: none; } + +/* Custom Select Styling */ +select { + appearance: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + background-color: var(--md-sys-surface-container-low) !important; + border: 1px solid var(--md-sys-color-outline-variant) !important; + color: var(--md-sys-color-on-surface) !important; + font-family: var(--font-family) !important; + outline: none !important; + cursor: pointer !important; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238d9199' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e") !important; + background-repeat: no-repeat !important; + background-position: right 12px center !important; + background-size: 14px !important; + transition: border-color 0.2s, box-shadow 0.2s !important; +} + +select:focus { + border-color: var(--md-sys-color-primary) !important; + box-shadow: 0 0 0 2px rgba(179, 197, 255, 0.2) !important; +} + +select option { + background-color: var(--md-sys-surface-container-high) !important; + color: var(--md-sys-color-on-surface) !important; + padding: 8px !important; +} + +/* Custom React Select Component Styling */ +.custom-select-trigger:hover { + border-color: var(--md-sys-color-primary) !important; + background-color: var(--md-sys-surface-container) !important; +} + +.custom-select-option:hover { + background-color: var(--md-sys-surface-container-highest) !important; + color: #fff !important; +} + +.custom-select-dropdown::-webkit-scrollbar { + width: 4px; +} +.custom-select-dropdown::-webkit-scrollbar-track { + background: transparent; +} +.custom-select-dropdown::-webkit-scrollbar-thumb { + background: var(--md-sys-color-outline-variant); + border-radius: 4px; +} +.custom-select-dropdown::-webkit-scrollbar-thumb:hover { + background: var(--md-sys-color-outline); +} From d81006e3da679b3fe1b0daaba99868787b31d00d Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 18:29:14 +0800 Subject: [PATCH 27/81] =?UTF-8?q?style:=20=E4=B8=BA=E6=89=80=E6=9C=89?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=8A=A0=E8=BD=BD=E6=B7=BB=E5=8A=A0=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8D=A0=E4=BD=8D=E5=9B=BE=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=9B=BE=E8=A3=82=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CacheImage.tsx | 35 ++++++++++++- src/components/GroupCard.tsx | 11 +++- src/components/workshop/DetailDrawer.tsx | 65 +++++++++++++++++++++--- src/components/workshop/ItemCard.tsx | 16 +++++- 4 files changed, 116 insertions(+), 11 deletions(-) diff --git a/src/components/CacheImage.tsx b/src/components/CacheImage.tsx index 7b01e2a..21138be 100644 --- a/src/components/CacheImage.tsx +++ b/src/components/CacheImage.tsx @@ -58,8 +58,41 @@ export const CacheImage: React.FC = ({ srcPath, fallback, onErr }; if (hasError || !srcPath || !resolvedSrc) { - return <>{fallback}; + if (fallback !== undefined) { + return <>{fallback}; + } + return ( +
+ + + + + +
+ ); } return ; }; + diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index 0e9a37b..fe175c1 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -99,7 +99,16 @@ export const GroupCard: React.FC = ({ >
{imagePath ? ( - + + +
+ } + /> ) : (
diff --git a/src/components/workshop/DetailDrawer.tsx b/src/components/workshop/DetailDrawer.tsx index 728dc80..38b0706 100644 --- a/src/components/workshop/DetailDrawer.tsx +++ b/src/components/workshop/DetailDrawer.tsx @@ -1,7 +1,8 @@ /** Workshop item/collection detail drawer */ import React, { useState } from 'react'; -import { Download, Globe, FolderPlus, Loader2 } from 'lucide-react'; +import { Download, Globe, FolderPlus, Loader2, FileText } from 'lucide-react'; +import { CacheImage } from '../CacheImage'; import { useTranslation } from 'react-i18next'; import { CollectionData } from './types'; import { AlertModal } from '../AlertModal'; @@ -166,8 +167,8 @@ const ItemDetail: React.FC = ({ return (
- {item.title} = ({ marginBottom: '16px', backgroundColor: '#111', }} + fallback={ +
+ +
+ } />

= ({ return (
- {collection.collection.title} = ({ marginBottom: '16px', backgroundColor: '#111', }} + fallback={ +
+ +
+ } />

= ({ backgroundColor: 'var(--md-sys-color-surface-container-low)', }} > - = ({ borderRadius: '4px', backgroundColor: '#111', }} + fallback={ +
+ +
+ } />
= ({ )}
- {item.title} + + +
+ } + />
{isDownloaded && ( From 002c749df4182c752830d3b30888f2c765e247e5 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 18:30:56 +0800 Subject: [PATCH 28/81] chore: remove package-lock.json from tracking and add to .gitignore --- .gitignore | 2 + package-lock.json | 3342 --------------------------------------------- 2 files changed, 2 insertions(+), 3342 deletions(-) delete mode 100644 package-lock.json diff --git a/.gitignore b/.gitignore index 70e8d84..18e2c71 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ steam-bridge/target/ .playwright-mcp known_addons.json /l4a +package-lock.json + diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 55f1337..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3342 +0,0 @@ -{ - "name": "addon-manager", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "addon-manager", - "version": "0.0.0", - "dependencies": { - "@tauri-apps/api": "^2.11.1", - "i18next": "^26.3.4", - "lucide-react": "^1.23.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-i18next": "^17.0.8" - }, - "devDependencies": { - "@tauri-apps/cli": "^2.1.0", - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "esbuild": "^0.28.1", - "jsdom": "^29.1.1", - "oxlint": "^1.71.0", - "typescript": "^6.0.3", - "vite": "^8.1.1", - "vitest": "^4.1.9" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", - "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-android-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", - "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", - "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", - "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", - "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", - "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", - "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", - "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", - "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", - "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", - "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", - "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", - "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", - "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", - "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", - "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", - "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", - "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", - "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", - "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.4", - "@tauri-apps/cli-darwin-x64": "2.11.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", - "@tauri-apps/cli-linux-arm64-musl": "2.11.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-musl": "2.11.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", - "@tauri-apps/cli-win32-x64-msvc": "2.11.4" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", - "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", - "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" - } - }, - "node_modules/i18next": { - "version": "26.3.4", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz", - "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==", - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/oxlint": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", - "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", - "dev": true, - "license": "MIT", - "bin": { - "oxlint": "bin/oxlint" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.72.0", - "@oxlint/binding-android-arm64": "1.72.0", - "@oxlint/binding-darwin-arm64": "1.72.0", - "@oxlint/binding-darwin-x64": "1.72.0", - "@oxlint/binding-freebsd-x64": "1.72.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", - "@oxlint/binding-linux-arm-musleabihf": "1.72.0", - "@oxlint/binding-linux-arm64-gnu": "1.72.0", - "@oxlint/binding-linux-arm64-musl": "1.72.0", - "@oxlint/binding-linux-ppc64-gnu": "1.72.0", - "@oxlint/binding-linux-riscv64-gnu": "1.72.0", - "@oxlint/binding-linux-riscv64-musl": "1.72.0", - "@oxlint/binding-linux-s390x-gnu": "1.72.0", - "@oxlint/binding-linux-x64-gnu": "1.72.0", - "@oxlint/binding-linux-x64-musl": "1.72.0", - "@oxlint/binding-openharmony-arm64": "1.72.0", - "@oxlint/binding-win32-arm64-msvc": "1.72.0", - "@oxlint/binding-win32-ia32-msvc": "1.72.0", - "@oxlint/binding-win32-x64-msvc": "1.72.0" - }, - "peerDependencies": { - "oxlint-tsgolint": ">=0.22.1", - "vite-plus": "*" - }, - "peerDependenciesMeta": { - "oxlint-tsgolint": { - "optional": true - }, - "vite-plus": { - "optional": true - } - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/react-i18next": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", - "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "i18next": ">= 26.2.0", - "react": ">= 16.8.0", - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.138.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", - "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.6" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", - "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - } - } -} From 9560ad6ab48ce0ca9a9f5e684eb70c0237ea4069 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 18:58:30 +0800 Subject: [PATCH 29/81] Add addon file watcher auto refresh --- src-tauri/Cargo.lock | 159 ++++++++++++- src-tauri/Cargo.toml | 1 + src-tauri/src/commands/handlers.rs | 31 ++- src-tauri/src/lib.rs | 11 + src-tauri/src/watcher.rs | 344 +++++++++++++++++++++++++++++ src/hooks/useAddonManager.test.tsx | 107 ++++++++- src/hooks/useAddonManager.ts | 81 +++++-- src/i18n.tsx | 5 +- 8 files changed, 716 insertions(+), 23 deletions(-) create mode 100644 src-tauri/src/watcher.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4c7f523..c955935 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -84,6 +84,7 @@ dependencies = [ "libloading 0.8.9", "log", "md-5", + "notify", "open", "regex", "reqwest 0.12.28", @@ -929,6 +930,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1008,6 +1019,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1743,6 +1763,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1885,6 +1925,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -2041,6 +2101,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.1" @@ -2130,6 +2202,25 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -4105,7 +4196,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.1", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4936,6 +5027,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4978,6 +5078,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5018,6 +5133,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5030,6 +5151,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5042,6 +5169,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5060,6 +5193,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5072,6 +5211,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5084,6 +5229,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5096,6 +5247,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3c61a70..008fbfc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,3 +33,4 @@ image = { version = "0.25", default-features = false, features = ["jpeg"] } vtf = "0.4" md-5 = "0.10.6" libloading = "0.8" +notify = "6.1" diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 5dc4cff..3c29d67 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -678,6 +678,24 @@ fn database_with_workshop_cache(db: &Database, workshop_cache_path: &Path) -> Da response } +pub async fn rescan_database_snapshot(app_handle: &AppHandle) -> Result { + let state = app_handle.state::(); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + )) +} + async fn fetch_steam_details(workshop_ids: &[String]) -> Result, String> { fetch_steam_details_web(workshop_ids).await } @@ -1453,9 +1471,10 @@ pub async fn save_settings( loading_dir: String, enable_dummy_bypass: bool, state: State<'_, crate::AppState>, + app_handle: AppHandle, ) -> Result { let mut db = state.db.lock().await; - let loading_path = Path::new(&loading_dir); + let loading_path = PathBuf::from(&loading_dir); let workshop_dir = loading_path.join("workshop").to_string_lossy().to_string(); db.settings.workshop_dir = workshop_dir; @@ -1477,6 +1496,9 @@ pub async fn save_settings( &state.workshop_service, ) .await?; + if let Err(err) = crate::watcher::rebind_addon_watcher(&app_handle, &loading_path) { + crate::watcher::emit_watch_error(&app_handle, &err); + } Ok(database_with_workshop_cache( &db, &state.workshop_cache_path, @@ -1585,6 +1607,7 @@ pub async fn move_addons( return Err(errors.join("\n")); } + crate::watcher::suppress_internal_refresh(&state); for plan in plans { if target_dir_type == "workshop" && plan.dest_path.exists() && is_dummy_vpk(&plan.dest_path) { @@ -1679,6 +1702,7 @@ pub async fn toggle_addons( return Err(errors.join("\n")); } + crate::watcher::suppress_internal_refresh(&state); for (id, current_path, dest_path) in plans { fs::rename(¤t_path, &dest_path) .map_err(|e| format!("Failed to toggle {}: {}", id, e))?; @@ -1722,6 +1746,7 @@ pub async fn rename_addon( if let Some(addon) = db.addons.get(&id).cloned() { let current_path = PathBuf::from(&addon.current_path); if current_path.exists() { + crate::watcher::suppress_internal_refresh(&state); let dir = current_path.parent().unwrap(); let new_filename = if addon.is_enabled { sanitized.clone() @@ -1872,6 +1897,7 @@ pub async fn rename_addons( return Err(errors.join("\n")); } + crate::watcher::suppress_internal_refresh(&state); for plan in plans { fs::rename(&plan.current_path, &plan.dest_path) .map_err(|e| format!("Failed to rename {}: {}", plan.id, e))?; @@ -2773,6 +2799,7 @@ pub async fn delete_addons( let mut db = state.db.lock().await; if delete_file { + crate::watcher::suppress_internal_refresh(&state); for id in &ids { if let Some(addon) = db.addons.get(id) { let path = PathBuf::from(&addon.current_path); @@ -2875,6 +2902,7 @@ pub async fn download_addon( .await { if downloaded_via_bridge { + crate::watcher::suppress_internal_refresh(&state); let mut db = state.db.lock().await; scan_addons_internal( &mut db, @@ -2961,6 +2989,7 @@ pub async fn download_addon( file.flush() .map_err(|e| format!("Failed to flush downloaded file: {}", e))?; drop(file); + crate::watcher::suppress_internal_refresh(&state); fs::rename(&tmp_path, &dest_path) .map_err(|e| format!("Failed to finalize downloaded file: {}", e))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7d52646..da8312f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,10 +1,12 @@ use std::path::{Path, PathBuf}; +use std::sync::Mutex as StdMutex; use tauri::async_runtime::Mutex; use tauri::Manager; pub mod commands; pub mod steam; pub mod vpk; +pub mod watcher; pub struct AppState { pub settings_path: PathBuf, @@ -16,6 +18,7 @@ pub struct AppState { pub cache_dir: PathBuf, pub workshop_service: steam::WorkshopService, pub db: Mutex, + pub addon_watcher: StdMutex, } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -78,6 +81,7 @@ pub fn run() { &runtime_dir, app.handle(), ); + let initial_loading_dir = db.settings.loading_dir.clone(); app.manage(AppState { settings_path, @@ -89,8 +93,15 @@ pub fn run() { cache_dir, workshop_service: steam::WorkshopService::new(&bridge_base_dir), db: Mutex::new(db), + addon_watcher: StdMutex::new(watcher::AddonWatcherController::default()), }); + if let Err(err) = + watcher::rebind_addon_watcher(app.handle(), Path::new(&initial_loading_dir)) + { + watcher::emit_watch_error(app.handle(), &err); + } + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..8974c2b --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,344 @@ +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Manager}; + +const WATCHER_DEBOUNCE_MS: u64 = 400; +const INTERNAL_REFRESH_SUPPRESS_MS: u64 = 1_500; +const DB_UPDATED_EVENT: &str = "addons-db-updated"; +const WATCH_ERROR_EVENT: &str = "addons-watch-error"; + +#[derive(Serialize, Clone)] +struct AddonsDbUpdatedEvent { + data: crate::commands::Database, + external: bool, +} + +#[derive(Serialize, Clone)] +struct AddonsWatchErrorEvent { + message: String, +} + +enum WatcherSignal { + Event, + Error(String), +} + +#[derive(Default)] +struct RefreshQueueState { + running: bool, + pending: bool, + pending_external: bool, +} + +#[derive(Default)] +struct RefreshCoordinator { + queue: Mutex, + suppress_until: Mutex>, +} + +impl RefreshCoordinator { + fn suppress_internal_refresh(&self) { + if let Ok(mut guard) = self.suppress_until.lock() { + *guard = Some(Instant::now() + Duration::from_millis(INTERNAL_REFRESH_SUPPRESS_MS)); + } + } + + fn is_suppressed(&self) -> bool { + let Ok(mut guard) = self.suppress_until.lock() else { + return false; + }; + + match *guard { + Some(deadline) if Instant::now() < deadline => true, + Some(_) => { + *guard = None; + false + } + None => false, + } + } + + fn request_refresh(self: &Arc, app_handle: AppHandle, external: bool) { + let Ok(mut queue) = self.queue.lock() else { + emit_watch_error(&app_handle, "Failed to acquire watcher refresh queue"); + return; + }; + + if queue.running { + queue.pending = true; + queue.pending_external |= external; + return; + } + + queue.running = true; + drop(queue); + + spawn_refresh_loop(app_handle, self.clone(), external); + } +} + +pub struct AddonWatcherController { + watcher: Option, + watched_paths: Vec, + coordinator: Arc, +} + +impl Default for AddonWatcherController { + fn default() -> Self { + Self { + watcher: None, + watched_paths: Vec::new(), + coordinator: Arc::new(RefreshCoordinator::default()), + } + } +} + +impl AddonWatcherController { + pub fn suppress_internal_refresh(&self) { + self.coordinator.suppress_internal_refresh(); + } + + pub fn rebind(&mut self, app_handle: &AppHandle, loading_dir: &Path) -> Result<(), String> { + self.watcher = None; + self.watched_paths.clear(); + + let watched_paths = watched_paths_from_loading_dir(loading_dir); + for path in &watched_paths { + fs::create_dir_all(path).map_err(|err| { + format!( + "Failed to create watched directory {}: {}", + path.display(), + err + ) + })?; + } + + let (tx, rx) = mpsc::channel::(); + let app_handle_for_thread = app_handle.clone(); + let coordinator = self.coordinator.clone(); + std::thread::spawn(move || run_event_loop(rx, app_handle_for_thread, coordinator)); + + let watched_paths_for_callback = watched_paths.clone(); + let mut watcher = + notify::recommended_watcher(move |result: Result| match result { + Ok(event) => { + if is_relevant_event(&event, &watched_paths_for_callback) { + let _ = tx.send(WatcherSignal::Event); + } + } + Err(err) => { + let _ = tx.send(WatcherSignal::Error(err.to_string())); + } + }) + .map_err(|err| format!("Failed to create file watcher: {}", err))?; + + for path in &watched_paths { + watcher + .watch(path, RecursiveMode::NonRecursive) + .map_err(|err| format!("Failed to watch {}: {}", path.display(), err))?; + } + + self.watched_paths = watched_paths; + self.watcher = Some(watcher); + Ok(()) + } +} + +fn run_event_loop( + rx: Receiver, + app_handle: AppHandle, + coordinator: Arc, +) { + loop { + let signal = match rx.recv() { + Ok(signal) => signal, + Err(_) => return, + }; + + match signal { + WatcherSignal::Error(message) => emit_watch_error(&app_handle, &message), + WatcherSignal::Event => { + loop { + match rx.recv_timeout(Duration::from_millis(WATCHER_DEBOUNCE_MS)) { + Ok(WatcherSignal::Event) => continue, + Ok(WatcherSignal::Error(message)) => { + emit_watch_error(&app_handle, &message) + } + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => return, + } + } + + let external = !coordinator.is_suppressed(); + coordinator.request_refresh(app_handle.clone(), external); + } + } + } +} + +fn spawn_refresh_loop( + app_handle: AppHandle, + coordinator: Arc, + initial_external: bool, +) { + tauri::async_runtime::spawn(async move { + let mut next_external = initial_external; + + loop { + match crate::commands::handlers::rescan_database_snapshot(&app_handle).await { + Ok(data) => { + let _ = app_handle.emit( + DB_UPDATED_EVENT, + AddonsDbUpdatedEvent { + data, + external: next_external, + }, + ); + } + Err(message) => emit_watch_error(&app_handle, &message), + } + + let Ok(mut queue) = coordinator.queue.lock() else { + emit_watch_error(&app_handle, "Failed to finalize watcher refresh queue"); + return; + }; + + if queue.pending { + next_external = queue.pending_external; + queue.pending = false; + queue.pending_external = false; + drop(queue); + continue; + } + + queue.running = false; + break; + } + }); +} + +pub fn rebind_addon_watcher(app_handle: &AppHandle, loading_dir: &Path) -> Result<(), String> { + let state = app_handle.state::(); + let mut controller = state + .addon_watcher + .lock() + .map_err(|_| "Failed to acquire addon watcher controller".to_string())?; + controller.rebind(app_handle, loading_dir) +} + +pub fn suppress_internal_refresh(state: &crate::AppState) { + if let Ok(controller) = state.addon_watcher.lock() { + controller.suppress_internal_refresh(); + } +} + +pub fn emit_watch_error(app_handle: &AppHandle, message: &str) { + log::warn!("addon watcher: {}", message); + let _ = app_handle.emit( + WATCH_ERROR_EVENT, + AddonsWatchErrorEvent { + message: message.to_string(), + }, + ); +} + +fn is_relevant_event(event: &Event, watched_paths: &[PathBuf]) -> bool { + match event.kind { + EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any => {} + _ => return false, + } + + event + .paths + .iter() + .any(|path| is_relevant_addon_path(path, watched_paths)) +} + +fn is_relevant_addon_path(path: &Path, watched_paths: &[PathBuf]) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + if !(file_name.ends_with(".vpk") || file_name.ends_with(".vpk.disabled")) { + return false; + } + + watched_paths.iter().any(|watched| { + path.parent() + .map(|parent| parent == watched) + .unwrap_or(false) + }) +} + +fn watched_paths_from_loading_dir(loading_dir: &Path) -> Vec { + vec![loading_dir.to_path_buf(), loading_dir.join("workshop")] +} + +#[cfg(test)] +mod tests { + use super::{ + is_relevant_addon_path, watched_paths_from_loading_dir, AddonWatcherController, + INTERNAL_REFRESH_SUPPRESS_MS, + }; + use notify::{event::ModifyKind, Event, EventKind}; + use std::path::{Path, PathBuf}; + use std::thread; + use std::time::Duration; + + #[test] + fn watched_paths_match_loading_and_workshop_roots() { + let loading_dir = Path::new("/tmp/l4a-loading"); + let paths = watched_paths_from_loading_dir(loading_dir); + assert_eq!( + paths, + vec![ + PathBuf::from("/tmp/l4a-loading"), + PathBuf::from("/tmp/l4a-loading/workshop"), + ] + ); + } + + #[test] + fn addon_path_filter_only_accepts_direct_vpk_entries() { + let watched = watched_paths_from_loading_dir(Path::new("/tmp/l4a-loading")); + + assert!(is_relevant_addon_path( + Path::new("/tmp/l4a-loading/test.vpk"), + &watched + )); + assert!(is_relevant_addon_path( + Path::new("/tmp/l4a-loading/workshop/test.vpk.disabled"), + &watched + )); + assert!(!is_relevant_addon_path( + Path::new("/tmp/l4a-loading/workshop/subdir/test.vpk"), + &watched + )); + assert!(!is_relevant_addon_path( + Path::new("/tmp/l4a-loading/readme.txt"), + &watched + )); + + let event = Event { + kind: EventKind::Modify(ModifyKind::Name(notify::event::RenameMode::Both)), + paths: vec![PathBuf::from("/tmp/l4a-loading/test.vpk")], + attrs: Default::default(), + }; + assert!(super::is_relevant_event(&event, &watched)); + } + + #[test] + fn suppression_window_marks_internal_refreshes_temporarily() { + let controller = AddonWatcherController::default(); + assert!(!controller.coordinator.is_suppressed()); + controller.suppress_internal_refresh(); + assert!(controller.coordinator.is_suppressed()); + thread::sleep(Duration::from_millis(INTERNAL_REFRESH_SUPPRESS_MS + 100)); + assert!(!controller.coordinator.is_suppressed()); + } +} diff --git a/src/hooks/useAddonManager.test.tsx b/src/hooks/useAddonManager.test.tsx index 826fbd2..350eeb7 100644 --- a/src/hooks/useAddonManager.test.tsx +++ b/src/hooks/useAddonManager.test.tsx @@ -1,15 +1,24 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useAddonManager } from './useAddonManager'; -import { Addon, Group, Settings } from '../types/addon'; +import { Addon, DatabasePayload, Group, Settings } from '../types/addon'; // Mock Tauri invoke function const mockInvoke = vi.fn(); +const mockListen = vi.fn(); +const eventListeners = new Map void>(); + vi.mock('@tauri-apps/api/core', () => ({ invoke: (cmd: string, args?: Record) => mockInvoke(cmd, args), })); vi.mock('@tauri-apps/api/event', () => ({ - listen: vi.fn().mockResolvedValue(() => {}), + listen: (eventName: string, callback: (event: { payload: any }) => void) => { + mockListen(eventName, callback); + eventListeners.set(eventName, callback); + return Promise.resolve(() => { + eventListeners.delete(eventName); + }); + }, })); describe('useAddonManager', () => { @@ -46,6 +55,7 @@ describe('useAddonManager', () => { beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); + eventListeners.clear(); // Default mock response for get_addons mockInvoke.mockImplementation((cmd) => { @@ -56,6 +66,14 @@ describe('useAddonManager', () => { settings: mockSettings, }); } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } return Promise.resolve({}); }); }); @@ -276,4 +294,89 @@ describe('useAddonManager', () => { // isSubmitting should reset to false expect(result.current.isSubmitting).toBe(false); }); + + test('should apply watcher database updates and show toast for external changes', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const nextPayload: DatabasePayload = { + addons: { + ...mockAddons, + 'addon3.vpk': { + id: 'addon3.vpk', + vpkName: 'addon3.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 123, + filesCount: 1, + addonInfo: { addontitle: 'Addon Three' }, + }, + }, + knownUninstalledAddons: {}, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }; + + await act(async () => { + eventListeners.get('addons-db-updated')?.({ + payload: { data: nextPayload, external: true }, + }); + }); + + expect(result.current.addons['addon3.vpk']).toBeDefined(); + expect(result.current.toasts.some((toast) => toast.message.includes('自动刷新'))).toBe(true); + }); + + test('should apply internal watcher refreshes without success toast', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const nextPayload: DatabasePayload = { + addons: { + 'addon2.vpk': mockAddons['addon2.vpk'], + }, + knownUninstalledAddons: {}, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }; + + act(() => { + result.current.handleSelectToggle('addon1.vpk'); + }); + + await act(async () => { + eventListeners.get('addons-db-updated')?.({ + payload: { data: nextPayload, external: false }, + }); + }); + + expect(result.current.addons['addon1.vpk']).toBeUndefined(); + expect(result.current.knownUninstalledAddons['addon1.vpk']).toBeUndefined(); + expect(result.current.selectedIds).toEqual([]); + expect(result.current.toasts.some((toast) => toast.message.includes('自动刷新'))).toBe(false); + }); + + test('should surface watcher errors through toast notifications', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + eventListeners.get('addons-watch-error')?.({ + payload: { message: 'watcher failed' }, + }); + }); + + expect(result.current.toasts.some((toast) => toast.message.includes('watcher failed'))).toBe(true); + }); }); diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index 894b010..7e1bda8 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -10,6 +10,15 @@ export type RenderedItem = | { type: 'group'; id: string; name: string; addons: Addon[]; groupObj: Group } | { type: 'addon'; id: string; data: Addon }; +interface AddonsDbUpdatedEvent { + data: DatabasePayload; + external: boolean; +} + +interface AddonsWatchErrorEvent { + message: string; +} + export function useAddonManager() { const { t } = useTranslation(); const [addons, setAddons] = useState>({}); @@ -53,11 +62,43 @@ export function useAddonManager() { }, 4000); }, []); - // Listen to download progress + // Update local states from DatabasePayload + const updateLocalState = useCallback((data: DatabasePayload) => { + const nextAddons = data.addons || {}; + const nextKnownUninstalledAddons = data.knownUninstalledAddons || {}; + const nextGroups = data.groups || []; + const nextMasterCollections = data.masterCollections || []; + + setAddons(nextAddons); + setGroups(nextGroups); + setKnownUninstalledAddons(nextKnownUninstalledAddons); + setMasterCollections(nextMasterCollections); + if (data.settings) { + setSettings(data.settings); + } + + setSelectedIds((prev) => prev.filter((id) => nextAddons[id] || nextKnownUninstalledAddons[id])); + setSelectedGroupIds((prev) => prev.filter((id) => nextGroups.some((group) => group.id === id))); + setDetailModal((prev) => { + if (!prev.open || !prev.addon) { + return prev; + } + + const nextAddon = nextAddons[prev.addon.id] || nextKnownUninstalledAddons[prev.addon.id]; + return nextAddon + ? { ...prev, addon: nextAddon } + : { open: false, addon: null }; + }); + }, []); + + // Listen to backend events useEffect(() => { - let unlisten: (() => void) | undefined; - const setupListener = async () => { - unlisten = await listen<{ workshopId: string; percent: number }>('download-progress', (event) => { + let unlistenDownload: (() => void) | undefined; + let unlistenDbUpdate: (() => void) | undefined; + let unlistenWatchError: (() => void) | undefined; + + const setupListeners = async () => { + unlistenDownload = await listen<{ workshopId: string; percent: number }>('download-progress', (event) => { setDownloadProgress((prev) => ({ ...prev, [event.payload.workshopId]: event.payload.percent, @@ -72,23 +113,27 @@ export function useAddonManager() { }, 3000); } }); + + unlistenDbUpdate = await listen('addons-db-updated', (event) => { + updateLocalState(event.payload.data); + if (event.payload.external) { + addToast(t('toasts.autoRefreshSuccess'), 'success'); + } + }); + + unlistenWatchError = await listen('addons-watch-error', (event) => { + addToast(t('toasts.autoRefreshFailed', { err: event.payload.message }), 'error'); + }); }; - setupListener(); + + setupListeners(); + return () => { - if (unlisten) unlisten(); + if (unlistenDownload) unlistenDownload(); + if (unlistenDbUpdate) unlistenDbUpdate(); + if (unlistenWatchError) unlistenWatchError(); }; - }, []); - - // Update local states from DatabasePayload - const updateLocalState = useCallback((data: DatabasePayload) => { - setAddons(data.addons || {}); - setGroups(data.groups || []); - setKnownUninstalledAddons(data.knownUninstalledAddons || {}); - setMasterCollections(data.masterCollections || []); - if (data.settings) { - setSettings(data.settings); - } - }, []); + }, [addToast, t, updateLocalState]); const { backgroundTasks, diff --git a/src/i18n.tsx b/src/i18n.tsx index c4c033a..0c7699b 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -283,6 +283,8 @@ const zh = { toasts: { autoMoveFailed: "自动移动附件失败: {{err}}", dbRefreshSuccess: "数据库刷新成功", + autoRefreshSuccess: "检测到外部组件变更,已自动刷新", + autoRefreshFailed: "自动刷新失败: {{err}}", dataLoadFailed: "数据加载失败: {{err}}", addonEnabled: "附加组件已启用", addonDisabled: "附加组件已禁用", @@ -700,6 +702,8 @@ const en = { toasts: { autoMoveFailed: "Auto move failed: {{err}}", dbRefreshSuccess: "Database refreshed successfully", + autoRefreshSuccess: "External addon changes detected and refreshed automatically", + autoRefreshFailed: "Automatic refresh failed: {{err}}", dataLoadFailed: "Data load failed: {{err}}", addonEnabled: "Addon enabled", addonDisabled: "Addon disabled", @@ -851,4 +855,3 @@ i18n }); export default i18n; - From 693649f5f91d0adb5e82d55c1d23a0ea8a7ba01a Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 19:12:53 +0800 Subject: [PATCH 30/81] fix(lint): fix frontend oxlint and backend cargo clippy issues --- src-tauri/src/commands/handlers.rs | 17 ++++++++--------- src-tauri/src/steam/service.rs | 7 ++++--- src-tauri/src/vpk/core.rs | 7 +++---- src/components/EditGroupModal.tsx | 2 +- src/components/workshop/DetailDrawer.tsx | 17 +++++++++-------- src/services/workshopClient.ts | 1 - 6 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 3c29d67..0ce8a3a 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -916,8 +916,8 @@ pub async fn scan_addons_internal( .cloned() }); - let has_capitalized_keys = cached.as_ref().map_or(false, |addon| { - addon.addon_info.as_object().map_or(false, |obj| { + let has_capitalized_keys = cached.as_ref().is_some_and(|addon| { + addon.addon_info.as_object().is_some_and(|obj| { obj.keys().any(|k| k.chars().any(|c| c.is_uppercase())) }) }); @@ -1789,11 +1789,9 @@ pub async fn rename_addon( let old_img = state.cache_dir.join(format!("{:x}_image.jpg", old_hash)); let new_img = state.cache_dir.join(format!("{:x}_image.jpg", new_hash)); - if old_img.exists() { - if let Ok(_) = fs::rename(&old_img, &new_img) { - if let Some(addon_ref) = db.addons.get_mut(&id) { - addon_ref.image_path = Some(format!("/cache/{:x}_image.jpg", new_hash)); - } + if old_img.exists() && fs::rename(&old_img, &new_img).is_ok() { + if let Some(addon_ref) = db.addons.get_mut(&id) { + addon_ref.image_path = Some(format!("/cache/{:x}_image.jpg", new_hash)); } } } @@ -1954,6 +1952,7 @@ pub async fn rename_addons( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn group_action( action: String, name: Option, @@ -2854,7 +2853,7 @@ pub async fn download_addon( app_handle: AppHandle, ) -> Result { let details_list = - fetch_steam_details_hybrid(&state.workshop_service, &[workshop_id.clone()]).await?; + fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)).await?; if details_list.is_empty() { return Err("Failed to retrieve details for workshop item".to_string()); } @@ -3087,7 +3086,7 @@ pub async fn add_known_addon( let mut db = state.db.lock().await; let details_list = - fetch_steam_details_hybrid(&state.workshop_service, &[workshop_id.clone()]).await?; + fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)).await?; if details_list.is_empty() { return Err("Failed to retrieve details for workshop item".to_string()); } diff --git a/src-tauri/src/steam/service.rs b/src-tauri/src/steam/service.rs index 4fbb803..e2aa245 100644 --- a/src-tauri/src/steam/service.rs +++ b/src-tauri/src/steam/service.rs @@ -191,9 +191,10 @@ pub async fn fetch_steam_details_web(workshop_ids: &[String]) -> Result Result, String> { let url = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"; - let mut params = Vec::new(); - params.push(("collectioncount".to_string(), "1".to_string())); - params.push(("publishedfileids[0]".to_string(), collection_id.to_string())); + let params = vec![ + ("collectioncount".to_string(), "1".to_string()), + ("publishedfileids[0]".to_string(), collection_id.to_string()), + ]; let client = reqwest::Client::new(); let res = client diff --git a/src-tauri/src/vpk/core.rs b/src-tauri/src/vpk/core.rs index f3646e4..6a320e2 100644 --- a/src-tauri/src/vpk/core.rs +++ b/src-tauri/src/vpk/core.rs @@ -84,13 +84,12 @@ pub fn parse_vpk>( let _terminator = u16::from_le_bytes(entry_slice[16..18].try_into().unwrap()); let mut preload_data = Vec::new(); - if preload_bytes > 0 { - if offset + preload_bytes as usize <= tree_buf.len() { + if preload_bytes > 0 + && offset + preload_bytes as usize <= tree_buf.len() { preload_data .extend_from_slice(&tree_buf[offset..offset + preload_bytes as usize]); offset += preload_bytes as usize; } - } let norm_path = path_str.trim(); let full_path = if norm_path.is_empty() { @@ -184,7 +183,7 @@ pub fn parse_key_values(text: &str) -> serde_json::Value { let mut s = String::new(); s.push(chars.next().unwrap()); let mut escaped = false; - while let Some(nc) = chars.next() { + for nc in chars.by_ref() { s.push(nc); if escaped { escaped = false; diff --git a/src/components/EditGroupModal.tsx b/src/components/EditGroupModal.tsx index 1b26dd9..4519974 100644 --- a/src/components/EditGroupModal.tsx +++ b/src/components/EditGroupModal.tsx @@ -40,7 +40,7 @@ export const EditGroupModal: React.FC = ({ setCollectionId(currentCollectionId); setShowConfirmSave(false); } - }, [open, groupId]); + }, [open, groupId, currentName, currentTags, currentCollectionId]); if (!open) return null; diff --git a/src/components/workshop/DetailDrawer.tsx b/src/components/workshop/DetailDrawer.tsx index 38b0706..9c6026d 100644 --- a/src/components/workshop/DetailDrawer.tsx +++ b/src/components/workshop/DetailDrawer.tsx @@ -137,7 +137,14 @@ export const DetailDrawer: React.FC = ({ /> ) : null}
- ); + setAlertInfo({ open: false, title: '', message: '' })} + /> + +); }; // ── Item detail sub-component ────────────────────────────────────────────────── @@ -586,12 +593,6 @@ const CollectionDetail: React.FC = ({
- setAlertInfo({ open: false, title: '', message: '' })} - /> - +

); }; diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index 648081e..018e9ab 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -1,7 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { HomepageSection, - TagCategory, WorkshopCapabilities, WorkshopItem, } from '../components/workshop/types'; From 6412fa96054b091b2e0a71f027ad1dcfa61704ec Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 20:05:09 +0800 Subject: [PATCH 31/81] feat(tasks): add task center for background downloads and workshop sync --- src-tauri/src/commands/handlers.rs | 395 ++++++++------- src-tauri/src/commands/mod.rs | 13 +- src-tauri/src/lib.rs | 4 + src/App.tsx | 38 +- src/components/AddonCard.tsx | 4 +- src/components/DetailModal.tsx | 4 +- src/components/KnownUninstalledView.tsx | 22 +- src/components/TaskCenterButton.tsx | 78 +++ src/components/TaskCenterModal.tsx | 451 ++++++++++++++++++ src/components/TopBar.test.tsx | 16 +- src/components/TopBar.tsx | 24 +- src/components/WorkshopBrowser.tsx | 12 + src/components/workshop/DetailDrawer.tsx | 13 +- .../workshop/WorkshopDetailModal.tsx | 4 +- src/components/workshop/types.ts | 5 +- src/hooks/useAddonManager.test.tsx | 88 ++++ src/hooks/useAddonManager.ts | 52 +- src/hooks/useBackgroundTasks.ts | 167 +++++-- src/i18n.tsx | 58 +++ src/index.css | 27 ++ src/types/addon.ts | 2 + 21 files changed, 1216 insertions(+), 261 deletions(-) create mode 100644 src/components/TaskCenterButton.tsx create mode 100644 src/components/TaskCenterModal.tsx diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 0ce8a3a..e9196f0 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -18,6 +18,8 @@ use super::types::{ Settings, SettingsStore, WorkshopSeenItem, }; +const DOWNLOAD_CANCELLED_ERR: &str = "Download cancelled"; + fn resolve_cache_file(cache_dir: &Path, image_path: &str) -> Result { let filename = image_path.strip_prefix("/cache/").unwrap_or(image_path); let rel_path = Path::new(filename); @@ -108,6 +110,32 @@ fn ensure_background_workshop_fetch_allowed( )) } +fn request_download_cancellation(state: &crate::AppState, workshop_id: &str) -> Result<(), String> { + let mut cancelled = state + .cancelled_downloads + .lock() + .map_err(|_| "Failed to acquire cancelled downloads lock".to_string())?; + cancelled.insert(workshop_id.to_string()); + Ok(()) +} + +fn clear_download_cancellation(state: &crate::AppState, workshop_id: &str) -> Result<(), String> { + let mut cancelled = state + .cancelled_downloads + .lock() + .map_err(|_| "Failed to acquire cancelled downloads lock".to_string())?; + cancelled.remove(workshop_id); + Ok(()) +} + +fn is_download_cancelled(state: &crate::AppState, workshop_id: &str) -> Result { + let cancelled = state + .cancelled_downloads + .lock() + .map_err(|_| "Failed to acquire cancelled downloads lock".to_string())?; + Ok(cancelled.contains(workshop_id)) +} + fn is_dummy_vpk(path: &Path) -> bool { if let Ok((files, mut file)) = crate::vpk::parse_vpk(path) { let addoninfo_key = files.keys().find(|k| { @@ -734,6 +762,7 @@ async fn fetch_collection_children_hybrid( } async fn attempt_bridge_download( + state: &crate::AppState, workshop_service: &WorkshopService, workshop_id: &str, workshop_dir: &Path, @@ -760,6 +789,10 @@ async fn attempt_bridge_download( let expected_disabled_path = workshop_dir.join(format!("{}.vpk.disabled", workshop_id)); for _ in 0..40 { + if is_download_cancelled(state, workshop_id)? { + return Err(DOWNLOAD_CANCELLED_ERR.to_string()); + } + let status: BridgeDownloadStatus = match workshop_service.bridge_download_status(workshop_id) { Ok(status) => status, @@ -2852,188 +2885,212 @@ pub async fn download_addon( state: State<'_, crate::AppState>, app_handle: AppHandle, ) -> Result { - let details_list = - fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)).await?; - if details_list.is_empty() { - return Err("Failed to retrieve details for workshop item".to_string()); - } - let details = details_list[0].clone(); + clear_download_cancellation(&state, &workshop_id)?; - let title = details - .get("title") - .and_then(|t| t.as_str()) - .unwrap_or("Workshop Item"); + let result = async { + let details_list = + fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)).await?; + if details_list.is_empty() { + return Err("Failed to retrieve details for workshop item".to_string()); + } + let details = details_list[0].clone(); - let workshop_dir = { - let db = state.db.lock().await; - PathBuf::from(&db.settings.workshop_dir) - }; - if !workshop_dir.exists() { - fs::create_dir_all(&workshop_dir).map_err(|e| e.to_string())?; - } - let dest_filename = format!("{}.vpk", workshop_id); - let dest_path = workshop_dir.join(&dest_filename); - if dest_path.exists() { - return Err(format!( - "Addon file already exists: {}", - dest_path.display() - )); - } - let disabled_dest_path = dest_path.with_extension("vpk.disabled"); - if disabled_dest_path.exists() { - return Err(format!( - "Disabled addon file already exists: {}", - disabled_dest_path.display() - )); - } - let tmp_path = workshop_dir.join(format!("{}.download", dest_filename)); - if tmp_path.exists() { - fs::remove_file(&tmp_path) - .map_err(|e| format!("Failed to remove stale download file: {}", e))?; - } + let title = details + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("Workshop Item"); - if let Ok(downloaded_via_bridge) = attempt_bridge_download( - &state.workshop_service, - &workshop_id, - &workshop_dir, - &app_handle, - ) - .await - { - if downloaded_via_bridge { - crate::watcher::suppress_internal_refresh(&state); - let mut db = state.db.lock().await; - scan_addons_internal( - &mut db, - &state.settings_path, - &state.groups_path, - &state.known_addons_path, - &state.cache_dir, - &state.workshop_service, - ) - .await?; - return Ok(database_with_workshop_cache( - &db, - &state.workshop_cache_path, + let workshop_dir = { + let db = state.db.lock().await; + PathBuf::from(&db.settings.workshop_dir) + }; + if !workshop_dir.exists() { + fs::create_dir_all(&workshop_dir).map_err(|e| e.to_string())?; + } + let dest_filename = format!("{}.vpk", workshop_id); + let dest_path = workshop_dir.join(&dest_filename); + if dest_path.exists() { + return Err(format!( + "Addon file already exists: {}", + dest_path.display() )); } - } + let disabled_dest_path = dest_path.with_extension("vpk.disabled"); + if disabled_dest_path.exists() { + return Err(format!( + "Disabled addon file already exists: {}", + disabled_dest_path.display() + )); + } + let tmp_path = workshop_dir.join(format!("{}.download", dest_filename)); + if tmp_path.exists() { + fs::remove_file(&tmp_path) + .map_err(|e| format!("Failed to remove stale download file: {}", e))?; + } - let file_url = details - .get("file_url") - .and_then(|u| u.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| { - "Workshop item has no download URL (SDK did not install it and Web API has no direct file URL)".to_string() - })?; - - println!("Downloading: {} (URL: {})", title, file_url); - let client = reqwest::Client::new(); - let mut response = client - .get(&file_url) - .send() + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + ) .await - .map_err(|e| format!("Download request failed: {}", e))?; - if !response.status().is_success() { - return Err(format!( - "Download server responded with status {}", - response.status() - )); - } + { + Ok(true) => { + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} + } + + let file_url = details + .get("file_url") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| { + "Workshop item has no download URL (SDK did not install it and Web API has no direct file URL)".to_string() + })?; + + println!("Downloading: {} (URL: {})", title, file_url); + let client = reqwest::Client::new(); + let mut response = client + .get(&file_url) + .send() + .await + .map_err(|e| format!("Download request failed: {}", e))?; + if !response.status().is_success() { + return Err(format!( + "Download server responded with status {}", + response.status() + )); + } - let total_size = response.content_length().unwrap_or(0); - let mut file = - fs::File::create(&tmp_path).map_err(|e| format!("Failed to create local file: {}", e))?; - let mut downloaded = 0; + let total_size = response.content_length().unwrap_or(0); + let mut file = fs::File::create(&tmp_path) + .map_err(|e| format!("Failed to create local file: {}", e))?; + let mut downloaded = 0; - #[derive(Serialize, Clone)] - struct DownloadProgress { - #[serde(rename = "workshopId")] - workshop_id: String, - percent: u32, - downloaded: u64, - total: u64, - source: String, - phase: String, - } + #[derive(Serialize, Clone)] + struct DownloadProgress { + #[serde(rename = "workshopId")] + workshop_id: String, + percent: u32, + downloaded: u64, + total: u64, + source: String, + phase: String, + } - while let Some(chunk) = response - .chunk() - .await - .map_err(|e| format!("Download chunk failed: {}", e))? - { - use std::io::Write; - file.write_all(&chunk) - .map_err(|e| format!("Failed to write chunk: {}", e))?; - downloaded += chunk.len() as u64; + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("Download chunk failed: {}", e))? + { + if is_download_cancelled(&state, &workshop_id)? { + drop(file); + fs::remove_file(&tmp_path).ok(); + return Err(DOWNLOAD_CANCELLED_ERR.to_string()); + } - let percent = if total_size > 0 { - (downloaded as f64 / total_size as f64 * 100.0) as u32 - } else { - 0 - }; + use std::io::Write; + file.write_all(&chunk) + .map_err(|e| format!("Failed to write chunk: {}", e))?; + downloaded += chunk.len() as u64; - let _ = app_handle.emit( - "download-progress", - DownloadProgress { - workshop_id: workshop_id.clone(), - percent, - downloaded, - total: total_size, - source: "web-fallback".to_string(), - phase: "fallback-download".to_string(), - }, - ); - } - file.flush() - .map_err(|e| format!("Failed to flush downloaded file: {}", e))?; - drop(file); - crate::watcher::suppress_internal_refresh(&state); - fs::rename(&tmp_path, &dest_path) - .map_err(|e| format!("Failed to finalize downloaded file: {}", e))?; + let percent = if total_size > 0 { + (downloaded as f64 / total_size as f64 * 100.0) as u32 + } else { + 0 + }; - let mut known_addons = load_known_addons(&state.known_addons_path); + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.clone(), + percent, + downloaded, + total: total_size, + source: "web-fallback".to_string(), + phase: "fallback-download".to_string(), + }, + ); + } + file.flush() + .map_err(|e| format!("Failed to flush downloaded file: {}", e))?; + drop(file); - let has_image = details.get("preview_url").is_some(); - let image_path = details - .get("preview_url") - .and_then(|u| u.as_str()) - .map(|s| s.to_string()); + if is_download_cancelled(&state, &workshop_id)? { + fs::remove_file(&tmp_path).ok(); + return Err(DOWNLOAD_CANCELLED_ERR.to_string()); + } - let id = workshop_id.clone(); - known_addons.insert( - id.clone(), - KnownAddonEntry { - id: id.clone(), - vpk_name: dest_filename.clone(), - workshop_id: Some(workshop_id.clone()), - addon_info: serde_json::Value::Null, - has_image, - image_path, - steam_details: Some(details.clone()), - }, - ); + crate::watcher::suppress_internal_refresh(&state); + fs::rename(&tmp_path, &dest_path) + .map_err(|e| format!("Failed to finalize downloaded file: {}", e))?; - fs::write( - &state.known_addons_path, - serde_json::to_string_pretty(&known_addons).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; + let mut known_addons = load_known_addons(&state.known_addons_path); - let mut db = state.db.lock().await; - scan_addons_internal( - &mut db, - &state.settings_path, - &state.groups_path, - &state.known_addons_path, - &state.cache_dir, - &state.workshop_service, - ) - .await?; - Ok(database_with_workshop_cache( - &db, - &state.workshop_cache_path, - )) + let has_image = details.get("preview_url").is_some(); + let image_path = details + .get("preview_url") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + + let id = workshop_id.clone(); + known_addons.insert( + id.clone(), + KnownAddonEntry { + id: id.clone(), + vpk_name: dest_filename.clone(), + workshop_id: Some(workshop_id.clone()), + addon_info: serde_json::Value::Null, + has_image, + image_path, + steam_details: Some(details.clone()), + }, + ); + + fs::write( + &state.known_addons_path, + serde_json::to_string_pretty(&known_addons).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + )) + } + .await; + + let _ = clear_download_cancellation(&state, &workshop_id); + result } #[tauri::command] @@ -3404,6 +3461,14 @@ pub async fn save_background_task_snapshot( .map_err(|e| format!("Failed to write background tasks: {}", e)) } +#[tauri::command] +pub async fn cancel_download( + workshop_id: String, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + request_download_cancellation(&state, &workshop_id) +} + #[tauri::command] pub async fn append_workshop_crawl_log( record: serde_json::Value, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 95a0c55..e1d508a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,12 +2,13 @@ pub mod handlers; mod types; pub use handlers::{ - add_known_addon, append_workshop_crawl_log, delete_addons, download_addon, fetch_collection, - fetch_workshop_html, get_addons, get_background_tasks, get_cache_image, get_settings, - get_workshop_cache, get_workshop_capabilities, group_action, move_addons, open_url, - open_workshop, persist_workshop_page_details, query_workshop_collection, query_workshop_home, - query_workshop_item, query_workshop_items, record_workshop_items_seen, rename_addon, - rename_addons, save_background_task_snapshot, save_settings, steam_sync, toggle_addons, + add_known_addon, append_workshop_crawl_log, cancel_download, delete_addons, download_addon, + fetch_collection, fetch_workshop_html, get_addons, get_background_tasks, get_cache_image, + get_settings, get_workshop_cache, get_workshop_capabilities, group_action, move_addons, + open_url, open_workshop, persist_workshop_page_details, query_workshop_collection, + query_workshop_home, query_workshop_item, query_workshop_items, record_workshop_items_seen, + rename_addon, rename_addons, save_background_task_snapshot, save_settings, steam_sync, + toggle_addons, }; pub use handlers::{load_db, save_db_internal}; pub use types::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da8312f..a4a82f2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::collections::HashSet; use std::sync::Mutex as StdMutex; use tauri::async_runtime::Mutex; use tauri::Manager; @@ -19,6 +20,7 @@ pub struct AppState { pub workshop_service: steam::WorkshopService, pub db: Mutex, pub addon_watcher: StdMutex, + pub cancelled_downloads: StdMutex>, } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -94,6 +96,7 @@ pub fn run() { workshop_service: steam::WorkshopService::new(&bridge_base_dir), db: Mutex::new(db), addon_watcher: StdMutex::new(watcher::AddonWatcherController::default()), + cancelled_downloads: StdMutex::new(HashSet::new()), }); if let Err(err) = @@ -139,6 +142,7 @@ pub fn run() { commands::handlers::persist_workshop_page_details, commands::handlers::get_background_tasks, commands::handlers::save_background_task_snapshot, + commands::handlers::cancel_download, commands::handlers::append_workshop_crawl_log, ]) .run(tauri::generate_context!()) diff --git a/src/App.tsx b/src/App.tsx index d9c40ee..2060efa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { MasterCollectionModal } from './components/MasterCollectionModal'; import { MasterCollectionHeader } from './components/MasterCollectionHeader'; import { AddToGroupModal } from './components/AddToGroupModal'; import { PromptModal } from './components/PromptModal'; +import { TaskCenterModal } from './components/TaskCenterModal'; import { useState } from 'react'; function App() { @@ -108,6 +109,10 @@ function App() { deleteAddons, handleBatchDownload, recordSeenItems, + backgroundTasks, + cancelTask, + retryTask, + clearFinishedTasks, // Master Collection handlers handleCreateMasterCollection, @@ -129,6 +134,7 @@ function App() { } = useAddonManager(); const [masterCollectionModal, setMasterCollectionModal] = useState(false); + const [taskCenterOpen, setTaskCenterOpen] = useState(false); const [addToGroupModal, setAddToGroupModal] = useState(false); const [addToMcModal, setAddToMcModal] = useState(false); const [renameCollectionModal, setRenameCollectionModal] = useState<{ open: boolean; collectionId: string; currentName: string }>({ @@ -208,6 +214,9 @@ function App() { searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} onDetailClick={(addon) => setDetailModal({ open: true, addon })} + backgroundTasks={backgroundTasks} + syncingSteam={syncingSteam} + onOpenTaskCenter={() => setTaskCenterOpen(true)} /> ) : currentFilterTab === 'workshop-browser' ? ( setTaskCenterOpen(true)} /> ) : ( <> @@ -234,7 +246,6 @@ function App() { sortBy={sortBy} onSortByChange={setSortBy} syncingSteam={syncingSteam} - onSyncSteam={syncSteamDetails} categoriesList={categoriesList} isSelectMode={isSelectMode} onToggleSelectMode={() => { @@ -244,6 +255,8 @@ function App() { setIsSelectMode(true); } }} + backgroundTasks={backgroundTasks} + onOpenTaskCenter={() => setTaskCenterOpen(true)} /> {/* Content body */} @@ -384,12 +397,12 @@ function App() { downloadProgress={downloadProgress} isSubmitting={isSubmitting} onItemNavigate={(workshopId) => { - const vpkKey = `${workshopId}.vpk`; - if (addons[vpkKey]) { - setDetailModal({ open: true, addon: addons[vpkKey] }); - } else if (knownUninstalledAddons[vpkKey]) { - setDetailModal({ open: true, addon: knownUninstalledAddons[vpkKey] }); + const foundAddon = Object.values(addons).find((a) => a.workshopId === workshopId) || + Object.values(knownUninstalledAddons).find((a) => a.workshopId === workshopId); + if (foundAddon) { + setDetailModal({ open: true, addon: foundAddon }); } else { + const vpkKey = `${workshopId}.vpk`; setDetailModal({ open: true, addon: { @@ -564,6 +577,19 @@ function App() { }} /> + {/* Task Center Modal */} + setTaskCenterOpen(false)} + backgroundTasks={backgroundTasks} + downloadProgress={downloadProgress} + syncingSteam={syncingSteam} + onSyncSteam={syncSteamDetails} + onCancelTask={cancelTask} + onRetryTask={retryTask} + onClearFinishedTasks={clearFinishedTasks} + /> + {/* Floating Batch Action Bar */} {isSelectMode && currentFilterTab !== 'known-uninstalled' && ( void; onRenameClick: (addon: Addon) => void; onDetailClick: (addon: Addon) => void; - onDownload?: (workshopId: string) => void; + onDownload?: (workshopId: string, title?: string, imagePath?: string) => void; isSelectMode: boolean; isSelected: boolean; onSelectToggle: (id: string) => void; @@ -171,7 +171,7 @@ export const AddonCard: React.FC = ({ className="btn btn-primary btn-icon-only" onClick={(e) => { e.stopPropagation(); - onDownload(addon.workshopId!); + onDownload(addon.workshopId!, title, addon.imagePath); }} disabled={isSubmitting || isDownloading} title={isDownloading ? `${downloadPercent}%` : t('workshop.detail.download')} diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx index b84c79c..687de08 100644 --- a/src/components/DetailModal.tsx +++ b/src/components/DetailModal.tsx @@ -20,7 +20,7 @@ interface DetailModalProps { knownUninstalledAddons: Record; onItemNavigate: (workshopId: string) => void; onDatabaseUpdate?: (data: DatabasePayload) => void; - onDownload?: (workshopId: string) => void; + onDownload?: (workshopId: string, title?: string, imagePath?: string) => void; downloadProgress?: Record; isSubmitting?: boolean; } @@ -310,7 +310,7 @@ export const DetailModal: React.FC = ({ addon.workshopId && onDownload && (

+
{/* Search */} @@ -199,7 +211,11 @@ export const KnownUninstalledView: React.FC = ({ {addon.workshopId ? ( + ); +}; diff --git a/src/components/TaskCenterModal.tsx b/src/components/TaskCenterModal.tsx new file mode 100644 index 0000000..7d67393 --- /dev/null +++ b/src/components/TaskCenterModal.tsx @@ -0,0 +1,451 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, RotateCw, Trash2, AlertTriangle, Loader2, RefreshCw, ClipboardCheck } from 'lucide-react'; +import { BackgroundTask } from '../types/addon'; +import { CacheImage } from './CacheImage'; + +interface TaskCenterModalProps { + open: boolean; + onCancel: () => void; + backgroundTasks: BackgroundTask[]; + downloadProgress: Record; + syncingSteam: boolean; + onSyncSteam: () => void; + onCancelTask: (id: string) => void; + onRetryTask: (id: string) => void; + onClearFinishedTasks: () => void; +} + +export const TaskCenterModal: React.FC = ({ + open, + onCancel, + backgroundTasks, + downloadProgress, + syncingSteam, + onSyncSteam, + onCancelTask, + onRetryTask, + onClearFinishedTasks, +}) => { + const { t } = useTranslation(); + + if (!open) return null; + + // Filter tasks into categories + const activeTasks = backgroundTasks.filter( + (t) => t.status === 'running' || t.status === 'queued' + ); + + const failedTasks = backgroundTasks.filter((t) => t.status === 'failed'); + + const finishedTasks = backgroundTasks.filter( + (t) => t.status === 'completed' || t.status === 'cancelled' + ); + + const hasFinishedTasks = finishedTasks.length > 0 || failedTasks.length > 0; + + return ( +
+
e.stopPropagation()} + style={{ + width: '600px', + maxWidth: '90%', + maxHeight: '85vh', + display: 'flex', + flexDirection: 'column', + padding: '24px', + borderRadius: '24px', + backgroundColor: 'var(--md-sys-surface-container)', + border: '1px solid var(--md-sys-color-outline-variant)', + boxShadow: '0 12px 36px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + }} + > + {/* Header */} +
+
+

+ {t('taskCenter.title', '任务中心')} +

+

+ {t('taskCenter.subtitle', '管理和查看组件的下载与同步任务')} +

+
+
+ {hasFinishedTasks && ( + + )} + + +
+
+ + {/* Scrollable list area */} +
+ {/* Steam Sync State (Separate active item if running) */} + {syncingSteam && ( +
+ +
+
+ {t('taskCenter.syncing', '正在同步创意工坊信息...')} +
+
+ {t('taskCenter.syncDesc', '正在获取和更新所有组件的最新 Steam 创意工坊元数据')} +
+
+
+ )} + + {/* Active Tasks (Downloads, crawls) */} + {activeTasks.length > 0 && ( +
+

+ {t('taskCenter.activeTasks', '正在运行的队列')} ({activeTasks.length}) +

+
+ {activeTasks.map((task) => { + const isDownload = task.kind === 'download'; + const workshopId = task.targetIds[0]; + // If download, read current progress percent + const percent = isDownload ? (downloadProgress[workshopId] ?? task.progress ?? 0) : 0; + const isRunning = task.status === 'running'; + + return ( +
+ {/* Image Preview */} + {isDownload ? ( + + ) : ( +
+ +
+ )} + + {/* Content */} +
+
+ {task.title || `Workshop Item ${workshopId}`} +
+
+ + {isDownload + ? isRunning + ? `${t('taskCenter.downloading', '正在下载')}...` + : t('taskCenter.inQueue', '等待下载') + : isRunning + ? `${t('taskCenter.syncing', '正在同步')}...` + : t('taskCenter.inQueue', '等待同步')} + + {isDownload && isRunning && {percent}%} +
+ + {/* Progress Bar */} + {isDownload && isRunning && ( +
+
+
+ )} +
+ + {/* Actions */} + +
+ ); + })} +
+
+ )} + + {/* Failed / Warning Tasks */} + {failedTasks.length > 0 && ( +
+

+ {t('taskCenter.errors', '警告通知 / 失败的任务')} ({failedTasks.length}) +

+
+ {failedTasks.map((task) => { + const workshopId = task.targetIds[0]; + return ( +
+
+ +
+
+ {task.title || `Workshop Item ${workshopId}`} +
+
+ {task.kind === 'download' ? t('taskCenter.downloadFailed', '下载失败') : t('taskCenter.syncFailed', '同步元数据失败')} +
+
+ + {/* Actions */} +
+ + +
+
+ + {/* Error details */} + {task.error && ( +
+ {t('taskCenter.errorDetails', { err: task.error })} +
+ )} +
+ ); + })} +
+
+ )} + + {/* Empty State */} + {!syncingSteam && activeTasks.length === 0 && failedTasks.length === 0 && ( +
+ +

+ {t('taskCenter.noTasks', '暂无任何任务')} +

+

+ {t('taskCenter.noTasksDesc', '下载组件或同步创意工坊时将会在此处显示')} +

+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; diff --git a/src/components/TopBar.test.tsx b/src/components/TopBar.test.tsx index a9dd422..ca1c9ae 100644 --- a/src/components/TopBar.test.tsx +++ b/src/components/TopBar.test.tsx @@ -10,7 +10,7 @@ describe('TopBar', () => { const onCategoryChange = vi.fn(); const onSortByChange = vi.fn(); const onToggleSelectMode = vi.fn(); - const onSyncSteam = vi.fn(); + const onOpenTaskCenter = vi.fn(); render( { sortBy="size" onSortByChange={onSortByChange} syncingSteam={false} - onSyncSteam={onSyncSteam} categoriesList={categoriesList} isSelectMode={false} onToggleSelectMode={onToggleSelectMode} + backgroundTasks={[]} + onOpenTaskCenter={onOpenTaskCenter} /> ); @@ -62,11 +63,11 @@ describe('TopBar', () => { fireEvent.click(screen.getByText('批量管理')); expect(onToggleSelectMode).toHaveBeenCalledTimes(1); - // Sync button + // Task Center button const buttons = screen.getAllByRole('button'); - const syncButton = buttons[buttons.length - 1]; // last button is Sync Steam icon-only - fireEvent.click(syncButton); - expect(onSyncSteam).toHaveBeenCalledTimes(1); + const taskCenterBtn = buttons[buttons.length - 1]; // last button is Task Center + fireEvent.click(taskCenterBtn); + expect(onOpenTaskCenter).toHaveBeenCalledTimes(1); }); test('renders batch management correctly in active select mode', () => { @@ -81,10 +82,11 @@ describe('TopBar', () => { sortBy="title" onSortByChange={vi.fn()} syncingSteam={false} - onSyncSteam={vi.fn()} categoriesList={categoriesList} isSelectMode={true} onToggleSelectMode={onToggleSelectMode} + backgroundTasks={[]} + onOpenTaskCenter={vi.fn()} /> ); diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 0db4a4c..c133cf8 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,7 +1,9 @@ import React from 'react'; -import { Search, RefreshCw, CheckSquare, X } from 'lucide-react'; +import { Search, CheckSquare, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { CustomSelect } from './CustomSelect'; +import { TaskCenterButton } from './TaskCenterButton'; +import { BackgroundTask } from '../types/addon'; interface TopBarProps { searchQuery: string; @@ -11,10 +13,11 @@ interface TopBarProps { sortBy: string; onSortByChange: (sortBy: string) => void; syncingSteam: boolean; - onSyncSteam: () => void; categoriesList: string[]; isSelectMode: boolean; onToggleSelectMode: () => void; + backgroundTasks: BackgroundTask[]; + onOpenTaskCenter: () => void; } export const TopBar: React.FC = ({ @@ -25,10 +28,11 @@ export const TopBar: React.FC = ({ sortBy, onSortByChange, syncingSteam, - onSyncSteam, categoriesList, isSelectMode, onToggleSelectMode, + backgroundTasks, + onOpenTaskCenter, }) => { const { t } = useTranslation(); @@ -96,15 +100,11 @@ export const TopBar: React.FC = ({ {isSelectMode ? t('topbar.exitBatch') : t('topbar.batchManage')} - +
); diff --git a/src/components/WorkshopBrowser.tsx b/src/components/WorkshopBrowser.tsx index ee7e6ea..a57bda6 100644 --- a/src/components/WorkshopBrowser.tsx +++ b/src/components/WorkshopBrowser.tsx @@ -23,6 +23,7 @@ import { ItemCard } from './workshop/ItemCard'; import { TagBrowserModal } from './workshop/TagBrowserModal'; import { SectionCarousel } from './workshop/SectionCarousel'; import { WorkshopDetailModal } from './workshop/WorkshopDetailModal'; +import { TaskCenterButton } from './TaskCenterButton'; import { AlertModal } from './AlertModal'; import { CustomSelect } from './CustomSelect'; import { @@ -57,6 +58,9 @@ export const WorkshopBrowser: React.FC = ({ onDatabaseUpdate, isSubmitting, groups, + backgroundTasks, + syncingSteam, + onOpenTaskCenter, }) => { const { t } = useTranslation(); const scrollIdleTimerRef = useRef(null); @@ -475,6 +479,14 @@ export const WorkshopBrowser: React.FC = ({ {activeTagName || t('workshop.tags.browse')} )} + +
0 ? '0' : 'auto' }}> + +
{/* Content area */} diff --git a/src/components/workshop/DetailDrawer.tsx b/src/components/workshop/DetailDrawer.tsx index 9c6026d..0fe7cac 100644 --- a/src/components/workshop/DetailDrawer.tsx +++ b/src/components/workshop/DetailDrawer.tsx @@ -15,7 +15,7 @@ interface DetailDrawerProps { downloadProgress: Record; isSubmitting: boolean; onClose: () => void; - onDownload: (workshopId: string) => void; + onDownload: (workshopId: string, title?: string, imagePath?: string) => void; onOpenLink: (url: string) => void; onCreatorClick: (id: string, name: string) => void; onTagClick: (tagId: string, tagName: string) => void; @@ -59,12 +59,13 @@ export const DetailDrawer: React.FC = ({ const handleDownloadAllCollection = () => { if (!selectedCollection) return; - const itemIds = selectedCollection.items.map((item) => item.publishedfileid); - itemIds.forEach((id) => onDownload(id)); + selectedCollection.items.forEach((item) => { + onDownload(item.publishedfileid, item.title, item.preview_url); + }); setAlertInfo({ open: true, title: t('common.success') || '成功', - message: t('workshop.detail.downloadAllSuccess', { count: itemIds.length }), + message: t('workshop.detail.downloadAllSuccess', { count: selectedCollection.items.length }), }); }; @@ -153,7 +154,7 @@ interface ItemDetailProps { item: any; downloadProgress: Record; isSubmitting: boolean; - onDownload: (workshopId: string) => void; + onDownload: (workshopId: string, title?: string, imagePath?: string) => void; onOpenLink: (url: string) => void; onCreatorClick: (id: string, name: string) => void; onTagClick: (tagId: string, tagName: string) => void; @@ -287,7 +288,7 @@ const ItemDetail: React.FC = ({
- @@ -389,7 +389,7 @@ export const WorkshopDetailModal: React.FC = ({
+
)} + + {activeTab === 'sdk' && ( +
+

{t('settings.sdkTitle')}

+

+ {t('settings.sdkDesc')} +

+
+
+
+
+ +
+ {t('settings.suppressSdkWarningDesc')} +
+
+ +
+
+ +
+ +
+
+
+ )}
); diff --git a/src/components/TaskCenterModal.tsx b/src/components/TaskCenterModal.tsx index 7d67393..46efaf6 100644 --- a/src/components/TaskCenterModal.tsx +++ b/src/components/TaskCenterModal.tsx @@ -313,6 +313,12 @@ export const TaskCenterModal: React.FC = ({
{failedTasks.map((task) => { const workshopId = task.targetIds[0]; + const isWarningTask = task.kind === 'warning'; + const taskTitle = task.title || ( + isWarningTask + ? t('taskCenter.steamworksUnavailableTitle', 'Steamworks SDK 不可用') + : `Workshop Item ${workshopId}` + ); return (
= ({ whiteSpace: 'nowrap', }} > - {task.title || `Workshop Item ${workshopId}`} + {taskTitle}
- {task.kind === 'download' ? t('taskCenter.downloadFailed', '下载失败') : t('taskCenter.syncFailed', '同步元数据失败')} + {isWarningTask + ? t('taskCenter.warningTitle', '功能受限警告') + : task.kind === 'download' + ? t('taskCenter.downloadFailed', '下载失败') + : t('taskCenter.syncFailed', '同步元数据失败')}
{/* Actions */} -
- - -
+ {!isWarningTask && ( +
+ + +
+ )}
{/* Error details */} diff --git a/src/hooks/useAddonManager.test.tsx b/src/hooks/useAddonManager.test.tsx index 1e92dbb..2bee6c1 100644 --- a/src/hooks/useAddonManager.test.tsx +++ b/src/hooks/useAddonManager.test.tsx @@ -50,6 +50,7 @@ describe('useAddonManager', () => { workshopDir: '/path/to/workshop', loadingDir: '/path/to/loading', enableDummyBypass: false, + suppressSdkUnavailableWarning: false, }; beforeEach(() => { @@ -74,6 +75,18 @@ describe('useAddonManager', () => { if (cmd === 'get_background_tasks') { return Promise.resolve([]); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } return Promise.resolve({}); }); }); @@ -169,6 +182,18 @@ describe('useAddonManager', () => { if (cmd === 'get_addons') { return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } if (cmd === 'move_addons') { return Promise.resolve({}); } @@ -206,6 +231,18 @@ describe('useAddonManager', () => { if (cmd === 'get_addons') { return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } if (cmd === 'group_action') { if (args?.action === 'create') { expect(args.name).toBe('New Group'); @@ -258,11 +295,36 @@ describe('useAddonManager', () => { resolvePromise = resolve; }); - mockInvoke.mockImplementation((cmd) => { + mockInvoke.mockImplementation((cmd, args) => { if (cmd === 'get_addons') { return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } if (cmd === 'save_settings') { + expect(args).toEqual({ + loadingDir: '/new/loading/dir', + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + }); return tauriPromise; } return Promise.resolve({}); @@ -279,7 +341,7 @@ describe('useAddonManager', () => { // Call saveSettings let savePromise: Promise | null = null; act(() => { - savePromise = result.current.saveSettings('/new/loading/dir', false); + savePromise = result.current.saveSettings('/new/loading/dir', false, false); }); // isSubmitting should be true immediately after invoking @@ -403,6 +465,101 @@ describe('useAddonManager', () => { expect(result.current.downloadProgress['12345']).toBe(50); }); + test('should add a frontend warning task when Steamworks SDK is unavailable', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: mockSettings, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: false, + bridgeLoaded: false, + bridgeInitialized: false, + provider: 'web-fallback', + lastError: 'Steam bridge DLL not found', + canQueryItems: false, + canQueryHome: false, + canDownload: false, + canEnumerateInstalled: false, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.backgroundTasks).toHaveLength(1); + }); + + expect(result.current.backgroundTasks[0]).toMatchObject({ + id: 'steamworks-sdk-unavailable', + kind: 'warning', + status: 'failed', + source: 'frontend-warning', + title: 'Steamworks SDK 不可用', + }); + expect(result.current.backgroundTasks[0].error).toContain('Steam bridge DLL not found'); + }); + + test('should suppress the frontend SDK warning task when the setting is enabled', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: { + ...mockSettings, + suppressSdkUnavailableWarning: true, + }, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: false, + bridgeLoaded: false, + bridgeInitialized: false, + provider: 'web-fallback', + lastError: 'Steam bridge DLL not found', + canQueryItems: false, + canQueryHome: false, + canDownload: false, + canEnumerateInstalled: false, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.backgroundTasks).toHaveLength(0); + }); + test('should request backend download cancellation when cancelling a running download task', async () => { let resolveDownload: ((value: DatabasePayload) => void) | undefined; const downloadPromise = new Promise((resolve) => { @@ -417,6 +574,18 @@ describe('useAddonManager', () => { settings: mockSettings, }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } if (cmd === 'get_workshop_cache') { return Promise.resolve({ '12345': { lastPageFetchedAt: new Date().toISOString() }, diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index 18c0f45..86f6f95 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -5,6 +5,8 @@ import { Addon, Group, Settings, Toast, DatabasePayload, MasterCollection } from import { getAddonAuthor, getAddonCategories, getSuggestedVpkName, getAddonInfoValue } from '../utils/addonHelpers'; import { useTranslation } from 'react-i18next'; import { useBackgroundTasks } from './useBackgroundTasks'; +import type { BackgroundTask } from '../types/addon'; +import type { WorkshopCapabilities } from '../components/workshop/types'; export type RenderedItem = | { type: 'group'; id: string; name: string; addons: Addon[]; groupObj: Group } @@ -30,7 +32,12 @@ export function useAddonManager() { const [knownUninstalledAddons, setKnownUninstalledAddons] = useState>({}); const [groups, setGroups] = useState([]); const [masterCollections, setMasterCollections] = useState([]); - const [settings, setSettings] = useState({ workshopDir: '', loadingDir: '', enableDummyBypass: false }); + const [settings, setSettings] = useState({ + workshopDir: '', + loadingDir: '', + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + }); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [currentFilterTab, setCurrentFilterTab] = useState('all'); // all, workshop, loading, disabled, groups, known-uninstalled, workshop-browser, master-collection @@ -46,6 +53,9 @@ export function useAddonManager() { const [isSelectMode, setIsSelectMode] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [downloadProgress, setDownloadProgress] = useState>({}); + const [workshopCapabilities, setWorkshopCapabilities] = useState(null); + const [workshopCapabilitiesCheckedAt, setWorkshopCapabilitiesCheckedAt] = useState(null); + const [workshopCapabilitiesError, setWorkshopCapabilitiesError] = useState(null); const clearDownloadProgress = useCallback((workshopId: string) => { setDownloadProgress((prev) => { @@ -151,7 +161,7 @@ export function useAddonManager() { }, [addToast, clearDownloadProgress, t, updateLocalState]); const { - backgroundTasks, + backgroundTasks: managedBackgroundTasks, enqueueDownloads, enqueueWorkshopCrawl, recordSeenItems, @@ -229,6 +239,56 @@ export function useAddonManager() { fetchData(); }, [fetchData]); + useEffect(() => { + let cancelled = false; + + void invoke('get_workshop_capabilities') + .then((capabilities) => { + if (cancelled) return; + setWorkshopCapabilities(capabilities); + setWorkshopCapabilitiesError(null); + setWorkshopCapabilitiesCheckedAt(new Date().toISOString()); + }) + .catch((err) => { + if (cancelled) return; + setWorkshopCapabilities(null); + setWorkshopCapabilitiesError(String(err)); + setWorkshopCapabilitiesCheckedAt(new Date().toISOString()); + }); + + return () => { + cancelled = true; + }; + }, []); + + const steamworksUnavailableTask: BackgroundTask | null = (() => { + if (loading || settings.suppressSdkUnavailableWarning) { + return null; + } + const bridgeUnavailable = workshopCapabilities !== null && !workshopCapabilities.bridgeAvailable; + if (!bridgeUnavailable && !workshopCapabilitiesError) { + return null; + } + + const detail = workshopCapabilities?.lastError || workshopCapabilitiesError || ''; + + return { + id: 'steamworks-sdk-unavailable', + kind: 'warning', + status: 'failed', + source: 'frontend-warning', + targetIds: [], + progress: 100, + createdAt: workshopCapabilitiesCheckedAt || new Date().toISOString(), + title: t('taskCenter.steamworksUnavailableTitle'), + error: detail || undefined, + }; + })(); + + const backgroundTasks = steamworksUnavailableTask + ? [steamworksUnavailableTask, ...managedBackgroundTasks] + : managedBackgroundTasks; + const handleOpenLink = async (url: string) => { if (!url) return; if (url.startsWith('http://') || url.startsWith('https://')) { @@ -358,13 +418,18 @@ export function useAddonManager() { }; // Save Settings - const saveSettings = async (loadingDir: string, enableDummyBypass: boolean) => { + const saveSettings = async ( + loadingDir: string, + enableDummyBypass: boolean, + suppressSdkUnavailableWarning: boolean, + ) => { if (isSubmitting) return; setIsSubmitting(true); try { const data: DatabasePayload = await invoke('save_settings', { loadingDir, - enableDummyBypass + enableDummyBypass, + suppressSdkUnavailableWarning, }); updateLocalState(data); setSettingsModal({ open: false, loadingDir: '' }); diff --git a/src/i18n.tsx b/src/i18n.tsx index 59faf7a..dffd38e 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -90,7 +90,9 @@ const zh = { history: "已完成的历史任务", statusCompleted: "已完成", statusCancelled: "已取消", - sync: "元数据同步" + sync: "元数据同步", + warningTitle: "功能受限警告", + steamworksUnavailableTitle: "Steamworks SDK 不可用" }, addonCard: { deselect: "取消选择", @@ -252,6 +254,7 @@ const zh = { settings: { pathSettings: "路径设置", experimental: "实验性", + sdk: "SDK", about: "关于软件", language: "语言设置", languageTitle: "界面语言设置", @@ -265,6 +268,10 @@ const zh = { savingAndScanning: "正在保存并扫描...", experimentalTitle: "实验性功能", experimentalDesc: "此处的选项处于实验性阶段。启用可能会对文件目录结构做出调整,请谨慎开启。", + sdkTitle: "SDK 设置", + sdkDesc: "管理与 Steamworks SDK 可用性相关的前端行为。", + suppressSdkWarningTitle: "抑制 SDK 不可用警告", + suppressSdkWarningDesc: "开启后,当 Steamworks SDK 不可用时,不再向任务中心添加前端警告。", dummyBypassTitle: "创意工坊检测绕过", dummyBypassDesc: [ "开启后,将 addon 从 workshop 移出时,将在 workshop 目录下自动生成一个 dummy addon(仅保留附件图片、原始 AppID 及版本号,将标题标记为原名 (L4A Dummy) 且说明改为由 L4A 生成),以试图绕过 L4D2 创意工坊订阅同步检测。", @@ -538,7 +545,9 @@ const en = { history: "Completed Task History", statusCompleted: "Completed", statusCancelled: "Cancelled", - sync: "Metadata Sync" + sync: "Metadata Sync", + warningTitle: "Limited Functionality Warning", + steamworksUnavailableTitle: "Steamworks SDK Unavailable" }, addonCard: { deselect: "Deselect", @@ -700,6 +709,7 @@ const en = { settings: { pathSettings: "Path Settings", experimental: "Experimental", + sdk: "SDK", about: "About", language: "Language", languageTitle: "Interface Language Settings", @@ -713,6 +723,10 @@ const en = { savingAndScanning: "Saving and Scanning...", experimentalTitle: "Experimental Features", experimentalDesc: "The options here are in experimental phase. Enabling them may adjust the directory structure. Please use with caution.", + sdkTitle: "SDK Settings", + sdkDesc: "Manage frontend behavior related to Steamworks SDK availability.", + suppressSdkWarningTitle: "Suppress SDK Unavailable Warning", + suppressSdkWarningDesc: "When enabled, the frontend will not add a warning to the task center if Steamworks SDK is unavailable.", dummyBypassTitle: "Workshop Detection Bypass", dummyBypassDesc: [ "When enabled, moving an addon out of the workshop folder will automatically generate a dummy addon in the workshop directory (keeping only thumbnail, original AppID, version, prefixing name with (L4A Dummy) and marking generated by L4A) to attempt to bypass Steam Workshop synchronization checks.", diff --git a/src/types/addon.ts b/src/types/addon.ts index d2b8a9a..d47fe64 100644 --- a/src/types/addon.ts +++ b/src/types/addon.ts @@ -108,7 +108,7 @@ export interface Addon { export interface BackgroundTask { id: string; - kind: 'download' | 'workshop-crawl'; + kind: 'download' | 'workshop-crawl' | 'warning'; status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; source?: string; targetIds: string[]; @@ -144,6 +144,7 @@ export interface Settings { workshopDir: string; loadingDir: string; enableDummyBypass: boolean; + suppressSdkUnavailableWarning: boolean; } export interface DatabasePayload { From c43cba0f43eaee63ce3d87781d3efe8831cf9828 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 22:01:22 +0800 Subject: [PATCH 34/81] Fix Steamworks workshop lifecycle and sorting --- src-tauri/src/lib.rs | 23 +++- src-tauri/src/steam/bridge.rs | 58 +++++++++- src-tauri/src/steam/service.rs | 6 + src/services/workshopClient.ts | 10 +- steam-bridge/src/lib.rs | 200 +++++++++++++++++++++++++++------ 5 files changed, 249 insertions(+), 48 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4a82f2..9402a00 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ -use std::path::{Path, PathBuf}; use std::collections::HashSet; +use std::path::{Path, PathBuf}; use std::sync::Mutex as StdMutex; use tauri::async_runtime::Mutex; use tauri::Manager; @@ -23,9 +23,15 @@ pub struct AppState { pub cancelled_downloads: StdMutex>, } +impl Drop for AppState { + fn drop(&mut self) { + self.workshop_service.shutdown(); + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let app = tauri::Builder::default() .setup(|app| { let mut host_runtime_dir = std::env::current_exe() .ok() @@ -145,8 +151,17 @@ pub fn run() { commands::handlers::cancel_download, commands::handlers::append_workshop_crawl_log, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|app_handle, event| { + if matches!( + event, + tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } + ) { + app_handle.state::().workshop_service.shutdown(); + } + }); } fn migrate_data(app_data_dir: Option<&Path>, legacy_runtime_dir: &Path, runtime_dir: &Path) { diff --git a/src-tauri/src/steam/bridge.rs b/src-tauri/src/steam/bridge.rs index 3d9c85a..ec0f0a8 100644 --- a/src-tauri/src/steam/bridge.rs +++ b/src-tauri/src/steam/bridge.rs @@ -5,7 +5,11 @@ use serde_json::Value; use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const IDLE_SHUTDOWN_DELAY: Duration = Duration::from_secs(60); type InitFn = unsafe extern "C" fn() -> *mut c_char; type RequestFn = unsafe extern "C" fn(*const c_char) -> *mut c_char; @@ -31,6 +35,8 @@ impl Drop for BridgeApi { #[derive(Clone)] pub struct WorkshopBridge { api: Arc, + invocation_gate: Arc>, + activity_token: Arc, capabilities: WorkshopCapabilities, } @@ -82,19 +88,24 @@ impl WorkshopBridge { let capabilities = WorkshopCapabilities { bridge_available: true, bridge_loaded: true, - bridge_initialized: true, + bridge_initialized: false, provider: "steam-sdk".to_string(), bridge_version: init_response.version.clone(), last_error: None, - current_user_steam_id: init_response.current_user_steam_id.clone(), - current_user_account_id: init_response.current_user_account_id.clone(), + current_user_steam_id: None, + current_user_account_id: None, can_query_items: true, can_query_home: true, can_download: true, can_enumerate_installed: true, }; - Ok(Self { api, capabilities }) + Ok(Self { + api, + invocation_gate: Arc::new(Mutex::new(())), + activity_token: Arc::new(AtomicU64::new(0)), + capabilities, + }) } pub fn capabilities(&self) -> WorkshopCapabilities { @@ -107,7 +118,15 @@ impl WorkshopBridge { payload: serde_json::to_value(payload).map_err(|e| e.to_string())?, }; let encoded = serde_json::to_string(&request).map_err(|e| e.to_string())?; - let result = invoke_raw_string(&self.api, Some(&encoded))?; + let result = { + let _guard = self + .invocation_gate + .lock() + .map_err(|_| "Steam bridge invocation mutex poisoned".to_string())?; + invoke_raw_string(&self.api, Some(&encoded)) + }?; + let token = self.activity_token.fetch_add(1, Ordering::SeqCst) + 1; + self.schedule_idle_shutdown(token); let response: BridgeResponse = serde_json::from_str(&result).map_err(|e| e.to_string())?; if response.ok { Ok(response.payload) @@ -117,6 +136,33 @@ impl WorkshopBridge { .unwrap_or_else(|| format!("Steam bridge call {} failed", method))) } } + + pub fn shutdown(&self) { + self.activity_token.fetch_add(1, Ordering::SeqCst); + if let Ok(_guard) = self.invocation_gate.lock() { + unsafe { + (self.api.shutdown)(); + } + } + } + + fn schedule_idle_shutdown(&self, token: u64) { + let api = Arc::clone(&self.api); + let invocation_gate = Arc::clone(&self.invocation_gate); + let activity_token = Arc::clone(&self.activity_token); + + std::thread::spawn(move || { + std::thread::sleep(IDLE_SHUTDOWN_DELAY); + let Ok(_guard) = invocation_gate.lock() else { + return; + }; + if activity_token.load(Ordering::SeqCst) == token { + unsafe { + (api.shutdown)(); + } + } + }); + } } unsafe fn load_library(path: &Path) -> Result { diff --git a/src-tauri/src/steam/service.rs b/src-tauri/src/steam/service.rs index e2aa245..ef9b871 100644 --- a/src-tauri/src/steam/service.rs +++ b/src-tauri/src/steam/service.rs @@ -51,6 +51,12 @@ impl WorkshopService { self.bridge.is_some() } + pub fn shutdown(&self) { + if let Some(bridge) = &self.bridge { + bridge.shutdown(); + } + } + pub fn bridge_fetch_details(&self, workshop_ids: &[String]) -> Result, String> { let bridge = self.bridge.as_ref().ok_or_else(|| { self.bridge_error diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index 018e9ab..3cd9235 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -323,11 +323,15 @@ export function mapSteamDetailToWorkshopItem(detail: any, source = 'steam-sdk'): ? detail.tags.map((tag: any) => String(tag.display_name || tag.tag || tag.name || '')).filter(Boolean) : [], subscriptions: detail.subscriptions ? parseInt(String(detail.subscriptions), 10) : undefined, - favorites: detail.favorites ? parseInt(String(detail.favorites), 10) : undefined, + favorites: (detail.favorites ?? detail.favorited) ? parseInt(String(detail.favorites ?? detail.favorited), 10) : undefined, lifetimeSubscriptions: detail.lifetime_subscriptions ? parseInt(String(detail.lifetime_subscriptions), 10) : undefined, - lifetimeFavorites: detail.lifetime_favorites ? parseInt(String(detail.lifetime_favorites), 10) : undefined, + lifetimeFavorites: (detail.lifetime_favorites ?? detail.lifetime_favorited) + ? parseInt(String(detail.lifetime_favorites ?? detail.lifetime_favorited), 10) + : undefined, views: detail.views ? parseInt(String(detail.views), 10) : undefined, - comments: detail.comments ? parseInt(String(detail.comments), 10) : undefined, + comments: (detail.comments ?? detail.num_comments_public) + ? parseInt(String(detail.comments ?? detail.num_comments_public), 10) + : undefined, totalVotes: detail.total_votes ? parseInt(String(detail.total_votes), 10) : undefined, timeCreated: detail.time_created ? parseInt(String(detail.time_created), 10) : undefined, timeUpdated: detail.time_updated ? parseInt(String(detail.time_updated), 10) : undefined, diff --git a/steam-bridge/src/lib.rs b/steam-bridge/src/lib.rs index 6e99cb9..074fa7f 100644 --- a/steam-bridge/src/lib.rs +++ b/steam-bridge/src/lib.rs @@ -6,7 +6,7 @@ use std::sync::{mpsc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use steamworks::{ AccountId, AppIDs, AppId, Client, FileType, ItemState, PublishedFileId, SteamId, UGCQueryType, - UGCType, UserList, UserListOrder, + UGCStatisticType, UGCType, UserList, UserListOrder, }; const APP_ID: u32 = 550; @@ -24,15 +24,6 @@ struct BridgeRuntime { struct InitResponse { ok: bool, version: String, - current_user_steam_id: String, - current_user_account_id: String, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct ErrorResponse { - ok: bool, - error: String, } #[derive(Debug, Serialize)] @@ -116,26 +107,10 @@ struct HomeSectionDef { #[no_mangle] pub extern "C" fn l4a_steam_bridge_init() -> *mut c_char { - match ensure_runtime() { - Ok(runtime) => { - let user = runtime - .as_ref() - .expect("runtime should be initialized") - .client - .user(); - let steam_id = user.steam_id(); - into_c_string(&InitResponse { - ok: true, - version: BRIDGE_VERSION.to_string(), - current_user_steam_id: steam_id.raw().to_string(), - current_user_account_id: steam_id.account_id().raw().to_string(), - }) - } - Err(err) => into_c_string(&ErrorResponse { - ok: false, - error: err, - }), - } + into_c_string(&InitResponse { + ok: true, + version: BRIDGE_VERSION.to_string(), + }) } #[no_mangle] @@ -173,6 +148,8 @@ pub extern "C" fn l4a_steam_bridge_shutdown() { runtime.take(); } } + std::env::remove_var("SteamAppId"); + std::env::remove_var("SteamGameId"); } fn handle_request(request: *const c_char) -> Result { @@ -330,7 +307,8 @@ fn query_home(runtime: &mut BridgeRuntime) -> Result { fn query_items(runtime: &mut BridgeRuntime, payload: QueryItemsPayload) -> Result { let query = build_query_handle(runtime, &payload, None)?; - let items = run_query(runtime, query)?; + let mut items = run_query(runtime, query)?; + normalize_query_items(&mut items, payload.sort.as_deref()); Ok(json!({ "source": "steam-sdk", "items": items, @@ -532,7 +510,12 @@ fn run_query_with_children( if let Some(children) = results.get_children(index) { child_ids.extend(children.into_iter().map(|child| child.0.to_string())); } - items.push(query_result_to_json(&item, results.preview_url(index))); + items.push(query_result_to_json( + &results, + index, + &item, + results.preview_url(index), + )); } } Ok((items, child_ids)) @@ -558,9 +541,21 @@ fn run_query_with_children( } } -fn query_result_to_json(item: &steamworks::QueryResult, preview_url: Option) -> Value { +fn query_result_to_json( + results: &steamworks::QueryResults<'_>, + index: u32, + item: &steamworks::QueryResult, + preview_url: Option, +) -> Value { let owner_id = item.owner.raw().to_string(); let account_id = item.owner.account_id().raw().to_string(); + let subscriptions = results.statistic(index, UGCStatisticType::Subscriptions); + let favorites = results.statistic(index, UGCStatisticType::Favorites); + let lifetime_subscriptions = results.statistic(index, UGCStatisticType::UniqueSubscriptions); + let lifetime_favorites = results.statistic(index, UGCStatisticType::UniqueFavorites); + let views = results.statistic(index, UGCStatisticType::UniqueWebsiteViews); + let comments = results.statistic(index, UGCStatisticType::Comments); + let total_votes = item.num_upvotes as u64 + item.num_downvotes as u64; json!({ "publishedfileid": item.published_file_id.0.to_string(), @@ -577,8 +572,16 @@ fn query_result_to_json(item: &steamworks::QueryResult, preview_url: Option "collection", @@ -587,6 +590,68 @@ fn query_result_to_json(item: &steamworks::QueryResult, preview_url: Option) { + match sort.unwrap_or("trend") { + "mostrecent" => items.sort_by(|left, right| { + json_u64(right, "time_created") + .cmp(&json_u64(left, "time_created")) + .then_with(|| json_u64(right, "time_updated").cmp(&json_u64(left, "time_updated"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + "lastupdated" => items.sort_by(|left, right| { + json_u64(right, "time_updated") + .cmp(&json_u64(left, "time_updated")) + .then_with(|| json_u64(right, "time_created").cmp(&json_u64(left, "time_created"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + "totalprofiles" => items.sort_by(|left, right| { + json_u64(right, "lifetime_subscriptions") + .cmp(&json_u64(left, "lifetime_subscriptions")) + .then_with(|| { + json_u64(right, "subscriptions").cmp(&json_u64(left, "subscriptions")) + }) + .then_with(|| json_u64(right, "time_updated").cmp(&json_u64(left, "time_updated"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + "toprated" => items.sort_by(|left, right| { + json_f64(right, "score") + .total_cmp(&json_f64(left, "score")) + .then_with(|| json_u64(right, "total_votes").cmp(&json_u64(left, "total_votes"))) + .then_with(|| json_u64(right, "time_updated").cmp(&json_u64(left, "time_updated"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + _ => {} + } +} + +fn json_u64(value: &Value, key: &str) -> u64 { + value + .get(key) + .and_then(Value::as_u64) + .or_else(|| value.get(key).and_then(Value::as_str)?.parse::().ok()) + .unwrap_or(0) +} + +fn json_f64(value: &Value, key: &str) -> f64 { + value + .get(key) + .and_then(Value::as_f64) + .or_else(|| value.get(key).and_then(Value::as_str)?.parse::().ok()) + .unwrap_or(0.0) +} + +fn json_string<'a>(value: &'a Value, key: &str) -> &'a str { + value.get(key).and_then(Value::as_str).unwrap_or("") +} + fn parse_published_file_id(workshop_id: &str) -> Result { workshop_id .trim() @@ -661,3 +726,68 @@ fn into_c_string(value: &T) -> *mut c_char { .unwrap_or_else(|err| json!({ "ok": false, "error": err.to_string() }).to_string()); CString::new(encoded).unwrap().into_raw() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_mostrecent_sorts_newest_first() { + let mut items = vec![ + json!({ "publishedfileid": "1", "time_created": 10_u64, "time_updated": 50_u64 }), + json!({ "publishedfileid": "2", "time_created": 30_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "time_created": 20_u64, "time_updated": 60_u64 }), + ]; + + normalize_query_items(&mut items, Some("mostrecent")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "2"); + assert_eq!(json_string(&items[1], "publishedfileid"), "3"); + assert_eq!(json_string(&items[2], "publishedfileid"), "1"); + } + + #[test] + fn normalize_lastupdated_sorts_latest_update_first() { + let mut items = vec![ + json!({ "publishedfileid": "1", "time_created": 10_u64, "time_updated": 50_u64 }), + json!({ "publishedfileid": "2", "time_created": 30_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "time_created": 20_u64, "time_updated": 60_u64 }), + ]; + + normalize_query_items(&mut items, Some("lastupdated")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "3"); + assert_eq!(json_string(&items[1], "publishedfileid"), "1"); + assert_eq!(json_string(&items[2], "publishedfileid"), "2"); + } + + #[test] + fn normalize_totalprofiles_prefers_subscription_counts() { + let mut items = vec![ + json!({ "publishedfileid": "1", "subscriptions": 200_u64, "lifetime_subscriptions": 500_u64, "time_updated": 10_u64 }), + json!({ "publishedfileid": "2", "subscriptions": 100_u64, "lifetime_subscriptions": 900_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "subscriptions": 300_u64, "lifetime_subscriptions": 700_u64, "time_updated": 30_u64 }), + ]; + + normalize_query_items(&mut items, Some("totalprofiles")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "2"); + assert_eq!(json_string(&items[1], "publishedfileid"), "3"); + assert_eq!(json_string(&items[2], "publishedfileid"), "1"); + } + + #[test] + fn normalize_toprated_prefers_score_then_votes() { + let mut items = vec![ + json!({ "publishedfileid": "1", "score": 0.90_f64, "total_votes": 100_u64, "time_updated": 10_u64 }), + json!({ "publishedfileid": "2", "score": 0.95_f64, "total_votes": 80_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "score": 0.95_f64, "total_votes": 120_u64, "time_updated": 30_u64 }), + ]; + + normalize_query_items(&mut items, Some("toprated")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "3"); + assert_eq!(json_string(&items[1], "publishedfileid"), "2"); + assert_eq!(json_string(&items[2], "publishedfileid"), "1"); + } +} From 9b73c7be6bd5a42be25370cb10d650bbf0755a1e Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 22:03:35 +0800 Subject: [PATCH 35/81] Format remaining Rust source files --- src-tauri/build.rs | 20 ++++++++------------ src-tauri/src/commands/handlers.rs | 10 ++++++---- src-tauri/src/vpk/core.rs | 11 +++++------ 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 9e88a07..ff3f1c0 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -46,10 +46,7 @@ fn stage_windows_steam_bridge() -> Result<(), String> { .map_err(|e| format!("failed to invoke cargo for steam bridge: {e}"))?; if !status.success() { - return Err(format!( - "steam bridge build failed with status {}", - status - )); + return Err(format!("steam bridge build failed with status {}", status)); } let app_profile_dir = find_profile_dir()?; @@ -114,13 +111,8 @@ fn copy_if_newer(src: &Path, dst: &Path) -> Result<(), String> { }; if should_copy { - fs::copy(src, dst).map_err(|e| { - format!( - "failed to copy {} -> {}: {e}", - src.display(), - dst.display() - ) - })?; + fs::copy(src, dst) + .map_err(|e| format!("failed to copy {} -> {}: {e}", src.display(), dst.display()))?; } Ok(()) @@ -176,7 +168,11 @@ fn gnu_runtime_dlls(target: &str, bridge_dll: &Path) -> Vec { fn imported_dlls_from_objdump(target: &str, bridge_dll: &Path) -> Option> { let objdump = objdump_command_for_target(target)?; - let output = Command::new(objdump).arg("-p").arg(bridge_dll).output().ok()?; + let output = Command::new(objdump) + .arg("-p") + .arg(bridge_dll) + .output() + .ok()?; if !output.status.success() { return None; } diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 8589cc2..915b8a4 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -951,9 +951,10 @@ pub async fn scan_addons_internal( }); let has_capitalized_keys = cached.as_ref().is_some_and(|addon| { - addon.addon_info.as_object().is_some_and(|obj| { - obj.keys().any(|k| k.chars().any(|c| c.is_uppercase())) - }) + addon + .addon_info + .as_object() + .is_some_and(|obj| obj.keys().any(|k| k.chars().any(|c| c.is_uppercase()))) }); let needs_metadata = match &cached { @@ -3146,7 +3147,8 @@ pub async fn add_known_addon( let mut db = state.db.lock().await; let details_list = - fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)).await?; + fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)) + .await?; if details_list.is_empty() { return Err("Failed to retrieve details for workshop item".to_string()); } diff --git a/src-tauri/src/vpk/core.rs b/src-tauri/src/vpk/core.rs index 6a320e2..cb39365 100644 --- a/src-tauri/src/vpk/core.rs +++ b/src-tauri/src/vpk/core.rs @@ -84,12 +84,11 @@ pub fn parse_vpk>( let _terminator = u16::from_le_bytes(entry_slice[16..18].try_into().unwrap()); let mut preload_data = Vec::new(); - if preload_bytes > 0 - && offset + preload_bytes as usize <= tree_buf.len() { - preload_data - .extend_from_slice(&tree_buf[offset..offset + preload_bytes as usize]); - offset += preload_bytes as usize; - } + if preload_bytes > 0 && offset + preload_bytes as usize <= tree_buf.len() { + preload_data + .extend_from_slice(&tree_buf[offset..offset + preload_bytes as usize]); + offset += preload_bytes as usize; + } let norm_path = path_str.trim(); let full_path = if norm_path.is_empty() { From 1e198dea00bad98fd775e66eee257d71350f5338 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Wed, 8 Jul 2026 23:33:56 +0800 Subject: [PATCH 36/81] Add Steamworks SDK disable safeguards --- README.md | 7 ++ src-tauri/src/commands/handlers.rs | 101 ++++++++++++++++----- src-tauri/src/commands/types.rs | 2 + src-tauri/src/steam/bridge.rs | 27 ------ src/components/SettingsView.test.tsx | 41 +++++++++ src/components/SettingsView.tsx | 23 +++++ src/hooks/useAddonManager.test.tsx | 49 +++++++++- src/hooks/useAddonManager.ts | 10 +- src/i18n.tsx | 4 + src/services/workshopClient.test.ts | 131 +++++++++++++++++++++++++++ src/services/workshopClient.ts | 25 ++++- src/types/addon.ts | 1 + 12 files changed, 363 insertions(+), 58 deletions(-) create mode 100644 src/components/SettingsView.test.tsx create mode 100644 src/services/workshopClient.test.ts diff --git a/README.md b/README.md index 61dae12..c33dfe7 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ - 游戏内的附加内容会看到两份相同的附件 一份是创意工坊名 一份是附件内 `addoninfo.txt` 的名 尽管创意工坊侧的 Dummy 附件不添加任何游戏内容. +- Steamworks SDK 集成: + - 使用 Steamworks SDK 会让 Left 4 Addons 伪造成“求生之路”游戏, 以通过 Steam 访问其创意工坊内容. + - Left 4 Addons 使用 Steamworks SDK 时, 启动《求生之路》会导致游戏本体不被 VAC 信任. + - 该项是为了避免只有 Steam 才能访问创意工坊而设计的, 具体取决于你使用的加速器. + - 如果你的网络环境随时能访问网页版创意工坊, 建议前往设置禁用 Steamworks SDK. + - 如果 Left 4 Addons 在本次启动后已经调用过一次 Steamworks SDK, 那么即使随后在设置中禁用 Steamworks SDK, Steam 侧“正在游玩 L4D2”的状态也不保证会立即消失; 某些情况下需要完全关闭 Left 4 Addons 才会解除. + - 如果 Steam 侧“正在游玩 L4D2”的状态是由 Left 4 Addons 拉起的, 那么从 Steam 客户端停止它会导致 Left 4 Addons 应用程序一并退出. --- diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 915b8a4..d822b3c 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -286,6 +286,7 @@ pub fn load_db( loading_dir: default_loading, enable_dummy_bypass: false, suppress_sdk_unavailable_warning: false, + disable_steamworks_sdk: false, }, addons: HashMap::new(), groups: Vec::new(), @@ -732,11 +733,16 @@ async fn fetch_steam_details(workshop_ids: &[String]) -> Result Result, String> { if workshop_ids.is_empty() { return Ok(Vec::new()); } + if !allow_bridge { + return fetch_steam_details(workshop_ids).await; + } + match workshop_service.bridge_fetch_details(workshop_ids) { Ok(details) if !details.is_empty() => Ok(details), Ok(_) | Err(_) => fetch_steam_details(workshop_ids).await, @@ -746,7 +752,12 @@ async fn fetch_steam_details_hybrid( async fn fetch_collection_children_hybrid( workshop_service: &WorkshopService, collection_id: &str, + allow_bridge: bool, ) -> Result, String> { + if !allow_bridge { + return fetch_collection_children_web(collection_id).await; + } + if let Ok(payload) = workshop_service.bridge_query_collection(collection_id) { let mut ids = Vec::new(); for item in payload.items { @@ -768,8 +779,9 @@ async fn attempt_bridge_download( workshop_id: &str, workshop_dir: &Path, app_handle: &AppHandle, + allow_bridge: bool, ) -> Result { - if !workshop_service.has_bridge() { + if !allow_bridge || !workshop_service.has_bridge() { return Ok(false); } @@ -852,6 +864,7 @@ pub async fn scan_addons_internal( cache_dir: &Path, workshop_service: &WorkshopService, ) -> Result<(), String> { + let allow_bridge = !db.settings.disable_steamworks_sdk; let workshop_dir = Path::new(&db.settings.workshop_dir); let loading_dir = Path::new(&db.settings.loading_dir); @@ -1126,7 +1139,7 @@ pub async fn scan_addons_internal( let ids_array: Vec = new_workshop_ids.into_iter().collect(); println!("Syncing Steam details for {} items...", ids_array.len()); if let Ok(steam_details_list) = - fetch_steam_details_hybrid(workshop_service, &ids_array).await + fetch_steam_details_hybrid(workshop_service, &ids_array, allow_bridge).await { for details in steam_details_list { if let Some(w_id) = details.get("publishedfileid").and_then(|id| id.as_str()) { @@ -1199,7 +1212,7 @@ pub async fn scan_addons_internal( ids_to_sync.dedup(); println!("Syncing Steam details for {} items...", ids_to_sync.len()); if let Ok(steam_details_list) = - fetch_steam_details_hybrid(workshop_service, &ids_to_sync).await + fetch_steam_details_hybrid(workshop_service, &ids_to_sync, allow_bridge).await { for details in steam_details_list { if let Some(w_id) = details.get("publishedfileid").and_then(|id| id.as_str()) { @@ -1506,6 +1519,7 @@ pub async fn save_settings( loading_dir: String, enable_dummy_bypass: bool, suppress_sdk_unavailable_warning: bool, + disable_steamworks_sdk: bool, state: State<'_, crate::AppState>, app_handle: AppHandle, ) -> Result { @@ -1517,6 +1531,10 @@ pub async fn save_settings( db.settings.loading_dir = loading_dir; db.settings.enable_dummy_bypass = enable_dummy_bypass; db.settings.suppress_sdk_unavailable_warning = suppress_sdk_unavailable_warning; + db.settings.disable_steamworks_sdk = disable_steamworks_sdk; + if disable_steamworks_sdk { + state.workshop_service.shutdown(); + } save_db_internal( &state.settings_path, &state.groups_path, @@ -2057,8 +2075,14 @@ pub async fn group_action( } // Fetch Steam details for newly added workshop items if !new_workshop_ids.is_empty() { + let allow_bridge = !db.settings.disable_steamworks_sdk; if let Ok(steam_details_list) = - fetch_steam_details_hybrid(&state.workshop_service, &new_workshop_ids).await + fetch_steam_details_hybrid( + &state.workshop_service, + &new_workshop_ids, + allow_bridge, + ) + .await { for details in steam_details_list { let wid = details["publishedfileid"].as_str().unwrap_or(""); @@ -2610,7 +2634,7 @@ pub async fn query_workshop_collection( #[tauri::command] pub async fn steam_sync(state: State<'_, crate::AppState>) -> Result { - let ids = { + let (ids, allow_bridge) = { let mut db = state.db.lock().await; // First, scan addons to populate database with any new/removed files @@ -2645,11 +2669,12 @@ pub async fn steam_sync(state: State<'_, crate::AppState>) -> Result, ) -> Result { - if let Ok(payload) = state - .workshop_service - .bridge_query_collection(&collection_id) - { - return Ok(serde_json::json!({ - "collection": payload.collection, - "items": payload.items, - })); + let allow_bridge = { + let db = state.db.lock().await; + !db.settings.disable_steamworks_sdk + }; + + if allow_bridge { + if let Ok(payload) = state + .workshop_service + .bridge_query_collection(&collection_id) + { + return Ok(serde_json::json!({ + "collection": payload.collection, + "items": payload.items, + })); + } } - let child_ids = - fetch_collection_children_hybrid(&state.workshop_service, &collection_id).await?; + let child_ids = fetch_collection_children_hybrid( + &state.workshop_service, + &collection_id, + allow_bridge, + ) + .await?; let mut query_ids = vec![collection_id.clone()]; query_ids.extend(child_ids.clone()); - let details = fetch_steam_details_hybrid(&state.workshop_service, &query_ids).await?; + let details = + fetch_steam_details_hybrid(&state.workshop_service, &query_ids, allow_bridge).await?; let mut collection_details = serde_json::Value::Null; let mut items = Vec::new(); @@ -3145,10 +3191,15 @@ pub async fn add_known_addon( state: State<'_, crate::AppState>, ) -> Result { let mut db = state.db.lock().await; + let allow_bridge = !db.settings.disable_steamworks_sdk; let details_list = - fetch_steam_details_hybrid(&state.workshop_service, std::slice::from_ref(&workshop_id)) - .await?; + fetch_steam_details_hybrid( + &state.workshop_service, + std::slice::from_ref(&workshop_id), + allow_bridge, + ) + .await?; if details_list.is_empty() { return Err("Failed to retrieve details for workshop item".to_string()); } diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index 29711e3..41c5188 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -11,6 +11,8 @@ pub struct Settings { pub enable_dummy_bypass: bool, #[serde(rename = "suppressSdkUnavailableWarning", default)] pub suppress_sdk_unavailable_warning: bool, + #[serde(rename = "disableSteamworksSdk", default)] + pub disable_steamworks_sdk: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] diff --git a/src-tauri/src/steam/bridge.rs b/src-tauri/src/steam/bridge.rs index ec0f0a8..9927e59 100644 --- a/src-tauri/src/steam/bridge.rs +++ b/src-tauri/src/steam/bridge.rs @@ -5,11 +5,7 @@ use serde_json::Value; use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; -use std::time::Duration; - -const IDLE_SHUTDOWN_DELAY: Duration = Duration::from_secs(60); type InitFn = unsafe extern "C" fn() -> *mut c_char; type RequestFn = unsafe extern "C" fn(*const c_char) -> *mut c_char; @@ -36,7 +32,6 @@ impl Drop for BridgeApi { pub struct WorkshopBridge { api: Arc, invocation_gate: Arc>, - activity_token: Arc, capabilities: WorkshopCapabilities, } @@ -103,7 +98,6 @@ impl WorkshopBridge { Ok(Self { api, invocation_gate: Arc::new(Mutex::new(())), - activity_token: Arc::new(AtomicU64::new(0)), capabilities, }) } @@ -125,8 +119,6 @@ impl WorkshopBridge { .map_err(|_| "Steam bridge invocation mutex poisoned".to_string())?; invoke_raw_string(&self.api, Some(&encoded)) }?; - let token = self.activity_token.fetch_add(1, Ordering::SeqCst) + 1; - self.schedule_idle_shutdown(token); let response: BridgeResponse = serde_json::from_str(&result).map_err(|e| e.to_string())?; if response.ok { Ok(response.payload) @@ -138,31 +130,12 @@ impl WorkshopBridge { } pub fn shutdown(&self) { - self.activity_token.fetch_add(1, Ordering::SeqCst); if let Ok(_guard) = self.invocation_gate.lock() { unsafe { (self.api.shutdown)(); } } } - - fn schedule_idle_shutdown(&self, token: u64) { - let api = Arc::clone(&self.api); - let invocation_gate = Arc::clone(&self.invocation_gate); - let activity_token = Arc::clone(&self.activity_token); - - std::thread::spawn(move || { - std::thread::sleep(IDLE_SHUTDOWN_DELAY); - let Ok(_guard) = invocation_gate.lock() else { - return; - }; - if activity_token.load(Ordering::SeqCst) == token { - unsafe { - (api.shutdown)(); - } - } - }); - } } unsafe fn load_library(path: &Path) -> Result { diff --git a/src/components/SettingsView.test.tsx b/src/components/SettingsView.test.tsx new file mode 100644 index 0000000..6fbe7a1 --- /dev/null +++ b/src/components/SettingsView.test.tsx @@ -0,0 +1,41 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SettingsView } from './SettingsView'; +import type { Settings } from '../types/addon'; + +describe('SettingsView', () => { + const baseSettings: Settings = { + workshopDir: '/game/addons/workshop', + loadingDir: '/game/addons', + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + }; + + test('submits the Steamworks SDK disable toggle from the SDK tab', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByText('SDK')); + + const disableSdkRow = screen.getByText('禁用 Steamworks SDK').closest('div')?.parentElement; + const disableSdkCheckbox = disableSdkRow?.querySelector('input[type="checkbox"]'); + expect(disableSdkCheckbox).toBeTruthy(); + fireEvent.click(disableSdkCheckbox as HTMLInputElement); + fireEvent.click(screen.getByText('保存并重新扫描')); + + expect(onConfirm).toHaveBeenCalledWith( + '/game/addons', + false, + false, + true, + ); + }); +}); diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 286604e..c011167 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -11,6 +11,7 @@ interface SettingsViewProps { loadingDir: string, enableDummyBypass: boolean, suppressSdkUnavailableWarning: boolean, + disableSteamworksSdk: boolean, ) => Promise; } @@ -24,11 +25,13 @@ export const SettingsView: React.FC = ({ const [loadingDir, setLoadingDir] = useState(''); const [enableDummyBypass, setEnableDummyBypass] = useState(false); const [suppressSdkUnavailableWarning, setSuppressSdkUnavailableWarning] = useState(false); + const [disableSteamworksSdk, setDisableSteamworksSdk] = useState(false); useEffect(() => { setLoadingDir(settings.loadingDir || ''); setEnableDummyBypass(settings.enableDummyBypass || false); setSuppressSdkUnavailableWarning(settings.suppressSdkUnavailableWarning || false); + setDisableSteamworksSdk(settings.disableSteamworksSdk || false); }, [settings]); const handleSubmit = async (e: React.FormEvent) => { @@ -38,6 +41,7 @@ export const SettingsView: React.FC = ({ loadingDir.trim(), enableDummyBypass, suppressSdkUnavailableWarning, + disableSteamworksSdk, ); }; @@ -275,6 +279,25 @@ export const SettingsView: React.FC = ({

+
+
+ +
+ {t('settings.disableSteamworksSdkDesc')} +
+
+ +
); diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index 53f4dae..9acfbbd 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -1,13 +1,13 @@ import { useState, useEffect, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -import { Addon, Group, Settings, Toast, DatabasePayload, MasterCollection } from '../types/addon'; +import { Addon, Group, Settings, Toast, DatabasePayload, MasterCollection, WorkshopSourceSettings } from '../types/addon'; import { getAddonAuthor, getAddonCategories, getSuggestedVpkName, getAddonInfoValue } from '../utils/addonHelpers'; import { useTranslation } from 'react-i18next'; import { useBackgroundTasks } from './useBackgroundTasks'; import type { BackgroundTask } from '../types/addon'; import type { WorkshopCapabilities } from '../components/workshop/types'; -import { setSteamworksSdkDisabled } from '../services/workshopClient'; +import { setSteamworksSdkDisabled, setWorkshopSourceSettings } from '../services/workshopClient'; export type RenderedItem = | { type: 'group'; id: string; name: string; addons: Addon[]; groupObj: Group } @@ -39,6 +39,15 @@ export function useAddonManager() { enableDummyBypass: false, suppressSdkUnavailableWarning: false, disableSteamworksSdk: false, + workshopSourceSettings: { + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', + }, }); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -243,7 +252,8 @@ export function useAddonManager() { useEffect(() => { setSteamworksSdkDisabled(settings.disableSteamworksSdk); - }, [settings.disableSteamworksSdk]); + setWorkshopSourceSettings(settings.workshopSourceSettings); + }, [settings.disableSteamworksSdk, settings.workshopSourceSettings]); useEffect(() => { let cancelled = false; @@ -429,6 +439,7 @@ export function useAddonManager() { enableDummyBypass: boolean, suppressSdkUnavailableWarning: boolean, disableSteamworksSdk: boolean, + workshopSourceSettings?: WorkshopSourceSettings, ) => { if (isSubmitting) return; setIsSubmitting(true); @@ -438,6 +449,7 @@ export function useAddonManager() { enableDummyBypass, suppressSdkUnavailableWarning, disableSteamworksSdk, + workshopSourceSettings, }); updateLocalState(data); setSettingsModal({ open: false, loadingDir: '' }); diff --git a/src/hooks/useBackgroundTasks.ts b/src/hooks/useBackgroundTasks.ts index 4852eda..72d56b1 100644 --- a/src/hooks/useBackgroundTasks.ts +++ b/src/hooks/useBackgroundTasks.ts @@ -5,7 +5,6 @@ import { fetchWorkshopItem, fetchWorkshopPageDetails, persistWorkshopPageDetails const DOWNLOAD_CONCURRENCY = 3; const WORKSHOP_CRAWL_COOLDOWN_MS = 6000; -const WORKSHOP_CACHE_TTL_MS = 24 * 60 * 60 * 1000; interface UseBackgroundTasksArgs { enabled: boolean; @@ -52,7 +51,6 @@ export function useBackgroundTasks({ const activeDownloadsRef = useRef(0); const crawlRunningRef = useRef(false); const lastCrawlAtRef = useRef(0); - const startupQueuedRef = useRef(false); const processDownloadQueueRef = useRef<() => void>(() => {}); const processCrawlQueueRef = useRef<() => void>(() => {}); @@ -297,39 +295,6 @@ export function useBackgroundTasks({ } }, [updateLocalState]); - useEffect(() => { - if (!enabled || startupQueuedRef.current) return; - startupQueuedRef.current = true; - - void (async () => { - try { - const cache = await invoke>('get_workshop_cache'); - const candidateIds = new Set(); - - [...Object.values(addons), ...Object.values(knownUninstalledAddons)].forEach((addon) => { - if (addon.workshopId) candidateIds.add(addon.workshopId); - }); - - const staleIds = [...candidateIds].filter((id) => { - const lastFetched = cache?.[id]?.lastSeenAt || cache?.[id]?.lastPageFetchedAt; - if (!lastFetched) return true; - const timestamp = Date.parse(lastFetched); - return Number.isNaN(timestamp) || Date.now() - timestamp > WORKSHOP_CACHE_TTL_MS; - }).sort((left, right) => { - const leftValue = Date.parse(cache?.[left]?.lastSeenAt || cache?.[left]?.lastPageFetchedAt || ''); - const rightValue = Date.parse(cache?.[right]?.lastSeenAt || cache?.[right]?.lastPageFetchedAt || ''); - const leftTime = Number.isNaN(leftValue) ? Number.MIN_SAFE_INTEGER : leftValue; - const rightTime = Number.isNaN(rightValue) ? Number.MIN_SAFE_INTEGER : rightValue; - return leftTime - rightTime; - }); - - enqueueWorkshopCrawl(staleIds, 'startup-auto'); - } catch (err) { - console.error('Failed to enqueue startup workshop crawl:', err); - } - })(); - }, [addons, enabled, enqueueWorkshopCrawl, knownUninstalledAddons]); - const cancelTask = useCallback((id: string) => { const task = tasksRef.current.find((t) => t.id === id); if (!task) return; diff --git a/src/i18n.tsx b/src/i18n.tsx index 3c05110..f7a3043 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -255,6 +255,7 @@ const zh = { pathSettings: "路径设置", experimental: "实验性", sdk: "SDK", + sources: "数据来源", about: "关于软件", language: "语言设置", languageTitle: "界面语言设置", @@ -274,6 +275,22 @@ const zh = { disableSteamworksSdkDesc: "开启后,前端与后端将不再调用 Steamworks SDK 查询创意工坊内容,而是回退网页抓取流程。保存并重新扫描后对后续请求立即生效,无需重启应用程序;但 Steam 侧显示的“正在游玩 L4D2”状态未必会立刻消失,某些情况下需要完全关闭 Left 4 Addons 才会解除。", suppressSdkWarningTitle: "抑制 SDK 不可用警告", suppressSdkWarningDesc: "开启后,当 Steamworks SDK 不可用时,不再向任务中心添加前端警告。", + sourcesTitle: "创意工坊数据来源", + sourcesDesc: "配置创意工坊元数据的来源。基础附件管理始终使用本地文件和本地缓存,不依赖远程服务。", + sourcePresetLabel: "来源模式", + sourcePresetConservative: "保守自动:缓存优先,SDK 可用时不抓取网页", + sourcePresetSdkOnly: "仅 Steam/SDK:不使用 Steam Web API 或网页爬取", + sourcePresetOffline: "离线模式:只使用本地文件和缓存", + sourcePresetHybrid: "SDK + 网页混合爬取", + sourceHybridWarning: "同时使用 Steamworks SDK 和 steamcommunity 网页爬取可能更容易触发 Steam 社区的 IP 请求速率限制。仅在确实需要网页补全作者、标签或页面详情时开启。", + sourceSteamworksSdk: "Steamworks SDK", + sourceSteamWebApi: "Steam Web API", + sourceSteamCommunityHtml: "steamcommunity 网页爬取", + sourceOrderTitle: "高级来源顺序", + sourceMoveUp: "上移", + sourceMoveDown: "下移", + sourceAllowHybridTitle: "允许 SDK 与 steamcommunity 同时爬取", + sourceAllowHybridDesc: "开启后,即使 SDK 已接通,也允许继续抓取 steamcommunity 页面用于补全数据。", dummyBypassTitle: "创意工坊检测绕过", dummyBypassDesc: [ "开启后,将 addon 从 workshop 移出时,将在 workshop 目录下自动生成一个 dummy addon(仅保留附件图片、原始 AppID 及版本号,将标题标记为原名 (L4A Dummy) 且说明改为由 L4A 生成),以试图绕过 L4D2 创意工坊订阅同步检测。", @@ -711,6 +728,7 @@ const en = { pathSettings: "Path Settings", experimental: "Experimental", sdk: "SDK", + sources: "Data Sources", about: "About", language: "Language", languageTitle: "Interface Language Settings", @@ -730,6 +748,22 @@ const en = { disableSteamworksSdkDesc: "When enabled, both frontend and backend stop using Steamworks SDK workshop queries and fall back to web-based fetching. It takes effect for subsequent requests immediately after Save and Rescan, with no app restart required; however, Steam may not clear the “playing L4D2” state immediately, and in some cases you may need to fully close Left 4 Addons first.", suppressSdkWarningTitle: "Suppress SDK Unavailable Warning", suppressSdkWarningDesc: "When enabled, the frontend will not add a warning to the task center if Steamworks SDK is unavailable.", + sourcesTitle: "Workshop Data Sources", + sourcesDesc: "Configure workshop metadata sources. Basic addon management always uses local files and local cache and does not depend on remote services.", + sourcePresetLabel: "Source mode", + sourcePresetConservative: "Conservative Auto: cache first, no web crawling while SDK is available", + sourcePresetSdkOnly: "Steam/SDK Only: no Steam Web API or web crawling", + sourcePresetOffline: "Offline Mode: local files and cache only", + sourcePresetHybrid: "SDK + Web Crawling", + sourceHybridWarning: "Using Steamworks SDK and steamcommunity crawling at the same time may make Steam Community IP rate limiting more likely. Enable this only when you need web-only author, tag, or page detail enrichment.", + sourceSteamworksSdk: "Steamworks SDK", + sourceSteamWebApi: "Steam Web API", + sourceSteamCommunityHtml: "steamcommunity HTML crawling", + sourceOrderTitle: "Advanced source order", + sourceMoveUp: "Up", + sourceMoveDown: "Down", + sourceAllowHybridTitle: "Allow SDK and steamcommunity to crawl together", + sourceAllowHybridDesc: "When enabled, steamcommunity pages may still be fetched for enrichment even after the SDK is connected.", dummyBypassTitle: "Workshop Detection Bypass", dummyBypassDesc: [ "When enabled, moving an addon out of the workshop folder will automatically generate a dummy addon in the workshop directory (keeping only thumbnail, original AppID, version, prefixing name with (L4A Dummy) and marking generated by L4A) to attempt to bypass Steam Workshop synchronization checks.", diff --git a/src/services/workshopClient.test.ts b/src/services/workshopClient.test.ts index 2f29453..d5d5053 100644 --- a/src/services/workshopClient.test.ts +++ b/src/services/workshopClient.test.ts @@ -38,6 +38,7 @@ describe('workshopClient', () => { mockParseSSRItems.mockReset(); mockParseTagCategories.mockReset(); mockRememberWorkshopItems.mockClear(); + localStorage.clear(); }); test('fetchWorkshopItems skips Steamworks SDK queries when disabled in frontend settings', async () => { @@ -171,6 +172,15 @@ describe('workshopClient', () => { }); const workshopClient = await import('./workshopClient'); + workshopClient.setWorkshopSourceSettings({ + preset: 'hybrid', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: true, + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', + }); const result = await workshopClient.fetchWorkshopItems({ query: 'tank' }); expect(result.source).toBe('steam-sdk'); @@ -186,6 +196,41 @@ describe('workshopClient', () => { })); }); + test('fetchWorkshopItems does not HTML-enrich SDK results by default when SDK is available', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_items') { + return Promise.resolve({ + source: 'steam-sdk', + items: [{ + publishedfileid: '12345', + title: 'SDK Item', + creator: '76561198000000001', + creator_steam_id: '76561198000000001', + creator_name: '', + }], + }); + } + if (cmd === 'fetch_workshop_html') { + throw new Error('HTML should not be fetched by default'); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const result = await workshopClient.fetchWorkshopItems({ query: 'tank' }); + + expect(result.source).toBe('steam-sdk'); + expect(result.items[0].authorName).toBe(''); + expect(mockInvoke).not.toHaveBeenCalledWith('fetch_workshop_html', expect.anything()); + }); + test('mapSteamDetailToWorkshopItem keeps 64-bit creator id and converts score to stars', async () => { const workshopClient = await import('./workshopClient'); const item = workshopClient.mapSteamDetailToWorkshopItem({ diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index 84207ff..ecf71ae 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -4,6 +4,7 @@ import { WorkshopCapabilities, WorkshopItem, } from '../components/workshop/types'; +import type { WorkshopSourceSettings } from '../types/addon'; import { parseHomepageSections, parseSSRItems, @@ -64,17 +65,90 @@ export interface FetchWorkshopItemsInput { let capabilitiesPromise: Promise | null = null; let steamworksSdkDisabled = false; let workshopWarningReporter: ((message: string) => void) | null = null; +let workshopSourceSettings: WorkshopSourceSettings = { + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', +}; export function setSteamworksSdkDisabled(disabled: boolean) { steamworksSdkDisabled = disabled; } +export function setWorkshopSourceSettings(settings: WorkshopSourceSettings | undefined) { + workshopSourceSettings = { + ...workshopSourceSettings, + ...(settings || {}), + sourceOrder: settings?.sourceOrder?.length ? settings.sourceOrder : workshopSourceSettings.sourceOrder, + cacheRetention: 'keep', + }; +} + export function setWorkshopWarningReporter(reporter: ((message: string) => void) | null) { workshopWarningReporter = reporter; } function shouldUseSteamworksSdk() { - return !steamworksSdkDisabled; + return !steamworksSdkDisabled && + workshopSourceSettings.preset !== 'offline' && + workshopSourceSettings.allowSteamworksSdk; +} + +function shouldUseSteamCommunityHtml(capabilities: WorkshopCapabilities | null) { + if (workshopSourceSettings.preset === 'offline' || !workshopSourceSettings.allowSteamCommunityHtml) { + return false; + } + const sdkAvailable = shouldUseSteamworksSdk() && !!(capabilities?.canQueryHome || capabilities?.canQueryItems); + if (workshopSourceSettings.preset === 'hybrid' || workshopSourceSettings.allowSdkHtmlHybrid) { + return true; + } + return !sdkAvailable; +} + +function listCacheKey(kind: string, input: unknown) { + return `l4a.workshop.${kind}.${JSON.stringify(input)}`; +} + +function readListCache(key: string): T | null { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) as T : null; + } catch { + return null; + } +} + +function writeListCache(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify({ savedAt: new Date().toISOString(), value })); + } catch (err) { + console.warn('Failed to save workshop list cache:', err); + } +} + +function readCachedValue(key: string): T | null { + const cached = readListCache<{ value: T }>(key); + return cached?.value || null; +} + +async function readWorkshopItemCache(): Promise> { + return invoke>('get_workshop_cache').catch(() => ({})); +} + +function cachedDetailToWorkshopItem(workshopId: string, detail: any): WorkshopItem { + return mapSteamDetailToWorkshopItem({ + ...detail, + publishedfileid: detail?.publishedfileid || detail?.workshopId || workshopId, + preview_url: detail?.preview_url || detail?.previewUrl || detail?.imagePath, + creator_name: detail?.creator_name || detail?.creatorName || detail?.authorName, + creator: detail?.creator || detail?.creatorId || detail?.authorId, + creator_steam_id: detail?.creator_steam_id || detail?.creatorSteamId, + creator_account_id: detail?.creator_account_id || detail?.creatorAccountId, + }, detail?.lastSeenSource || detail?.lastPageSource || 'cache'); } function resolveBrowseSort(input: FetchWorkshopItemsInput): string | undefined { @@ -205,6 +279,7 @@ export async function fetchWorkshopHome() { const capabilities = shouldUseSteamworksSdk() ? await getWorkshopCapabilities().catch(() => null) : null; + const cacheKey = listCacheKey('home', { version: 1 }); let sdkSections: HomepageSection[] = []; let source = 'web-fallback'; @@ -228,7 +303,18 @@ export async function fetchWorkshopHome() { } } + const allowHtml = shouldUseSteamCommunityHtml(capabilities); + if (sdkSections.length === 0) { + if (!allowHtml) { + const cached = readCachedValue(cacheKey); + if (cached) return { ...cached, source: 'cache' }; + return { + source: 'cache', + sections: [], + tagCategories: [], + }; + } const html = await invoke('fetch_workshop_html', { url: 'https://steamcommunity.com/app/550/workshop/', source: 'workshop-home', @@ -238,16 +324,18 @@ export async function fetchWorkshopHome() { items: rememberWorkshopItems(section.items), })); const tagCategories = parseTagCategories(html); - return { + const result = { source: 'web-fallback', sections: htmlSections, tagCategories, }; + writeListCache(cacheKey, result); + return result; } let tagCategories = []; let htmlSections: HomepageSection[] = []; - try { + if (allowHtml) try { const html = await invoke('fetch_workshop_html', { url: 'https://steamcommunity.com/app/550/workshop/', source: 'workshop-home', @@ -261,11 +349,13 @@ export async function fetchWorkshopHome() { console.warn('Workshop home HTML enrichment failed:', err); } - return { + const result = { source: htmlSections.length > 0 && source === 'steam-sdk' ? 'hybrid' : source, sections: enrichHomepageSections(sdkSections, htmlSections), tagCategories, }; + writeListCache(cacheKey, result); + return result; } export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { @@ -273,6 +363,7 @@ export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { ? await getWorkshopCapabilities().catch(() => null) : null; const resolvedSort = resolveBrowseSort(input); + const cacheKey = listCacheKey('items', { ...input, sort: resolvedSort || '' }); if (capabilities?.canQueryItems) { const creatorId = input.creatorId?.trim(); const creatorNumeric = !creatorId || /^\d+$/.test(creatorId); @@ -291,7 +382,7 @@ export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { }); reportWorkshopWarnings(data.warnings); let items = data.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source)); - if (items.some(needsHtmlAuthorEnrichment)) { + if (items.some(needsHtmlAuthorEnrichment) && shouldUseSteamCommunityHtml(capabilities)) { try { const html: string = await invoke('fetch_workshop_html', { url: buildBrowseUrl(input), @@ -302,25 +393,41 @@ export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { console.warn('Steam SDK browse HTML enrichment failed:', err); } } - return { + const result = { source: data.source, items: rememberWorkshopItems(items), }; + writeListCache(cacheKey, result); + return result; } catch (err) { console.warn('Steam SDK browse query failed, falling back to HTML:', err); } } } + if (!shouldUseSteamCommunityHtml(capabilities)) { + const cached = readCachedValue(cacheKey); + if (cached) return { ...cached, source: 'cache' }; + return { source: 'cache', items: [] }; + } + const url = buildBrowseUrl(input); - const html: string = await invoke('fetch_workshop_html', { - url, - source: input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', - }); - return { - source: 'web-fallback', - items: rememberWorkshopItems(parseSSRItems(html, 'workshop_query')), - }; + try { + const html: string = await invoke('fetch_workshop_html', { + url, + source: input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', + }); + const result = { + source: 'web-fallback', + items: rememberWorkshopItems(parseSSRItems(html, 'workshop_query')), + }; + writeListCache(cacheKey, result); + return result; + } catch (err) { + const cached = readCachedValue(cacheKey); + if (cached) return { ...cached, source: 'cache' }; + throw err; + } } export async function fetchWorkshopItem(workshopId: string) { @@ -340,11 +447,22 @@ export async function fetchWorkshopItem(workshopId: string) { } } - const data: any = await invoke('fetch_collection', { collectionId: workshopId }); - return { - source: 'web-fallback', - item: rememberWorkshopItems([mapSteamDetailToWorkshopItem(data.collection, 'web-fallback')])[0], - }; + try { + const data: any = await invoke('fetch_collection', { collectionId: workshopId }); + return { + source: 'web-fallback', + item: rememberWorkshopItems([mapSteamDetailToWorkshopItem(data.collection, 'web-fallback')])[0], + }; + } catch (err) { + const cache = await readWorkshopItemCache(); + if (cache[workshopId]) { + return { + source: 'cache', + item: rememberWorkshopItems([cachedDetailToWorkshopItem(workshopId, cache[workshopId])])[0], + }; + } + throw err; + } } export async function fetchWorkshopCollection(workshopId: string) { @@ -378,25 +496,53 @@ export async function fetchWorkshopCollection(workshopId: string) { } } - const data: any = await invoke('fetch_collection', { collectionId: workshopId }); - const resolvedCollection = rememberWorkshopItems([ - mapSteamDetailToWorkshopItem(data.collection, 'web-fallback'), - ])[0]; - return { - source: 'web-fallback', - collection: { - ...data.collection, - title: resolvedCollection.title, - preview_url: resolvedCollection.imagePath, - creator_name: resolvedCollection.authorName, - creator: resolvedCollection.authorId, - creator_steam_id: resolvedCollection.authorSteamId, - creator_account_id: resolvedCollection.authorAccountId, - }, - items: rememberWorkshopItems( - (data.items || []).map((item: any) => mapSteamDetailToWorkshopItem(item, 'web-fallback')), - ), - }; + try { + const data: any = await invoke('fetch_collection', { collectionId: workshopId }); + const resolvedCollection = rememberWorkshopItems([ + mapSteamDetailToWorkshopItem(data.collection, 'web-fallback'), + ])[0]; + return { + source: 'web-fallback', + collection: { + ...data.collection, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + (data.items || []).map((item: any) => mapSteamDetailToWorkshopItem(item, 'web-fallback')), + ), + }; + } catch (err) { + const cache = await readWorkshopItemCache(); + const collection = cache[workshopId]; + if (collection) { + const resolvedCollection = cachedDetailToWorkshopItem(workshopId, collection); + const childIds = collection.childItemIds || []; + return { + source: 'cache', + collection: { + ...collection, + publishedfileid: workshopId, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + childIds + .map((childId: string) => cache[childId] ? cachedDetailToWorkshopItem(childId, cache[childId]) : null) + .filter(Boolean), + ), + }; + } + throw err; + } } export async function fetchWorkshopHtml(url: string, source: string) { diff --git a/src/types/addon.ts b/src/types/addon.ts index da69e78..9dfa344 100644 --- a/src/types/addon.ts +++ b/src/types/addon.ts @@ -146,6 +146,17 @@ export interface Settings { enableDummyBypass: boolean; suppressSdkUnavailableWarning: boolean; disableSteamworksSdk: boolean; + workshopSourceSettings?: WorkshopSourceSettings; +} + +export interface WorkshopSourceSettings { + preset: 'conservative' | 'sdk-only' | 'offline' | 'hybrid'; + allowSteamworksSdk: boolean; + allowSteamWebApi: boolean; + allowSteamCommunityHtml: boolean; + allowSdkHtmlHybrid: boolean; + sourceOrder: string[]; + cacheRetention: 'keep'; } export interface DatabasePayload { From 7eca5f6201654a35053cfa3f6073981896e8f9fe Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 02:41:35 +0800 Subject: [PATCH 40/81] Fix Steam bridge runtime loading on Linux --- src-tauri/build.rs | 67 ++++++++++++++++++++++------ src-tauri/src/steam/bridge.rs | 83 ++++++++++++++++++++++++++--------- steam-bridge/src/lib.rs | 45 ++++++++++++++++--- 3 files changed, 155 insertions(+), 40 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index ff3f1c0..cb793e5 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -7,16 +7,34 @@ use std::process::Command; fn main() { tauri_build::build(); - if let Err(err) = stage_windows_steam_bridge() { + if let Err(err) = stage_steam_bridge() { println!("cargo:warning=failed to stage Steam bridge runtime files: {err}"); } } -fn stage_windows_steam_bridge() -> Result<(), String> { +fn stage_steam_bridge() -> Result<(), String> { let target = env::var("TARGET").map_err(|e| e.to_string())?; - if !target.contains("windows") { + let artifacts = if target.contains("windows") { + SteamBridgeArtifacts { + bridge_source_name: "l4a_steam_bridge.dll", + bridge_staged_name: "l4a-steam-bridge.dll", + steam_api_name: "steam_api64.dll", + } + } else if target.contains("linux") { + SteamBridgeArtifacts { + bridge_source_name: "libl4a_steam_bridge.so", + bridge_staged_name: "libl4a-steam-bridge.so", + steam_api_name: "libsteam_api.so", + } + } else if target.contains("darwin") { + SteamBridgeArtifacts { + bridge_source_name: "libl4a_steam_bridge.dylib", + bridge_staged_name: "libl4a-steam-bridge.dylib", + steam_api_name: "libsteam_api.dylib", + } + } else { return Ok(()); - } + }; println!("cargo:rerun-if-env-changed=STEAM_SDK_LOCATION"); println!("cargo:rerun-if-changed=../steam-bridge/Cargo.toml"); @@ -38,8 +56,7 @@ fn stage_windows_steam_bridge() -> Result<(), String> { .arg(&bridge_manifest) .arg("--target") .arg(&target) - .arg("--profile") - .arg(&profile) + .args(cargo_profile_args(&profile)) .arg("--target-dir") .arg(&bridge_target_dir) .status() @@ -60,18 +77,26 @@ fn stage_windows_steam_bridge() -> Result<(), String> { let bridge_output_dir = bridge_target_dir.join(&target).join(&profile); copy_if_newer( - &bridge_output_dir.join("l4a_steam_bridge.dll"), - &steam_dir.join("l4a-steam-bridge.dll"), + &bridge_output_dir.join(artifacts.bridge_source_name), + &steam_dir.join(artifacts.bridge_staged_name), )?; - let steam_api = find_file_named(&bridge_output_dir.join("build"), "steam_api64.dll") - .ok_or_else(|| { - "failed to locate steam_api64.dll in steam bridge build output; set STEAM_SDK_LOCATION to your Steamworks SDK root if needed".to_string() - })?; - copy_if_newer(&steam_api, &steam_dir.join("steam_api64.dll"))?; + let steam_api = + find_file_named(&bridge_output_dir.join("build"), artifacts.steam_api_name).ok_or_else( + || { + format!( + "failed to locate {} in steam bridge build output; set STEAM_SDK_LOCATION to your Steamworks SDK root if needed", + artifacts.steam_api_name + ) + }, + )?; + copy_if_newer(&steam_api, &steam_dir.join(artifacts.steam_api_name))?; if target.contains("windows-gnu") { - for dll_name in gnu_runtime_dlls(&target, &bridge_output_dir.join("l4a_steam_bridge.dll")) { + for dll_name in gnu_runtime_dlls( + &target, + &bridge_output_dir.join(artifacts.bridge_source_name), + ) { if let Some(runtime_dll) = locate_linker_runtime_dll(&target, &dll_name) { copy_if_newer(&runtime_dll, &steam_dir.join(&dll_name))?; } @@ -81,6 +106,20 @@ fn stage_windows_steam_bridge() -> Result<(), String> { Ok(()) } +struct SteamBridgeArtifacts { + bridge_source_name: &'static str, + bridge_staged_name: &'static str, + steam_api_name: &'static str, +} + +fn cargo_profile_args(profile: &str) -> Vec<&str> { + match profile { + "debug" => Vec::new(), + "release" => vec!["--release"], + _ => vec!["--profile", profile], + } +} + fn find_profile_dir() -> Result { let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|e| e.to_string())?); let profile = env::var("PROFILE").map_err(|e| e.to_string())?; diff --git a/src-tauri/src/steam/bridge.rs b/src-tauri/src/steam/bridge.rs index e87d022..a509c96 100644 --- a/src-tauri/src/steam/bridge.rs +++ b/src-tauri/src/steam/bridge.rs @@ -13,6 +13,7 @@ type FreeStringFn = unsafe extern "C" fn(*mut c_char); type ShutdownFn = unsafe extern "C" fn(); struct BridgeApi { + _dependencies: Vec, _library: Library, init: InitFn, request_json: RequestFn, @@ -37,18 +38,20 @@ pub struct WorkshopBridge { impl WorkshopBridge { pub fn load_near(exe_dir: &Path) -> Result { - let mut last_error = None; - for candidate in candidate_library_paths(exe_dir) { - match unsafe { Self::load_from_path(&candidate) } { - Ok(bridge) => return Ok(bridge), - Err(err) => last_error = Some(format!("{}: {}", candidate.display(), err)), - } + let bridge_path = bridge_library_path(exe_dir); + if !bridge_path.exists() { + return Err(format!( + "Steam bridge library not found at {}", + bridge_path.display() + )); } - Err(last_error.unwrap_or_else(|| "Steam bridge DLL not found".to_string())) + unsafe { Self::load_from_path(&bridge_path) } + .map_err(|err| format!("failed to load {}: {}", bridge_path.display(), err)) } unsafe fn load_from_path(path: &Path) -> Result { + let dependencies = load_support_libraries(path)?; let library = load_library(path)?; let init = *library .get::(b"l4a_steam_bridge_init\0") @@ -64,6 +67,7 @@ impl WorkshopBridge { .map_err(|e| e.to_string())?; let api = Arc::new(BridgeApi { + _dependencies: dependencies, _library: library, init, request_json, @@ -83,12 +87,12 @@ impl WorkshopBridge { let capabilities = WorkshopCapabilities { bridge_available: true, bridge_loaded: true, - bridge_initialized: false, + bridge_initialized: true, provider: "steam-sdk".to_string(), bridge_version: init_response.version.clone(), last_error: None, - current_user_steam_id: None, - current_user_account_id: None, + current_user_steam_id: init_response.current_user_steam_id.clone(), + current_user_account_id: init_response.current_user_account_id.clone(), can_query_items: true, can_query_home: true, can_download: true, @@ -177,21 +181,58 @@ fn format_windows_load_error(path: &Path, source: &str) -> String { ) } -fn candidate_library_paths(exe_dir: &Path) -> Vec { - let names = if cfg!(target_os = "windows") { - vec!["l4a-steam-bridge.dll"] +fn bridge_library_path(exe_dir: &Path) -> PathBuf { + exe_dir.join("steam").join(bridge_library_name()) +} + +fn bridge_library_name() -> &'static str { + if cfg!(target_os = "windows") { + "l4a-steam-bridge.dll" } else if cfg!(target_os = "macos") { - vec!["libl4a-steam-bridge.dylib"] + "libl4a-steam-bridge.dylib" } else { - vec!["libl4a-steam-bridge.so", "l4a-steam-bridge.dll"] - }; + "libl4a-steam-bridge.so" + } +} + +fn steam_api_library_name() -> &'static str { + if cfg!(target_os = "windows") { + "steam_api64.dll" + } else if cfg!(target_os = "macos") { + "libsteam_api.dylib" + } else { + "libsteam_api.so" + } +} + +unsafe fn load_support_libraries(path: &Path) -> Result, String> { + #[cfg(target_os = "windows")] + { + let _ = path; + Ok(Vec::new()) + } + + #[cfg(not(target_os = "windows"))] + { + use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW}; + + let parent = path + .parent() + .ok_or_else(|| format!("failed to resolve parent directory for {}", path.display()))?; + let steam_api_path = parent.join(steam_api_library_name()); + if !steam_api_path.exists() { + return Err(format!( + "required Steamworks runtime not found at {}", + steam_api_path.display() + )); + } + + let steam_api = UnixLibrary::open(Some(&steam_api_path), RTLD_NOW | RTLD_GLOBAL) + .map(Library::from) + .map_err(|e| format!("failed to preload {}: {}", steam_api_path.display(), e))?; - let mut paths = Vec::new(); - for name in names { - paths.push(exe_dir.join(name)); - paths.push(exe_dir.join("steam").join(name)); + Ok(vec![steam_api]) } - paths } fn invoke_raw_string(api: &BridgeApi, request: Option<&str>) -> Result { diff --git a/steam-bridge/src/lib.rs b/steam-bridge/src/lib.rs index 3df2e46..8c691f2 100644 --- a/steam-bridge/src/lib.rs +++ b/steam-bridge/src/lib.rs @@ -26,6 +26,12 @@ struct BridgeRuntime { struct InitResponse { ok: bool, version: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + current_user_steam_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + current_user_account_id: Option, } #[derive(Debug, Serialize)] @@ -127,10 +133,37 @@ struct QueryExecution { #[no_mangle] pub extern "C" fn l4a_steam_bridge_init() -> *mut c_char { - into_c_string(&InitResponse { - ok: true, - version: BRIDGE_VERSION.to_string(), - }) + let response = match ensure_runtime() { + Ok(runtime) => { + let user = runtime.as_ref().map(|runtime| runtime.client.user()); + let Some(user) = user else { + return into_c_string(&InitResponse { + ok: false, + version: BRIDGE_VERSION.to_string(), + error: Some("Steam bridge runtime unavailable".to_string()), + current_user_steam_id: None, + current_user_account_id: None, + }); + }; + let steam_id = user.steam_id(); + InitResponse { + ok: true, + version: BRIDGE_VERSION.to_string(), + error: None, + current_user_steam_id: Some(steam_id.raw().to_string()), + current_user_account_id: Some(steam_id.account_id().raw().to_string()), + } + } + Err(err) => InitResponse { + ok: false, + version: BRIDGE_VERSION.to_string(), + error: Some(err), + current_user_steam_id: None, + current_user_account_id: None, + }, + }; + + into_c_string(&response) } #[no_mangle] @@ -383,7 +416,9 @@ fn query_collection(runtime: &mut BridgeRuntime, workshop_id: &str) -> Result Result { - Ok(Value::Array(query_details_internal(runtime, workshop_ids)?.0)) + Ok(Value::Array( + query_details_internal(runtime, workshop_ids)?.0, + )) } fn query_details_internal( From 20163a56bb098ea70998ff0b27a48606cc98ac71 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 03:06:53 +0800 Subject: [PATCH 41/81] fix: limit workshop image caching to known items --- src-tauri/src/commands/handlers.rs | 601 +++++++++++++----- src/components/AddonCard.tsx | 1 + src/components/CacheImage.test.tsx | 17 +- src/components/CacheImage.tsx | 18 +- src/components/DetailModal.tsx | 1 + src/components/Gallery.tsx | 6 + src/components/GroupCard.tsx | 1 + src/components/KnownUninstalledView.tsx | 1 + src/components/WorkshopBrowser.tsx | 7 + src/components/workshop/ItemCard.tsx | 8 +- src/components/workshop/SectionCarousel.tsx | 3 + .../workshop/WorkshopDetailModal.tsx | 14 +- 12 files changed, 505 insertions(+), 173 deletions(-) diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 57232c4..e07b034 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -248,7 +248,19 @@ fn is_background_workshop_fetch_source(source: &str) -> bool { matches!(source, "startup-auto" | "background-refresh") } -fn is_known_workshop_id(known_addons_path: &Path, workshop_id: &str) -> bool { +fn is_known_group_collection_id(groups_path: &Path, workshop_id: &str) -> bool { + let workshop_id = workshop_id.trim(); + if workshop_id.is_empty() { + return false; + } + + load_groups(groups_path) + .into_iter() + .filter_map(|group| group.workshop_collection_id) + .any(|collection_id| collection_id.trim() == workshop_id) +} + +fn is_known_workshop_id(known_addons_path: &Path, groups_path: &Path, workshop_id: &str) -> bool { let workshop_id = workshop_id.trim(); if workshop_id.is_empty() { return false; @@ -261,6 +273,10 @@ fn is_known_workshop_id(known_addons_path: &Path, workshop_id: &str) -> bool { return true; } + if is_known_group_collection_id(groups_path, workshop_id) { + return true; + } + known_addons.values().any(|entry| { entry.id == workshop_id || entry.vpk_name == format!("{}.vpk", workshop_id) @@ -272,12 +288,13 @@ fn ensure_background_workshop_fetch_allowed( source: &str, workshop_id: &str, known_addons_path: &Path, + groups_path: &Path, ) -> Result<(), String> { if !is_background_workshop_fetch_source(source) { return Ok(()); } - if is_known_workshop_id(known_addons_path, workshop_id) { + if is_known_workshop_id(known_addons_path, groups_path, workshop_id) { return Ok(()); } @@ -287,6 +304,206 @@ fn ensure_background_workshop_fetch_allowed( )) } +fn clear_persisted_workshop_media_and_content( + obj: &mut serde_json::Map, +) { + for key in [ + "previewUrl", + "imagePath", + "shortDescription", + "galleryPreviewUrls", + "description", + "descriptionHtml", + "imageGallery", + "galleryUrls", + "backgroundImageUrl", + ] { + obj.remove(key); + } +} + +fn persist_seen_workshop_item_entry( + obj: &mut serde_json::Map, + item: &WorkshopSeenItem, + source: &str, + now: &str, + allow_rich_content: bool, +) { + insert_non_empty_string(obj, "workshopId", &item.workshop_id); + insert_non_empty_string(obj, "title", &item.title); + insert_non_empty_string(obj, "creatorName", &item.author_name); + insert_non_empty_string(obj, "authorName", &item.author_name); + insert_non_empty_string(obj, "creatorId", &item.author_id); + insert_non_empty_string(obj, "authorId", &item.author_id); + insert_non_empty_string(obj, "creatorProfileUrl", &item.author_url); + insert_non_empty_string(obj, "authorUrl", &item.author_url); + if let Some(vanity_id) = item.author_vanity_id.as_deref() { + insert_non_empty_string(obj, "creatorVanityId", vanity_id); + } + if let Some(account_id) = item.author_account_id.as_deref() { + insert_non_empty_string(obj, "creatorAccountId", account_id); + } + if let Some(steam_id) = item.author_steam_id.as_deref() { + insert_non_empty_string(obj, "creatorSteamId", steam_id); + } + if item.author_id.chars().all(|c| c.is_ascii_digit()) && !item.author_id.is_empty() { + insert_non_empty_string(obj, "creatorSteamId", &item.author_id); + } + if item.author_url.contains("/profiles/") { + if let Some(id) = item + .author_url + .split("/profiles/") + .nth(1) + .and_then(|s| s.split('/').next()) + { + if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() { + insert_non_empty_string(obj, "creatorSteamId", id); + } + } + } + insert_optional_value( + obj, + "fileSizeDisplay", + item.file_size.clone().map(serde_json::Value::String), + ); + insert_optional_value(obj, "tags", item.tags.clone().map(|v| serde_json::json!(v))); + insert_optional_u64(obj, "subscriptions", item.subscriptions); + insert_optional_u64(obj, "favorites", item.favorites); + insert_optional_u64(obj, "lifetimeSubscriptions", item.lifetime_subscriptions); + insert_optional_u64(obj, "lifetimeFavorites", item.lifetime_favorites); + insert_optional_u64(obj, "views", item.views); + insert_optional_u64(obj, "comments", item.comments); + insert_optional_u64(obj, "totalVotes", item.total_votes); + insert_optional_u64(obj, "timeCreated", item.time_created); + insert_optional_u64(obj, "timeUpdated", item.time_updated); + insert_optional_u64(obj, "childCount", item.child_count); + insert_optional_u64(obj, "previewCount", item.preview_count); + insert_optional_vec_string(obj, "childItemIds", item.child_item_ids.clone()); + insert_non_empty_string(obj, "lastSeenSource", source); + insert_non_empty_string(obj, "lastSeenAt", now); + + if allow_rich_content { + insert_non_empty_string(obj, "previewUrl", &item.image_path); + insert_non_empty_string(obj, "imagePath", &item.image_path); + insert_optional_value( + obj, + "shortDescription", + item.short_description + .clone() + .map(serde_json::Value::String), + ); + insert_optional_vec_string(obj, "galleryPreviewUrls", item.gallery_preview_urls.clone()); + } else { + clear_persisted_workshop_media_and_content(obj); + } +} + +fn persist_workshop_page_details_entry( + obj: &mut serde_json::Map, + workshop_id: &str, + details: &serde_json::Value, + source: &str, + now: &str, + allow_rich_content: bool, +) { + insert_non_empty_string(obj, "workshopId", workshop_id); + insert_non_empty_string(obj, "lastPageFetchedAt", now); + insert_non_empty_string(obj, "lastPageSource", source); + if let Some(title) = details.get("title").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "title", title); + } + if let Some(creator_name) = details.get("creatorName").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorName", creator_name); + insert_non_empty_string(obj, "authorName", creator_name); + } + if let Some(profile_url) = details.get("creatorProfileUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorProfileUrl", profile_url); + insert_non_empty_string(obj, "authorUrl", profile_url); + } + if let Some(steam_id) = details.get("creatorSteamId").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorSteamId", steam_id); + insert_non_empty_string(obj, "creatorId", steam_id); + } + if let Some(vanity_id) = details.get("creatorVanityId").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorVanityId", vanity_id); + if obj + .get("creatorId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .is_empty() + { + insert_non_empty_string(obj, "creatorId", vanity_id); + } + } + if let Some(account_id) = details.get("creatorAccountId").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorAccountId", account_id); + } + if let Some(file_size) = details.get("fileSizeDisplay").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "fileSizeDisplay", file_size); + } + if let Some(posted_text) = details.get("postedDateText").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "postedDateText", posted_text); + } + if let Some(updated_text) = details.get("updatedDateText").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "updatedDateText", updated_text); + } + insert_optional_value( + obj, + "changeNoteCount", + details.get("changeNoteCount").cloned(), + ); + insert_optional_value(obj, "ratingStars", details.get("ratingStars").cloned()); + insert_optional_value(obj, "ratingCount", details.get("ratingCount").cloned()); + insert_optional_value( + obj, + "uniqueVisitors", + details.get("uniqueVisitors").cloned(), + ); + insert_optional_value( + obj, + "currentSubscribers", + details.get("currentSubscribers").cloned(), + ); + insert_optional_value( + obj, + "currentFavorites", + details.get("currentFavorites").cloned(), + ); + + if let Some(tags) = details.get("tags").cloned() { + obj.insert("pageTags".to_string(), tags); + } + if let Some(required) = details.get("requiredItems").cloned() { + obj.insert("requiredItems".to_string(), required); + } + if let Some(parent_collections) = details.get("parentCollections").cloned() { + obj.insert("parentCollections".to_string(), parent_collections); + } + + if allow_rich_content { + if let Some(preview_url) = details.get("previewUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "previewUrl", preview_url); + insert_non_empty_string(obj, "imagePath", preview_url); + } + if let Some(description) = details.get("description").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "description", description); + } + if let Some(description_html) = details.get("descriptionHtml").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "descriptionHtml", description_html); + } + if let Some(gallery) = details.get("imageGallery").cloned() { + obj.insert("imageGallery".to_string(), gallery.clone()); + obj.insert("galleryUrls".to_string(), gallery); + } + if let Some(background) = details.get("backgroundImageUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "backgroundImageUrl", background); + } + } else { + clear_persisted_workshop_media_and_content(obj); + } +} + fn request_download_cancellation(state: &crate::AppState, workshop_id: &str) -> Result<(), String> { let mut cancelled = state .cancelled_downloads @@ -689,7 +906,7 @@ fn save_workshop_cache( "schemaVersion": 2, "items": cache, })) - .map_err(|e| format!("Failed to serialize workshop cache: {}", e))?; + .map_err(|e| format!("Failed to serialize workshop cache: {}", e))?; fs::write(cache_path, json).map_err(|e| format!("Failed to write workshop cache: {}", e)) } @@ -1846,7 +2063,10 @@ pub async fn cache_remote_image( .await .map_err(|e| format!("Failed to fetch remote image: {}", e))?; if !response.status().is_success() { - return Err(format!("Remote image responded with status {}", response.status())); + return Err(format!( + "Remote image responded with status {}", + response.status() + )); } let content_type = response @@ -3124,15 +3344,14 @@ pub async fn steam_sync(state: State<'_, crate::AppState>) -> Result std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-groups.json", + name, + std::process::id() + )); + fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap(); + path + } + #[test] fn background_source_detection_is_limited_to_silent_refreshes() { assert!(is_background_workshop_fetch_source("startup-auto")); @@ -4170,18 +4275,56 @@ mod tests { } }), ); - - let allowed = ensure_background_workshop_fetch_allowed("startup-auto", "12345", &path); - let blocked = ensure_background_workshop_fetch_allowed("startup-auto", "99999", &path); - let manual = ensure_background_workshop_fetch_allowed("workshop-detail", "99999", &path); + let groups_path = write_groups_file("allowed", json!([])); + + let allowed = + ensure_background_workshop_fetch_allowed("startup-auto", "12345", &path, &groups_path); + let blocked = + ensure_background_workshop_fetch_allowed("startup-auto", "99999", &path, &groups_path); + let manual = ensure_background_workshop_fetch_allowed( + "workshop-detail", + "99999", + &path, + &groups_path, + ); fs::remove_file(&path).ok(); + fs::remove_file(&groups_path).ok(); assert!(allowed.is_ok()); assert!(blocked.is_err()); assert!(manual.is_ok()); } + #[test] + fn background_fetch_allows_known_collection_ids() { + let known_addons_path = write_known_addons_file("collection-allowed", json!({})); + let groups_path = write_groups_file( + "collection-allowed", + json!([ + { + "id": "group-1", + "name": "Known Collection", + "addons": [], + "workshopCollectionId": "54321", + "source": "workshop-import" + } + ]), + ); + + let allowed = ensure_background_workshop_fetch_allowed( + "startup-auto", + "54321", + &known_addons_path, + &groups_path, + ); + + fs::remove_file(&known_addons_path).ok(); + fs::remove_file(&groups_path).ok(); + + assert!(allowed.is_ok()); + } + #[test] fn extracts_friendly_workshop_error_message_from_html() { let html = r#" @@ -4200,4 +4343,126 @@ mod tests { Some("Too many requests, please try again later.") ); } + + #[test] + fn unknown_seen_items_do_not_persist_images_or_descriptions() { + let mut obj = json!({ + "previewUrl": "https://example.com/old-preview.jpg", + "imagePath": "https://example.com/old-preview.jpg", + "shortDescription": "old description", + "galleryPreviewUrls": ["https://example.com/old-gallery.jpg"], + "description": "old page description" + }) + .as_object() + .cloned() + .unwrap(); + + persist_seen_workshop_item_entry( + &mut obj, + &WorkshopSeenItem { + workshop_id: "99999".to_string(), + title: "Unknown item".to_string(), + image_path: "https://example.com/preview.jpg".to_string(), + short_description: Some("new description".to_string()), + gallery_preview_urls: Some(vec!["https://example.com/gallery.jpg".to_string()]), + ..WorkshopSeenItem::default() + }, + "workshop-browser", + "2026-07-09T00:00:00Z", + false, + ); + + assert_eq!( + obj.get("title").and_then(|v| v.as_str()), + Some("Unknown item") + ); + assert!(obj.get("previewUrl").is_none()); + assert!(obj.get("imagePath").is_none()); + assert!(obj.get("shortDescription").is_none()); + assert!(obj.get("galleryPreviewUrls").is_none()); + assert!(obj.get("description").is_none()); + } + + #[test] + fn known_seen_items_still_persist_preview_fields() { + let mut obj = serde_json::Map::new(); + + persist_seen_workshop_item_entry( + &mut obj, + &WorkshopSeenItem { + workshop_id: "12345".to_string(), + title: "Known item".to_string(), + image_path: "https://example.com/preview.jpg".to_string(), + short_description: Some("description".to_string()), + gallery_preview_urls: Some(vec!["https://example.com/gallery.jpg".to_string()]), + ..WorkshopSeenItem::default() + }, + "workshop-browser", + "2026-07-09T00:00:00Z", + true, + ); + + assert_eq!( + obj.get("previewUrl").and_then(|v| v.as_str()), + Some("https://example.com/preview.jpg") + ); + assert_eq!( + obj.get("imagePath").and_then(|v| v.as_str()), + Some("https://example.com/preview.jpg") + ); + assert_eq!( + obj.get("shortDescription").and_then(|v| v.as_str()), + Some("description") + ); + assert_eq!( + obj.get("galleryPreviewUrls") + .and_then(|v| v.as_array()) + .map(|v| v.len()), + Some(1) + ); + } + + #[test] + fn unknown_page_details_do_not_persist_rich_content() { + let mut obj = json!({ + "previewUrl": "https://example.com/old-preview.jpg", + "imagePath": "https://example.com/old-preview.jpg", + "description": "old description", + "descriptionHtml": "

old

", + "imageGallery": ["https://example.com/old-gallery.jpg"], + "galleryUrls": ["https://example.com/old-gallery.jpg"], + "backgroundImageUrl": "https://example.com/old-bg.jpg" + }) + .as_object() + .cloned() + .unwrap(); + + persist_workshop_page_details_entry( + &mut obj, + "99999", + &json!({ + "title": "Unknown detail item", + "previewUrl": "https://example.com/preview.jpg", + "description": "new description", + "descriptionHtml": "

new

", + "imageGallery": ["https://example.com/gallery.jpg"], + "backgroundImageUrl": "https://example.com/bg.jpg" + }), + "workshop-detail", + "2026-07-09T00:00:00Z", + false, + ); + + assert_eq!( + obj.get("title").and_then(|v| v.as_str()), + Some("Unknown detail item") + ); + assert!(obj.get("previewUrl").is_none()); + assert!(obj.get("imagePath").is_none()); + assert!(obj.get("description").is_none()); + assert!(obj.get("descriptionHtml").is_none()); + assert!(obj.get("imageGallery").is_none()); + assert!(obj.get("galleryUrls").is_none()); + assert!(obj.get("backgroundImageUrl").is_none()); + } } diff --git a/src/components/AddonCard.tsx b/src/components/AddonCard.tsx index 9fc1f2d..d256982 100644 --- a/src/components/AddonCard.tsx +++ b/src/components/AddonCard.tsx @@ -89,6 +89,7 @@ export const AddonCard: React.FC = ({ {addon.imagePath ? ( { expect(img.src).toBe('blob:cache-image-url'); }); - test('caches remote image before rendering', async () => { + test('renders remote image directly by default', async () => { + render( + Fallback content} + /> + ); + + const img = await screen.findByAltText('Remote Image') as HTMLImageElement; + expect(img.src).toBe('https://example.com/image.jpg'); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + test('caches remote image before rendering when cacheRemote is enabled', async () => { vi.mocked(URL.createObjectURL).mockReturnValueOnce('blob:remote-cache-image-url'); mockInvoke.mockImplementation(async (cmd) => { if (cmd === 'cache_remote_image') return '/cache/remote.jpg'; @@ -48,6 +62,7 @@ describe('CacheImage', () => { render( Fallback content} /> diff --git a/src/components/CacheImage.tsx b/src/components/CacheImage.tsx index da51b15..9401cbd 100644 --- a/src/components/CacheImage.tsx +++ b/src/components/CacheImage.tsx @@ -4,9 +4,16 @@ import { invoke } from '@tauri-apps/api/core'; interface CacheImageProps extends React.ImgHTMLAttributes { srcPath?: string; fallback?: React.ReactNode; + cacheRemote?: boolean; } -export const CacheImage: React.FC = ({ srcPath, fallback, onError, ...props }) => { +export const CacheImage: React.FC = ({ + srcPath, + fallback, + onError, + cacheRemote = false, + ...props +}) => { const [hasError, setHasError] = useState(false); const [resolvedSrc, setResolvedSrc] = useState(''); @@ -22,6 +29,13 @@ export const CacheImage: React.FC = ({ srcPath, fallback, onErr } if (srcPath.startsWith('http://') || srcPath.startsWith('https://')) { + if (!cacheRemote) { + setResolvedSrc(srcPath); + return () => { + cancelled = true; + }; + } + const cacheRemoteImage = async () => { try { const cachedPath = await invoke('cache_remote_image', { url: srcPath }); @@ -74,7 +88,7 @@ export const CacheImage: React.FC = ({ srcPath, fallback, onErr URL.revokeObjectURL(objectUrl); } }; - }, [srcPath]); + }, [srcPath, cacheRemote]); const handleError = (e: React.SyntheticEvent) => { setHasError(true); diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx index 09d7e80..9ad1ae9 100644 --- a/src/components/DetailModal.tsx +++ b/src/components/DetailModal.tsx @@ -138,6 +138,7 @@ export const DetailModal: React.FC = ({ } title={title} fallbackImage={addon.imagePath} + cacheRemote fallbackIcon={
diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx index 9c62a5f..99d22ee 100644 --- a/src/components/Gallery.tsx +++ b/src/components/Gallery.tsx @@ -7,6 +7,7 @@ interface GalleryProps { title: string; fallbackImage?: string; fallbackIcon?: React.ReactNode; + cacheRemote?: boolean; } export const Gallery: React.FC = ({ @@ -14,6 +15,7 @@ export const Gallery: React.FC = ({ title, fallbackImage, fallbackIcon, + cacheRemote = false, }) => { const [galleryIndex, setGalleryIndex] = useState(0); const [fullscreenImageIndex, setFullscreenImageIndex] = useState(null); @@ -66,6 +68,7 @@ export const Gallery: React.FC = ({ > = ({ ) : fallbackImage ? ( } @@ -166,6 +170,7 @@ export const Gallery: React.FC = ({ setGalleryIndex(i)} style={{ @@ -216,6 +221,7 @@ export const Gallery: React.FC = ({ > e.stopPropagation()} diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index fe175c1..a1e7c9f 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -101,6 +101,7 @@ export const GroupCard: React.FC = ({ {imagePath ? ( = ({ {addon.imagePath ? ( = ({ }) => { const { t } = useTranslation(); const scrollIdleTimerRef = useRef(null); + const knownCollectionIds = new Set( + (groups || []) + .map((group) => group.workshopCollectionId?.trim()) + .filter((id): id is string => !!id), + ); // View mode const [viewMode, setViewMode] = useState<'home' | 'browse' | 'search'>('home'); @@ -328,6 +333,7 @@ export const WorkshopBrowser: React.FC = ({ sectionType={sec.id === 'collections' ? 'collections' : 'readytouseitems'} addons={addons} knownUninstalledAddons={knownUninstalledAddons} + knownCollectionIds={knownCollectionIds} onItemClick={(item) => viewItemDetails(item.workshopId)} onViewAll={handleViewAllSection} loadingDetailId={loadingDetailId} @@ -386,6 +392,7 @@ export const WorkshopBrowser: React.FC = ({ section={section} addons={addons} knownUninstalledAddons={knownUninstalledAddons} + knownCollectionIds={knownCollectionIds} onClick={() => section === 'collections' ? viewCollectionDetails(item.workshopId) : viewItemDetails(item.workshopId)} isLoading={loadingDetailId === item.workshopId} /> diff --git a/src/components/workshop/ItemCard.tsx b/src/components/workshop/ItemCard.tsx index 88a98a3..fb18bf4 100644 --- a/src/components/workshop/ItemCard.tsx +++ b/src/components/workshop/ItemCard.tsx @@ -11,6 +11,7 @@ interface ItemCardProps { section: string; addons: Record; knownUninstalledAddons: Record; + knownCollectionIds?: Set; onClick: () => void; isLoading?: boolean; } @@ -20,12 +21,16 @@ export const ItemCard: React.FC = ({ section, addons, knownUninstalledAddons, + knownCollectionIds, onClick, isLoading, }) => { const { t } = useTranslation(); const isDownloaded = addons[item.workshopId + '.vpk'] !== undefined; const isKnown = knownUninstalledAddons[item.workshopId + '.vpk'] !== undefined; + const isKnownCollection = section === 'collections' && !!knownCollectionIds?.has(item.workshopId); + const isKnownEntry = isKnown || isKnownCollection; + const shouldCacheRemote = isDownloaded || isKnownEntry; return (
@@ -39,6 +44,7 @@ export const ItemCard: React.FC = ({ = ({ {isDownloaded && ( {t('workshop.badges.downloaded')} )} - {!isDownloaded && isKnown && ( + {!isDownloaded && isKnownEntry && ( ; knownUninstalledAddons: Record; + knownCollectionIds?: Set; onItemClick: (item: WorkshopItem) => void; onViewAll: (section: HomepageSection) => void; loadingDetailId?: string | null; @@ -30,6 +31,7 @@ export const SectionCarousel: React.FC = ({ sectionType, addons, knownUninstalledAddons, + knownCollectionIds, onItemClick, onViewAll, loadingDetailId, @@ -106,6 +108,7 @@ export const SectionCarousel: React.FC = ({ section={sectionType} addons={addons} knownUninstalledAddons={knownUninstalledAddons} + knownCollectionIds={knownCollectionIds} onClick={() => onItemClick(item)} isLoading={loadingDetailId === item.workshopId} /> diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx index f1666f2..5dfb1ab 100644 --- a/src/components/workshop/WorkshopDetailModal.tsx +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -97,6 +97,9 @@ export const WorkshopDetailModal: React.FC = ({ if (collection) { const allWorkshopIds = collection.items.map((i) => i.workshopId); const collectionId = collection.workshopId || allWorkshopIds[0]; + const isKnownCollection = !!collectionId && !!groups?.some( + (group) => group.workshopCollectionId?.trim() === collectionId, + ); // Use the large background image from the scraped page if available const heroImage = pageDetails?.backgroundImageUrl || collection.imagePath; const collectionCreatorName = pageDetails?.creatorName || collection.creatorName; @@ -122,7 +125,12 @@ export const WorkshopDetailModal: React.FC = ({
{heroImage ? ( - + ) : ( )} @@ -176,6 +184,7 @@ export const WorkshopDetailModal: React.FC = ({ {collection.items.map((ci) => { const isKnown = !!knownUninstalledAddons[ci.workshopId + '.vpk']; const isDownloaded = !!addons[ci.workshopId + '.vpk']; + const shouldCacheRemote = isDownloaded || isKnown; return (
= ({ > {ci.imagePath ? ( ) : (
@@ -245,6 +255,7 @@ export const WorkshopDetailModal: React.FC = ({ const isDownloaded = !!addons[vpkKey]; const downloading = downloadProgress[item.workshopId] !== undefined; const progress = downloadProgress[item.workshopId]; + const shouldCacheRemote = isDownloaded || isKnown; const resolvedItem = resolveWorkshopItemAuthor(item); const displayAuthorName = pageDetails?.creatorName || resolvedItem.authorName; const displayAuthorUrl = pageDetails?.creatorProfileUrl || resolvedItem.authorUrl; @@ -283,6 +294,7 @@ export const WorkshopDetailModal: React.FC = ({ gallery={gallery} title={item.title} fallbackImage={item.imagePath} + cacheRemote={shouldCacheRemote} />
From b27c0006a910cd8d0ebc4dd896209f861a4f3c7c Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 04:15:55 +0800 Subject: [PATCH 42/81] Fix workshop snapshot caching and author mapping --- src-tauri/src/commands/handlers.rs | 458 +++++++++++++++++- src/App.tsx | 5 +- src/components/CacheImage.tsx | 3 + src/components/DetailModal.tsx | 9 +- src/components/WorkshopCommon.tsx | 5 +- src/components/workshop/ItemCard.tsx | 5 +- .../workshop/WorkshopDetailModal.tsx | 27 +- .../workshop/authorDirectory.test.ts | 22 + src/components/workshop/authorDirectory.ts | 24 +- src/components/workshop/ssrParser.test.ts | 27 ++ src/components/workshop/ssrParser.ts | 76 ++- src/index.css | 1 + src/services/workshopClient.test.ts | 45 ++ src/services/workshopClient.ts | 296 ++++++++++- src/utils/workshopKnown.ts | 36 ++ 15 files changed, 984 insertions(+), 55 deletions(-) create mode 100644 src/components/workshop/ssrParser.test.ts create mode 100644 src/utils/workshopKnown.ts diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index e07b034..947cefc 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -180,6 +180,16 @@ fn cache_image_extension(content_type: Option<&str>, url: &reqwest::Url) -> &'st "jpg" } +fn find_cached_remote_image(cache_dir: &Path, hash: &str) -> Option { + for extension in ["jpg", "png", "webp"] { + let filename = format!("{}_remote_image.{}", hash, extension); + if cache_dir.join(&filename).exists() { + return Some(format!("/cache/{}", filename)); + } + } + None +} + fn workshop_html_fetch_gate() -> &'static Mutex { WORKSHOP_HTML_FETCH_GATE.get_or_init(|| Mutex::new(WorkshopHtmlFetchGate::default())) } @@ -331,8 +341,13 @@ fn persist_seen_workshop_item_entry( ) { insert_non_empty_string(obj, "workshopId", &item.workshop_id); insert_non_empty_string(obj, "title", &item.title); - insert_non_empty_string(obj, "creatorName", &item.author_name); - insert_non_empty_string(obj, "authorName", &item.author_name); + let author_ids = [ + item.author_id.clone(), + item.author_steam_id.clone().unwrap_or_default(), + item.author_account_id.clone().unwrap_or_default(), + item.author_vanity_id.clone().unwrap_or_default(), + ]; + insert_author_name_if_useful(obj, &item.author_name, &author_ids); insert_non_empty_string(obj, "creatorId", &item.author_id); insert_non_empty_string(obj, "authorId", &item.author_id); insert_non_empty_string(obj, "creatorProfileUrl", &item.author_url); @@ -413,8 +428,24 @@ fn persist_workshop_page_details_entry( insert_non_empty_string(obj, "title", title); } if let Some(creator_name) = details.get("creatorName").and_then(|v| v.as_str()) { - insert_non_empty_string(obj, "creatorName", creator_name); - insert_non_empty_string(obj, "authorName", creator_name); + let author_ids = [ + details + .get("creatorSteamId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + details + .get("creatorAccountId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + details + .get("creatorVanityId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ]; + insert_author_name_if_useful(obj, creator_name, &author_ids); } if let Some(profile_url) = details.get("creatorProfileUrl").and_then(|v| v.as_str()) { insert_non_empty_string(obj, "creatorProfileUrl", profile_url); @@ -894,6 +925,253 @@ fn load_workshop_cache(cache_path: &Path) -> HashMap HashMap::new() } +fn json_string(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn account_id_to_steam_id(account_id: &str) -> Option { + let account = account_id.trim().parse::().ok()?; + Some((76561197960265728u64 + account).to_string()) +} + +fn known_addon_to_workshop_cache_entry( + key: &str, + entry: &KnownAddonEntry, +) -> Option<(String, serde_json::Value)> { + let workshop_id = entry + .workshop_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + let trimmed = key.trim(); + trimmed + .chars() + .all(|c| c.is_ascii_digit()) + .then_some(trimmed) + })?; + + let mut obj = serde_json::Map::new(); + insert_non_empty_string(&mut obj, "workshopId", workshop_id); + insert_non_empty_string(&mut obj, "title", &entry.vpk_name); + if let Some(image_path) = entry.image_path.as_deref() { + insert_non_empty_string(&mut obj, "imagePath", image_path); + insert_non_empty_string(&mut obj, "previewUrl", image_path); + } + + if let Some(details) = entry.steam_details.as_ref() { + if let Some(title) = json_string(details, "title") { + insert_non_empty_string(&mut obj, "title", &title); + } + if let Some(preview_url) = json_string(details, "preview_url") { + insert_non_empty_string(&mut obj, "previewUrl", &preview_url); + insert_non_empty_string(&mut obj, "imagePath", &preview_url); + } + if let Some(description) = json_string(details, "description") { + insert_non_empty_string(&mut obj, "description", &description); + insert_non_empty_string(&mut obj, "shortDescription", &description); + } + if let Some(creator) = json_string(details, "creator") { + insert_non_empty_string(&mut obj, "creatorId", &creator); + if creator.chars().all(|c| c.is_ascii_digit()) { + insert_non_empty_string(&mut obj, "creatorSteamId", &creator); + } + } + if let Some(account_id) = json_string(details, "creator_account_id") { + insert_non_empty_string(&mut obj, "creatorAccountId", &account_id); + if let Some(steam_id) = account_id_to_steam_id(&account_id) { + insert_non_empty_string(&mut obj, "creatorSteamId", &steam_id); + } + } + if let Some(vanity_id) = json_string(details, "creator_vanity_id") { + insert_non_empty_string(&mut obj, "creatorVanityId", &vanity_id); + } + if let Some(creator_name) = json_string(details, "creator_name") { + let ids = [ + json_string(&serde_json::Value::Object(obj.clone()), "creatorSteamId") + .unwrap_or_default(), + json_string(&serde_json::Value::Object(obj.clone()), "creatorAccountId") + .unwrap_or_default(), + json_string(&serde_json::Value::Object(obj.clone()), "creatorVanityId") + .unwrap_or_default(), + ]; + insert_author_name_if_useful(&mut obj, &creator_name, &ids); + } + if let Some(file_size) = json_string(details, "file_size") { + insert_non_empty_string(&mut obj, "fileSizeDisplay", &file_size); + } + if let Some(tags) = details.get("tags").cloned() { + obj.insert("tags".to_string(), tags); + } + obj.insert("steamDetails".to_string(), details.clone()); + } + + Some((workshop_id.to_string(), serde_json::Value::Object(obj))) +} + +fn merge_workshop_cache_values(base: &mut serde_json::Value, overlay: serde_json::Value) { + let Some(base_obj) = base.as_object_mut() else { + *base = overlay; + return; + }; + let Some(overlay_obj) = overlay.as_object() else { + *base = overlay; + return; + }; + for (key, value) in overlay_obj { + if !value.is_null() { + base_obj.insert(key.clone(), value.clone()); + } + } +} + +fn load_known_addons_as_workshop_cache( + known_addons_path: &Path, +) -> HashMap { + load_known_addons(known_addons_path) + .iter() + .filter_map(|(key, entry)| known_addon_to_workshop_cache_entry(key, entry)) + .collect() +} + +fn author_identity_values(obj: &serde_json::Map) -> HashSet { + let mut ids = HashSet::new(); + for key in [ + "creatorSteamId", + "creatorAccountId", + "creatorVanityId", + "creatorId", + "authorId", + "creatorProfileUrl", + "authorUrl", + ] { + if let Some(value) = obj.get(key).and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + ids.insert(trimmed.to_lowercase()); + if key == "creatorAccountId" { + if let Some(steam_id) = account_id_to_steam_id(trimmed) { + ids.insert(steam_id); + } + } + if let Some(profile_id) = trimmed + .split("/profiles/") + .nth(1) + .and_then(|s| s.split('/').next()) + { + if !profile_id.is_empty() { + ids.insert(profile_id.to_lowercase()); + } + } + if let Some(vanity_id) = trimmed + .split("/id/") + .nth(1) + .and_then(|s| s.split('/').next()) + { + if !vanity_id.is_empty() { + ids.insert(vanity_id.to_lowercase()); + } + } + } + } + } + ids +} + +fn propagate_author_names(cache: &mut HashMap) { + let learned: Vec<(String, Option, HashSet)> = cache + .values() + .filter_map(|value| { + let obj = value.as_object()?; + let ids = author_identity_values(obj); + let name = obj + .get("creatorName") + .or_else(|| obj.get("authorName")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|name| { + !looks_like_placeholder_author_name( + name, + &ids.iter().cloned().collect::>(), + ) + })?; + let url = obj + .get("creatorProfileUrl") + .or_else(|| obj.get("authorUrl")) + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + Some((name.to_string(), url, ids)) + }) + .collect(); + + if learned.is_empty() { + return; + } + + for value in cache.values_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + let ids = author_identity_values(obj); + if ids.is_empty() { + continue; + } + let current_name = obj + .get("creatorName") + .or_else(|| obj.get("authorName")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + for (name, url, learned_ids) in &learned { + if ids.is_disjoint(learned_ids) { + continue; + } + let ids_vec = ids.iter().cloned().collect::>(); + if looks_like_placeholder_author_name(current_name, &ids_vec) { + insert_non_empty_string(obj, "creatorName", name); + insert_non_empty_string(obj, "authorName", name); + } + if let Some(url) = url { + insert_non_empty_string(obj, "creatorProfileUrl", url); + insert_non_empty_string(obj, "authorUrl", url); + } + break; + } + } +} + +fn merge_known_addon_snapshots_into_cache( + cache: &mut HashMap, + known_addons_path: &Path, +) { + for (workshop_id, mut known_value) in load_known_addons_as_workshop_cache(known_addons_path) { + if let Some(existing) = cache.get(&workshop_id).cloned() { + merge_workshop_cache_values(&mut known_value, existing); + } + cache.insert(workshop_id, known_value); + } +} + +fn workshop_cache_with_known_addons( + cache_path: &Path, + known_addons_path: &Path, +) -> HashMap { + let mut combined = load_known_addons_as_workshop_cache(known_addons_path); + for (workshop_id, value) in load_workshop_cache(cache_path) { + if let Some(existing) = combined.get_mut(&workshop_id) { + merge_workshop_cache_values(existing, value); + } else { + combined.insert(workshop_id, value); + } + } + propagate_author_names(&mut combined); + combined +} + fn save_workshop_cache( cache_path: &Path, cache: &HashMap, @@ -924,6 +1202,30 @@ fn insert_non_empty_string( } } +fn looks_like_placeholder_author_name(name: &str, ids: &[String]) -> bool { + let name = name.trim(); + if name.is_empty() || name.chars().all(|c| c.is_ascii_digit()) { + return true; + } + + ids.iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty()) + .any(|id| name.eq_ignore_ascii_case(id)) +} + +fn insert_author_name_if_useful( + obj: &mut serde_json::Map, + name: &str, + ids: &[String], +) { + if looks_like_placeholder_author_name(name, ids) { + return; + } + insert_non_empty_string(obj, "creatorName", name); + insert_non_empty_string(obj, "authorName", name); +} + fn insert_optional_value( obj: &mut serde_json::Map, key: &str, @@ -1082,8 +1384,12 @@ fn merge_workshop_details_into_addon(addon: &mut Addon, details: &serde_json::Va } } -fn database_with_workshop_cache(db: &Database, workshop_cache_path: &Path) -> Database { - let cache = load_workshop_cache(workshop_cache_path); +fn database_with_workshop_cache( + db: &Database, + workshop_cache_path: &Path, + known_addons_path: &Path, +) -> Database { + let cache = workshop_cache_with_known_addons(workshop_cache_path, known_addons_path); if cache.is_empty() { return db.clone(); } @@ -1109,7 +1415,8 @@ fn database_with_workshop_cache(db: &Database, workshop_cache_path: &Path) -> Da pub async fn rescan_database_snapshot(app_handle: &AppHandle) -> Result<(Database, bool), String> { let state = app_handle.state::(); let mut db = state.db.lock().await; - let before = database_with_workshop_cache(&db, &state.workshop_cache_path); + let before = + database_with_workshop_cache(&db, &state.workshop_cache_path, &state.known_addons_path); scan_addons_internal( &mut db, &state.settings_path, @@ -1119,7 +1426,8 @@ pub async fn rescan_database_snapshot(app_handle: &AppHandle) -> Result<(Databas &state.workshop_service, ) .await?; - let after = database_with_workshop_cache(&db, &state.workshop_cache_path); + let after = + database_with_workshop_cache(&db, &state.workshop_cache_path, &state.known_addons_path); let changed = after != before; Ok((after, changed)) } @@ -2008,6 +2316,7 @@ pub async fn save_settings( Ok(database_with_workshop_cache( &db, &state.workshop_cache_path, + &state.known_addons_path, )) } @@ -2026,6 +2335,7 @@ pub async fn get_addons(state: State<'_, crate::AppState>) -> Result) -> Result) -> Result) -> Result {} @@ -3923,6 +4246,7 @@ pub async fn download_addon( Ok(database_with_workshop_cache( &db, &state.workshop_cache_path, + &state.known_addons_path, )) } .await; @@ -4059,6 +4383,7 @@ pub async fn add_known_addon( Ok(database_with_workshop_cache( &db, &state.workshop_cache_path, + &state.known_addons_path, )) } @@ -4066,7 +4391,10 @@ pub async fn add_known_addon( pub async fn get_workshop_cache( state: State<'_, crate::AppState>, ) -> Result, String> { - Ok(load_workshop_cache(&state.workshop_cache_path)) + Ok(workshop_cache_with_known_addons( + &state.workshop_cache_path, + &state.known_addons_path, + )) } #[tauri::command] @@ -4093,11 +4421,14 @@ pub async fn record_workshop_items_seen( persist_seen_workshop_item_entry(obj, &item, &source, &now, allow_rich_content); } + merge_known_addon_snapshots_into_cache(&mut cache, &state.known_addons_path); + propagate_author_names(&mut cache); save_workshop_cache(&state.workshop_cache_path, &cache)?; let db = state.db.lock().await; Ok(database_with_workshop_cache( &db, &state.workshop_cache_path, + &state.known_addons_path, )) } @@ -4121,11 +4452,8 @@ pub async fn persist_workshop_page_details( &state.known_addons_path, &state.groups_path, )?; - let allow_rich_content = is_known_workshop_id( - &state.known_addons_path, - &state.groups_path, - &workshop_id, - ); + let allow_rich_content = + is_known_workshop_id(&state.known_addons_path, &state.groups_path, &workshop_id); { let obj = cache_entry_object(&mut cache, &workshop_id); @@ -4169,11 +4497,14 @@ pub async fn persist_workshop_page_details( } } + merge_known_addon_snapshots_into_cache(&mut cache, &state.known_addons_path); + propagate_author_names(&mut cache); save_workshop_cache(&state.workshop_cache_path, &cache)?; let db = state.db.lock().await; Ok(database_with_workshop_cache( &db, &state.workshop_cache_path, + &state.known_addons_path, )) } @@ -4224,8 +4555,9 @@ pub async fn append_workshop_crawl_log( mod tests { use super::{ ensure_background_workshop_fetch_allowed, extract_steamcommunity_error_message, - is_background_workshop_fetch_source, persist_seen_workshop_item_entry, - persist_workshop_page_details_entry, + is_background_workshop_fetch_source, merge_known_addon_snapshots_into_cache, + persist_seen_workshop_item_entry, persist_workshop_page_details_entry, + propagate_author_names, workshop_cache_with_known_addons, }; use crate::commands::types::WorkshopSeenItem; use serde_json::json; @@ -4465,4 +4797,98 @@ mod tests { assert!(obj.get("galleryUrls").is_none()); assert!(obj.get("backgroundImageUrl").is_none()); } + + #[test] + fn known_addons_are_exposed_as_workshop_snapshots() { + let known_addons_path = write_known_addons_file( + "snapshot", + json!({ + "3560883926": { + "id": "3560883926", + "vpkName": "Early Days PART 1/6", + "workshopId": "3560883926", + "addonInfo": null, + "hasImage": true, + "imagePath": "https://example.com/preview.jpg", + "steamDetails": { + "publishedfileid": "3560883926", + "title": "Early Days PART 1/6", + "creator": "76561198012020581", + "description": "[b]UPDATE:[/b]\n\n[h2]Gameplay[/h2]", + "preview_url": "https://example.com/preview.jpg", + "tags": [{ "tag": "Campaigns" }] + } + } + }), + ); + let cache_path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-workshop_cache.json", + "snapshot", + std::process::id() + )); + fs::remove_file(&cache_path).ok(); + + let cache = workshop_cache_with_known_addons(&cache_path, &known_addons_path); + let entry = cache.get("3560883926").unwrap(); + + fs::remove_file(&known_addons_path).ok(); + fs::remove_file(&cache_path).ok(); + + assert_eq!( + entry.get("description").and_then(|v| v.as_str()), + Some("[b]UPDATE:[/b]\n\n[h2]Gameplay[/h2]") + ); + assert_eq!( + entry.get("creatorSteamId").and_then(|v| v.as_str()), + Some("76561198012020581") + ); + } + + #[test] + fn learned_author_name_updates_known_snapshots_with_matching_steam_id() { + let known_addons_path = write_known_addons_file( + "author-propagation", + json!({ + "3560886114": { + "id": "3560886114", + "vpkName": "Early Days PART 2/6", + "workshopId": "3560886114", + "addonInfo": null, + "hasImage": false, + "imagePath": null, + "steamDetails": { + "publishedfileid": "3560886114", + "title": "Early Days PART 2/6", + "creator": "76561198012020581", + "creator_name": "76561198012020581" + } + } + }), + ); + let mut cache = std::collections::HashMap::from([( + "3560883926".to_string(), + json!({ + "workshopId": "3560883926", + "creatorName": "perfect_buddy", + "creatorSteamId": "76561198012020581", + "creatorAccountId": "51754853", + "creatorProfileUrl": "https://steamcommunity.com/id/perfectbuddy" + }), + )]); + + merge_known_addon_snapshots_into_cache(&mut cache, &known_addons_path); + propagate_author_names(&mut cache); + + fs::remove_file(&known_addons_path).ok(); + + let related = cache.get("3560886114").unwrap(); + assert_eq!( + related.get("creatorName").and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + related.get("creatorProfileUrl").and_then(|v| v.as_str()), + Some("https://steamcommunity.com/id/perfectbuddy") + ); + } } diff --git a/src/App.tsx b/src/App.tsx index 7623f51..9352b2e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -406,12 +406,11 @@ function App() { if (foundAddon) { setDetailModal({ open: true, addon: foundAddon }); } else { - const vpkKey = `${workshopId}.vpk`; setDetailModal({ open: true, addon: { - id: vpkKey, - vpkName: vpkKey, + id: workshopId, + vpkName: workshopId, workshopId: workshopId, dirType: 'none', isEnabled: false, diff --git a/src/components/CacheImage.tsx b/src/components/CacheImage.tsx index 9401cbd..2686b1e 100644 --- a/src/components/CacheImage.tsx +++ b/src/components/CacheImage.tsx @@ -56,6 +56,9 @@ export const CacheImage: React.FC = ({ cacheRemoteImage(); return () => { cancelled = true; + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } }; } diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx index 9ad1ae9..eb26660 100644 --- a/src/components/DetailModal.tsx +++ b/src/components/DetailModal.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { WorkshopPageDetails } from './workshop/types'; import { Gallery } from './Gallery'; import { RequiredItems } from './WorkshopCommon'; -import { fetchWorkshopPageDetails, persistWorkshopPageDetails } from '../services/workshopClient'; +import { fetchWorkshopPageDetails, getWorkshopPageSnapshot, persistWorkshopPageDetails } from '../services/workshopClient'; interface DetailModalProps { open: boolean; @@ -48,7 +48,12 @@ export const DetailModal: React.FC = ({ const fetchPageDetails = useCallback(async (workshopId: string) => { setPageDetailsLoading(true); - setPageDetails(null); + const snapshot = await getWorkshopPageSnapshot(workshopId); + if (snapshot) { + setPageDetails(snapshot); + } else { + setPageDetails(null); + } try { const details = await fetchWorkshopPageDetails(workshopId, 'addon-detail'); setPageDetails(details); diff --git a/src/components/WorkshopCommon.tsx b/src/components/WorkshopCommon.tsx index 83c20a7..33caf82 100644 --- a/src/components/WorkshopCommon.tsx +++ b/src/components/WorkshopCommon.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Package, Link2, CheckCircle, FolderPlus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { isKnownWorkshopItem } from '../utils/workshopKnown'; interface RequiredItemsProps { requiredItems: { title: string; workshopId: string }[]; @@ -36,8 +37,8 @@ export const RequiredItems: React.FC = ({
{requiredItems.map((req) => { - const reqIsKnown = !!knownUninstalledAddons[req.workshopId + '.vpk']; - const reqIsDownloaded = !!addons[req.workshopId + '.vpk']; + const reqIsKnown = isKnownWorkshopItem(knownUninstalledAddons, req.workshopId); + const reqIsDownloaded = isKnownWorkshopItem(addons, req.workshopId); return (
= ({ isLoading, }) => { const { t } = useTranslation(); - const isDownloaded = addons[item.workshopId + '.vpk'] !== undefined; - const isKnown = knownUninstalledAddons[item.workshopId + '.vpk'] !== undefined; + const isDownloaded = isKnownWorkshopItem(addons, item.workshopId); + const isKnown = isKnownWorkshopItem(knownUninstalledAddons, item.workshopId); const isKnownCollection = section === 'collections' && !!knownCollectionIds?.has(item.workshopId); const isKnownEntry = isKnown || isKnownCollection; const shouldCacheRemote = isDownloaded || isKnownEntry; diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx index 5dfb1ab..211fd21 100644 --- a/src/components/workshop/WorkshopDetailModal.tsx +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -7,8 +7,9 @@ import { CacheImage } from '../CacheImage'; import { DatabasePayload, Group } from '../../types/addon'; import { Gallery } from '../Gallery'; import { RequiredItems, ParentCollections } from '../WorkshopCommon'; -import { fetchWorkshopPageDetails, persistWorkshopPageDetails } from '../../services/workshopClient'; +import { fetchWorkshopPageDetails, getWorkshopPageSnapshot, persistWorkshopPageDetails } from '../../services/workshopClient'; import { resolveWorkshopItemAuthor } from './authorDirectory'; +import { isKnownWorkshopItem } from '../../utils/workshopKnown'; interface WorkshopDetailModalProps { open: boolean; @@ -65,7 +66,12 @@ export const WorkshopDetailModal: React.FC = ({ // Fetch extra details by scraping the workshop page HTML const fetchPageDetails = useCallback(async (workshopId: string) => { setPageDetailsLoading(true); - setPageDetails(null); + const snapshot = await getWorkshopPageSnapshot(workshopId); + if (snapshot) { + setPageDetails(snapshot); + } else { + setPageDetails(null); + } try { const details = await fetchWorkshopPageDetails(workshopId, 'workshop-detail'); setPageDetails(details); @@ -182,8 +188,8 @@ export const WorkshopDetailModal: React.FC = ({
{collection.items.map((ci) => { - const isKnown = !!knownUninstalledAddons[ci.workshopId + '.vpk']; - const isDownloaded = !!addons[ci.workshopId + '.vpk']; + const isKnown = isKnownWorkshopItem(knownUninstalledAddons, ci.workshopId); + const isDownloaded = isKnownWorkshopItem(addons, ci.workshopId); const shouldCacheRemote = isDownloaded || isKnown; return (
= ({ // ── Item detail ────────────────────────────────────────────────────────────── if (!item) return null; - const vpkKey = item.workshopId + '.vpk'; - const isKnown = !!knownUninstalledAddons[vpkKey]; - const isDownloaded = !!addons[vpkKey]; + const isKnown = isKnownWorkshopItem(knownUninstalledAddons, item.workshopId); + const isDownloaded = isKnownWorkshopItem(addons, item.workshopId); const downloading = downloadProgress[item.workshopId] !== undefined; const progress = downloadProgress[item.workshopId]; const shouldCacheRemote = isDownloaded || isKnown; @@ -261,7 +266,13 @@ export const WorkshopDetailModal: React.FC = ({ const displayAuthorUrl = pageDetails?.creatorProfileUrl || resolvedItem.authorUrl; // Find group this addon belongs to - const itemGroup = groups?.find(g => g.addons.includes(vpkKey)); + const itemGroup = groups?.find(g => ( + g.addons.includes(item.workshopId) || + g.addons.some((addonId) => { + const addon = addons[addonId] || knownUninstalledAddons[addonId]; + return addon?.workshopId === item.workshopId; + }) + )); // Build gallery: cover image first, then scraped screenshots const scrapedGallery = pageDetails?.imageGallery || []; diff --git a/src/components/workshop/authorDirectory.test.ts b/src/components/workshop/authorDirectory.test.ts index b9ccb48..13fc2f8 100644 --- a/src/components/workshop/authorDirectory.test.ts +++ b/src/components/workshop/authorDirectory.test.ts @@ -100,4 +100,26 @@ describe('authorDirectory', () => { expect(resolved.authorName).toBe('Page Author'); expect(resolved.authorUrl).toBe('https://steamcommunity.com/profiles/76561198000000002'); }); + + test('matches Steam account id to SteamID64 for page author mappings', () => { + rememberWorkshopPageDetails({ + creatorName: 'perfect_buddy', + creatorProfileUrl: 'https://steamcommunity.com/id/perfectbuddy', + creatorAccountId: '51754853', + imageGallery: [], + tags: [], + requiredItems: [], + parentCollections: [], + }); + + const resolved = resolveWorkshopItemAuthor(createItem({ + workshopId: '105', + authorName: '76561198012020581', + authorId: '76561198012020581', + authorSteamId: '76561198012020581', + })); + + expect(resolved.authorName).toBe('perfect_buddy'); + expect(resolved.authorUrl).toBe('https://steamcommunity.com/id/perfectbuddy'); + }); }); diff --git a/src/components/workshop/authorDirectory.ts b/src/components/workshop/authorDirectory.ts index 5b9b6d8..bcd12ee 100644 --- a/src/components/workshop/authorDirectory.ts +++ b/src/components/workshop/authorDirectory.ts @@ -47,6 +47,20 @@ function isNumericIdentifier(value?: string | null): boolean { return /^\d+$/.test(normalizeValue(value)); } +function isSteamId64(value?: string | null): boolean { + return /^\d{17,20}$/.test(normalizeValue(value)); +} + +function accountIdToSteamId(accountId?: string | null): string { + const normalized = normalizeValue(accountId); + if (!/^\d+$/.test(normalized)) return ''; + try { + return (BigInt(normalized) + 76561197960265728n).toString(); + } catch { + return ''; + } +} + function looksLikePlaceholderName(name?: string | null, ids: string[] = []): boolean { const trimmed = normalizeValue(name); if (!trimmed) return true; @@ -60,7 +74,13 @@ function looksLikePlaceholderName(name?: string | null, ids: string[] = []): boo function collectIdentity(input: AuthorIdentity): Required { const parsedFromUrl = parseProfileIdentifiers(input.authorUrl); const authorId = normalizeValue(input.authorId); - const authorSteamId = normalizeValue(input.authorSteamId || input.ownerSteamId || (isNumericIdentifier(authorId) ? authorId : '')); + const accountId = normalizeValue(input.authorAccountId || input.ownerAccountId); + const authorSteamId = normalizeValue( + input.authorSteamId + || input.ownerSteamId + || (isSteamId64(authorId) ? authorId : '') + || accountIdToSteamId(accountId), + ); const authorVanityId = normalizeValue( input.authorVanityId || (!isNumericIdentifier(authorId) ? authorId : '') || parsedFromUrl.vanityId, ); @@ -70,7 +90,7 @@ function collectIdentity(input: AuthorIdentity): Required { authorUrl: normalizeValue(input.authorUrl), authorSteamId: authorSteamId || normalizeValue(parsedFromUrl.steamId), authorVanityId, - authorAccountId: normalizeValue(input.authorAccountId || input.ownerAccountId), + authorAccountId: accountId, authorId, ownerSteamId: normalizeValue(input.ownerSteamId), ownerAccountId: normalizeValue(input.ownerAccountId), diff --git a/src/components/workshop/ssrParser.test.ts b/src/components/workshop/ssrParser.test.ts new file mode 100644 index 0000000..4f5b820 --- /dev/null +++ b/src/components/workshop/ssrParser.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; +import { parseWorkshopPageDetails } from './ssrParser'; + +describe('ssrParser', () => { + test('preserves Steam description line breaks from rendered HTML', () => { + const details = parseWorkshopPageDetails(` +
Early Days PART 1/6
+
+ +
perfect_buddy
Offline
+
+
+ UPDATE: Fixed things.

+
Gameplay
+ First line.
Second line. +
  • Residential
  • Downtown
+
+ `); + + expect(details.description).toContain('UPDATE: Fixed things.'); + expect(details.description).toContain('\n\nGameplay\n'); + expect(details.description).toContain('First line.\nSecond line.'); + expect(details.description).toContain('* Residential'); + expect(details.creatorName).toBe('perfect_buddy'); + expect(details.creatorSteamId).toBe('76561198012020581'); + }); +}); diff --git a/src/components/workshop/ssrParser.ts b/src/components/workshop/ssrParser.ts index e4177b7..cffa53d 100644 --- a/src/components/workshop/ssrParser.ts +++ b/src/components/workshop/ssrParser.ts @@ -39,6 +39,16 @@ function parseProfileIdentifiers(url: string): { }; } +function accountIdToSteamId(accountId?: string): string | undefined { + const normalized = (accountId || '').trim(); + if (!/^\d+$/.test(normalized)) return undefined; + try { + return (BigInt(normalized) + 76561197960265728n).toString(); + } catch { + return undefined; + } +} + // ── Helper: find balanced {…} from a start position in HTML ─────────────────── function extractBalancedJson(html: string, startIdx: number): string | null { @@ -468,6 +478,69 @@ function toFullSizeUrl(thumbUrl: string): string { return thumbUrl.replace(/\?imw=.*$/, ''); } +function appendLineBreak(output: string[]): void { + const last = output[output.length - 1] || ''; + if (!last.endsWith('\n')) { + output.push('\n'); + } +} + +function appendBlockBreak(output: string[]): void { + const text = output.join(''); + if (!text.endsWith('\n\n')) { + if (!text.endsWith('\n')) output.push('\n'); + output.push('\n'); + } +} + +function descriptionNodeToText(node: Element): string { + const blockTags = new Set(['DIV', 'P', 'UL', 'OL', 'TABLE', 'TR', 'H1', 'H2', 'H3', 'H4']); + const output: string[] = []; + + const walk = (current: Node) => { + if (current.nodeType === Node.TEXT_NODE) { + output.push(current.textContent || ''); + return; + } + if (current.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const element = current as Element; + const tag = element.tagName; + + if (tag === 'BR') { + appendLineBreak(output); + return; + } + + if (tag === 'LI') { + appendLineBreak(output); + output.push('* '); + } else if (blockTags.has(tag) && output.join('').trim()) { + appendBlockBreak(output); + } + + element.childNodes.forEach(walk); + + if (tag === 'LI') { + appendLineBreak(output); + } else if (blockTags.has(tag)) { + appendBlockBreak(output); + } + }; + + node.childNodes.forEach(walk); + + return output + .join('') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n[ \t]+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + /** Parse extra details from a Steam Workshop item/collection page HTML */ export function parseWorkshopPageDetails(html: string): WorkshopPageDetails { const result: WorkshopPageDetails = { @@ -490,7 +563,7 @@ export function parseWorkshopPageDetails(html: string): WorkshopPageDetails { const descriptionNode = doc.querySelector('.workshopItemDescription'); if (descriptionNode) { - result.description = descriptionNode.textContent?.trim() || ''; + result.description = descriptionNodeToText(descriptionNode); result.descriptionHtml = descriptionNode.innerHTML.trim(); } @@ -505,6 +578,7 @@ export function parseWorkshopPageDetails(html: string): WorkshopPageDetails { result.creatorName = creatorNameNode.childNodes[0].textContent.trim(); } result.creatorAccountId = attr('.friendBlock', 'data-miniprofile') || undefined; + result.creatorSteamId = result.creatorSteamId || accountIdToSteamId(result.creatorAccountId); const detailLabels = all('.detailsStatsContainerLeft .detailsStatLeft').map((el) => el.textContent?.trim().toLowerCase() || ''); const detailValues = all('.detailsStatsContainerRight .detailsStatRight').map((el) => el.textContent?.trim() || ''); diff --git a/src/index.css b/src/index.css index 6bbce64..638c1a0 100644 --- a/src/index.css +++ b/src/index.css @@ -442,6 +442,7 @@ body { -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; + white-space: pre-line; margin-top: 4px; } diff --git a/src/services/workshopClient.test.ts b/src/services/workshopClient.test.ts index d5d5053..1228b33 100644 --- a/src/services/workshopClient.test.ts +++ b/src/services/workshopClient.test.ts @@ -231,6 +231,51 @@ describe('workshopClient', () => { expect(mockInvoke).not.toHaveBeenCalledWith('fetch_workshop_html', expect.anything()); }); + test('fetchWorkshopItems enriches SDK placeholder author from snapshot cache', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { + workshopId: '12345', + title: 'Cached Item', + creatorName: 'Cached Author', + creatorSteamId: '76561198000000001', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + }, + }); + } + if (cmd === 'query_workshop_items') { + return Promise.resolve({ + source: 'steam-sdk', + items: [{ + publishedfileid: '12345', + title: 'SDK Item', + creator: '76561198000000001', + creator_steam_id: '76561198000000001', + creator_name: '76561198000000001', + }], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const result = await workshopClient.fetchWorkshopItems({ query: 'tank' }); + + expect(result.source).toBe('steam-sdk'); + expect(result.items[0]).toMatchObject({ + authorName: 'Cached Author', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + }); + }); + test('mapSteamDetailToWorkshopItem keeps 64-bit creator id and converts score to stars', async () => { const workshopClient = await import('./workshopClient'); const item = workshopClient.mapSteamDetailToWorkshopItem({ diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index ecf71ae..36f28b7 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -3,6 +3,7 @@ import { HomepageSection, WorkshopCapabilities, WorkshopItem, + WorkshopPageDetails, } from '../components/workshop/types'; import type { WorkshopSourceSettings } from '../types/addon'; import { @@ -14,6 +15,7 @@ import { import { rememberWorkshopItems, rememberWorkshopPageDetails, + resolveWorkshopItemAuthor, } from '../components/workshop/authorDirectory'; interface WorkshopHomeResponse { @@ -136,7 +138,11 @@ function readCachedValue(key: string): T | null { } async function readWorkshopItemCache(): Promise> { - return invoke>('get_workshop_cache').catch(() => ({})); + try { + return await invoke>('get_workshop_cache'); + } catch { + return {}; + } } function cachedDetailToWorkshopItem(workshopId: string, detail: any): WorkshopItem { @@ -151,6 +157,172 @@ function cachedDetailToWorkshopItem(workshopId: string, detail: any): WorkshopIt }, detail?.lastSeenSource || detail?.lastPageSource || 'cache'); } +function normalizeCachedTags(tags: any): { category: string; name: string }[] { + if (!Array.isArray(tags)) return []; + return tags + .map((tag) => { + if (typeof tag === 'string') { + return { category: '', name: tag }; + } + return { + category: normalizeText(tag?.category), + name: normalizeText(tag?.name || tag?.display_name || tag?.tag), + }; + }) + .filter((tag) => tag.name); +} + +function normalizeCachedRelations(value: any): { title: string; workshopId: string }[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => ({ + title: normalizeText(item?.title), + workshopId: normalizeText(item?.workshopId || item?.publishedfileid), + })) + .filter((item) => item.workshopId); +} + +function mapCachedDetailToPageDetails(detail: any): WorkshopPageDetails { + const steamDetails = detail?.steamDetails || {}; + const gallery = detail?.imageGallery || detail?.galleryUrls || detail?.galleryPreviewUrls || []; + return { + title: normalizeText(detail?.title || steamDetails?.title) || undefined, + previewUrl: normalizeText(detail?.previewUrl || detail?.imagePath || detail?.preview_url || steamDetails?.preview_url) || undefined, + description: normalizeText(detail?.description || steamDetails?.description || detail?.shortDescription) || undefined, + descriptionHtml: normalizeText(detail?.descriptionHtml) || undefined, + creatorName: normalizeText(detail?.creatorName || detail?.authorName || detail?.creator_name || steamDetails?.creator_name) || undefined, + creatorProfileUrl: normalizeText(detail?.creatorProfileUrl || detail?.authorUrl) || undefined, + creatorSteamId: normalizeText(detail?.creatorSteamId || detail?.creator_steam_id || steamDetails?.creator) || undefined, + creatorVanityId: normalizeText(detail?.creatorVanityId || steamDetails?.creator_vanity_id) || undefined, + creatorAccountId: normalizeText(detail?.creatorAccountId || detail?.creator_account_id || steamDetails?.creator_account_id) || undefined, + imageGallery: Array.isArray(gallery) ? gallery.map(String).filter(Boolean) : [], + tags: normalizeCachedTags(detail?.pageTags || detail?.tags || steamDetails?.tags), + requiredItems: normalizeCachedRelations(detail?.requiredItems), + parentCollections: normalizeCachedRelations(detail?.parentCollections), + fileSizeDisplay: normalizeText(detail?.fileSizeDisplay || detail?.fileSize || steamDetails?.file_size) || undefined, + postedDateText: normalizeText(detail?.postedDateText) || undefined, + updatedDateText: normalizeText(detail?.updatedDateText) || undefined, + changeNoteCount: toNumericOrUndefined(detail?.changeNoteCount), + ratingStars: toNumericOrUndefined(detail?.ratingStars), + ratingCount: toNumericOrUndefined(detail?.ratingCount), + uniqueVisitors: toNumericOrUndefined(detail?.uniqueVisitors), + currentSubscribers: toNumericOrUndefined(detail?.currentSubscribers), + currentFavorites: toNumericOrUndefined(detail?.currentFavorites), + backgroundImageUrl: normalizeText(detail?.backgroundImageUrl) || undefined, + }; +} + +function normalizePageDetails(details: Partial | null | undefined): WorkshopPageDetails { + return { + ...details, + imageGallery: Array.isArray(details?.imageGallery) ? details.imageGallery.filter(Boolean) : [], + tags: Array.isArray(details?.tags) ? details.tags.filter((tag) => normalizeText(tag?.name)) : [], + requiredItems: Array.isArray(details?.requiredItems) + ? details.requiredItems.filter((item) => normalizeText(item?.workshopId)) + : [], + parentCollections: Array.isArray(details?.parentCollections) + ? details.parentCollections.filter((item) => normalizeText(item?.workshopId)) + : [], + }; +} + +function chooseDescription(current?: string, incoming?: string): string | undefined { + const existing = normalizeText(current); + const next = normalizeText(incoming); + return existing || next || undefined; +} + +function chooseArray(current: T[] | undefined, incoming: T[] | undefined): T[] { + const existing = Array.isArray(current) ? current : []; + const next = Array.isArray(incoming) ? incoming : []; + return next.length > 0 ? next : existing; +} + +function chooseNumber(current: number | undefined, incoming: number | undefined): number | undefined { + return incoming !== undefined ? incoming : current; +} + +function resolvePageDetailsAuthor(details: WorkshopPageDetails): WorkshopPageDetails { + const resolved = resolveWorkshopItemAuthor({ + workshopId: '', + title: details.title || '', + imagePath: details.previewUrl || '', + authorName: details.creatorName || '', + authorId: details.creatorSteamId || details.creatorVanityId || details.creatorAccountId || '', + authorUrl: details.creatorProfileUrl || '', + authorSteamId: details.creatorSteamId, + authorVanityId: details.creatorVanityId, + authorAccountId: details.creatorAccountId, + stars: 0, + }); + + return { + ...details, + creatorName: resolved.authorName || details.creatorName, + creatorProfileUrl: resolved.authorUrl || details.creatorProfileUrl, + creatorSteamId: resolved.authorSteamId || details.creatorSteamId, + creatorVanityId: resolved.authorVanityId || details.creatorVanityId, + creatorAccountId: resolved.authorAccountId || details.creatorAccountId, + }; +} + +export function mergeWorkshopPageDetails( + current: Partial | null | undefined, + incoming: Partial | null | undefined, +): WorkshopPageDetails { + const existing = normalizePageDetails(current); + const next = normalizePageDetails(incoming); + const merged = resolvePageDetailsAuthor(normalizePageDetails({ + title: normalizeText(next.title) || existing.title, + previewUrl: normalizeText(next.previewUrl) || existing.previewUrl, + description: chooseDescription(existing.description, next.description), + descriptionHtml: normalizeText(next.descriptionHtml) || existing.descriptionHtml, + creatorName: normalizeText(next.creatorName) || existing.creatorName, + creatorProfileUrl: normalizeText(next.creatorProfileUrl) || existing.creatorProfileUrl, + creatorSteamId: normalizeText(next.creatorSteamId) || existing.creatorSteamId, + creatorVanityId: normalizeText(next.creatorVanityId) || existing.creatorVanityId, + creatorAccountId: normalizeText(next.creatorAccountId) || existing.creatorAccountId, + imageGallery: chooseArray(existing.imageGallery, next.imageGallery), + tags: chooseArray(existing.tags, next.tags), + requiredItems: chooseArray(existing.requiredItems, next.requiredItems), + parentCollections: chooseArray(existing.parentCollections, next.parentCollections), + fileSizeDisplay: normalizeText(next.fileSizeDisplay) || existing.fileSizeDisplay, + postedDateText: normalizeText(next.postedDateText) || existing.postedDateText, + updatedDateText: normalizeText(next.updatedDateText) || existing.updatedDateText, + changeNoteCount: chooseNumber(existing.changeNoteCount, next.changeNoteCount), + ratingStars: chooseNumber(existing.ratingStars, next.ratingStars), + ratingCount: chooseNumber(existing.ratingCount, next.ratingCount), + uniqueVisitors: chooseNumber(existing.uniqueVisitors, next.uniqueVisitors), + currentSubscribers: chooseNumber(existing.currentSubscribers, next.currentSubscribers), + currentFavorites: chooseNumber(existing.currentFavorites, next.currentFavorites), + backgroundImageUrl: normalizeText(next.backgroundImageUrl) || existing.backgroundImageUrl, + })); + rememberWorkshopPageDetails(merged); + return merged; +} + +function hasUsefulPageSnapshot(details: WorkshopPageDetails): boolean { + return Boolean( + details.title || + details.creatorName || + details.description || + details.imageGallery.length > 0 || + details.requiredItems.length > 0 || + details.parentCollections.length > 0 || + details.tags.length > 0, + ); +} + +async function enrichItemsFromSnapshot(items: WorkshopItem[]): Promise { + if (items.length === 0) return items; + const cache = await readWorkshopItemCache(); + if (Object.keys(cache).length === 0) return items; + const cachedItems = items + .map((item) => cache[item.workshopId] ? cachedDetailToWorkshopItem(item.workshopId, cache[item.workshopId]) : null) + .filter((item): item is WorkshopItem => Boolean(item)); + return enrichWorkshopItems(items, cachedItems); +} + function resolveBrowseSort(input: FetchWorkshopItemsInput): string | undefined { if (input.query?.trim()) { return input.sort || 'textsearch'; @@ -177,10 +349,21 @@ function normalizeText(value: unknown): string { return String(value || '').trim(); } +function accountIdToSteamId(accountId: unknown): string { + const normalized = normalizeText(accountId); + if (!/^\d+$/.test(normalized)) return ''; + try { + return (BigInt(normalized) + 76561197960265728n).toString(); + } catch { + return ''; + } +} + function isPlaceholderAuthorName(name: unknown, ids: string[]): boolean { const normalized = normalizeText(name); if (!normalized) return true; const lowered = normalized.toLowerCase(); + if (/^\d+$/.test(normalized)) return true; return ids.some((id) => lowered === normalizeText(id).toLowerCase()); } @@ -214,7 +397,13 @@ function mapSteamScoreToStars(detail: any): number { } function needsHtmlAuthorEnrichment(item: WorkshopItem): boolean { - return !normalizeText(item.authorName) || !normalizeText(item.authorUrl); + return isPlaceholderAuthorName(item.authorName, [ + item.authorSteamId || '', + item.authorAccountId || '', + item.authorId || '', + item.ownerSteamId || '', + item.ownerAccountId || '', + ]) || !normalizeText(item.authorUrl); } function enrichWorkshopItems(primary: WorkshopItem[], fallback: WorkshopItem[]): WorkshopItem[] { @@ -226,9 +415,26 @@ function enrichWorkshopItems(primary: WorkshopItem[], fallback: WorkshopItem[]): return item; } + const primaryAuthorIsPlaceholder = isPlaceholderAuthorName(item.authorName, [ + item.authorSteamId || '', + item.authorAccountId || '', + item.authorId || '', + item.ownerSteamId || '', + item.ownerAccountId || '', + ]); + const fallbackAuthorIsUsable = !isPlaceholderAuthorName(fallbackItem.authorName, [ + fallbackItem.authorSteamId || '', + fallbackItem.authorAccountId || '', + fallbackItem.authorId || '', + fallbackItem.ownerSteamId || '', + fallbackItem.ownerAccountId || '', + ]); + return { ...item, - authorName: normalizeText(item.authorName) || fallbackItem.authorName, + authorName: primaryAuthorIsPlaceholder && fallbackAuthorIsUsable + ? fallbackItem.authorName + : normalizeText(item.authorName) || fallbackItem.authorName, authorId: item.authorId || fallbackItem.authorId, authorUrl: normalizeText(item.authorUrl) || fallbackItem.authorUrl, authorSteamId: item.authorSteamId || fallbackItem.authorSteamId, @@ -238,7 +444,7 @@ function enrichWorkshopItems(primary: WorkshopItem[], fallback: WorkshopItem[]): ownerAccountId: item.ownerAccountId || fallbackItem.ownerAccountId, stars: item.stars > 0 ? item.stars : fallbackItem.stars, imagePath: item.imagePath || fallbackItem.imagePath, - shortDescription: item.shortDescription || fallbackItem.shortDescription, + shortDescription: chooseDescription(item.shortDescription, fallbackItem.shortDescription), }; }); } @@ -297,6 +503,10 @@ export async function fetchWorkshopHome() { ), browseParams: section.browseParams, })); + sdkSections = await Promise.all(sdkSections.map(async (section) => ({ + ...section, + items: rememberWorkshopItems(await enrichItemsFromSnapshot(section.items)), + }))); source = data.source; } catch (err) { console.warn('Steam SDK home query failed, falling back to HTML:', err); @@ -319,10 +529,10 @@ export async function fetchWorkshopHome() { url: 'https://steamcommunity.com/app/550/workshop/', source: 'workshop-home', }); - const htmlSections = parseHomepageSections(html).map((section) => ({ + const htmlSections = await Promise.all(parseHomepageSections(html).map(async (section) => ({ ...section, - items: rememberWorkshopItems(section.items), - })); + items: rememberWorkshopItems(await enrichItemsFromSnapshot(section.items)), + }))); const tagCategories = parseTagCategories(html); const result = { source: 'web-fallback', @@ -340,10 +550,10 @@ export async function fetchWorkshopHome() { url: 'https://steamcommunity.com/app/550/workshop/', source: 'workshop-home', }); - htmlSections = parseHomepageSections(html).map((section) => ({ + htmlSections = await Promise.all(parseHomepageSections(html).map(async (section) => ({ ...section, - items: rememberWorkshopItems(section.items), - })); + items: rememberWorkshopItems(await enrichItemsFromSnapshot(section.items)), + }))); tagCategories = parseTagCategories(html); } catch (err) { console.warn('Workshop home HTML enrichment failed:', err); @@ -382,6 +592,7 @@ export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { }); reportWorkshopWarnings(data.warnings); let items = data.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source)); + items = await enrichItemsFromSnapshot(items); if (items.some(needsHtmlAuthorEnrichment) && shouldUseSteamCommunityHtml(capabilities)) { try { const html: string = await invoke('fetch_workshop_html', { @@ -417,9 +628,10 @@ export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { url, source: input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', }); + const items = await enrichItemsFromSnapshot(parseSSRItems(html, 'workshop_query')); const result = { source: 'web-fallback', - items: rememberWorkshopItems(parseSSRItems(html, 'workshop_query')), + items: rememberWorkshopItems(items), }; writeListCache(cacheKey, result); return result; @@ -431,6 +643,14 @@ export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { } export async function fetchWorkshopItem(workshopId: string) { + const cache = await readWorkshopItemCache(); + if (cache[workshopId]) { + return { + source: 'snapshot', + item: rememberWorkshopItems([cachedDetailToWorkshopItem(workshopId, cache[workshopId])])[0], + }; + } + const capabilities = shouldUseSteamworksSdk() ? await getWorkshopCapabilities().catch(() => null) : null; @@ -454,7 +674,6 @@ export async function fetchWorkshopItem(workshopId: string) { item: rememberWorkshopItems([mapSteamDetailToWorkshopItem(data.collection, 'web-fallback')])[0], }; } catch (err) { - const cache = await readWorkshopItemCache(); if (cache[workshopId]) { return { source: 'cache', @@ -466,6 +685,31 @@ export async function fetchWorkshopItem(workshopId: string) { } export async function fetchWorkshopCollection(workshopId: string) { + const cache = await readWorkshopItemCache(); + const cachedCollection = cache[workshopId]; + if (cachedCollection) { + const resolvedCollection = cachedDetailToWorkshopItem(workshopId, cachedCollection); + const childIds = cachedCollection.childItemIds || []; + return { + source: 'snapshot', + collection: { + ...cachedCollection, + publishedfileid: workshopId, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + childIds + .map((childId: string) => cache[childId] ? cachedDetailToWorkshopItem(childId, cache[childId]) : null) + .filter(Boolean), + ), + }; + } + const capabilities = shouldUseSteamworksSdk() ? await getWorkshopCapabilities().catch(() => null) : null; @@ -488,7 +732,7 @@ export async function fetchWorkshopCollection(workshopId: string) { creator_account_id: resolvedCollection.authorAccountId, }, items: rememberWorkshopItems( - data.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source)), + await enrichItemsFromSnapshot(data.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source))), ), }; } catch (err) { @@ -513,11 +757,10 @@ export async function fetchWorkshopCollection(workshopId: string) { creator_account_id: resolvedCollection.authorAccountId, }, items: rememberWorkshopItems( - (data.items || []).map((item: any) => mapSteamDetailToWorkshopItem(item, 'web-fallback')), + await enrichItemsFromSnapshot((data.items || []).map((item: any) => mapSteamDetailToWorkshopItem(item, 'web-fallback'))), ), }; } catch (err) { - const cache = await readWorkshopItemCache(); const collection = cache[workshopId]; if (collection) { const resolvedCollection = cachedDetailToWorkshopItem(workshopId, collection); @@ -551,10 +794,24 @@ export async function fetchWorkshopHtml(url: string, source: string) { export async function fetchWorkshopPageDetails(workshopId: string, source: string) { const url = `https://steamcommunity.com/sharedfiles/filedetails/?id=${workshopId}`; - const html = await fetchWorkshopHtml(url, source); - const details = parseWorkshopPageDetails(html); - rememberWorkshopPageDetails(details); - return details; + const snapshot = await getWorkshopPageSnapshot(workshopId); + try { + const html = await fetchWorkshopHtml(url, source); + const details = mergeWorkshopPageDetails(snapshot, parseWorkshopPageDetails(html)); + return details; + } catch (err) { + if (snapshot) return snapshot; + throw err; + } +} + +export async function getWorkshopPageSnapshot(workshopId: string): Promise { + const cache = await readWorkshopItemCache(); + const detail = cache[workshopId]; + if (!detail) return null; + const snapshot = mergeWorkshopPageDetails(null, mapCachedDetailToPageDetails(detail)); + if (!hasUsefulPageSnapshot(snapshot)) return null; + return snapshot; } export async function persistWorkshopPageDetails(workshopId: string, details: any, source: string) { @@ -589,6 +846,7 @@ export function mapSteamDetailToWorkshopItem(detail: any, source = 'steam-sdk'): detail.owner_steam_id || detail.ownerSteamId || (detail.creator && /^\d{17,20}$/.test(String(detail.creator)) ? detail.creator : '') || + accountIdToSteamId(detail.creator_account_id || detail.creatorAccountId || detail.owner_account_id || detail.ownerAccountId) || '', ); const ownerAccountId = normalizeText( diff --git a/src/utils/workshopKnown.ts b/src/utils/workshopKnown.ts new file mode 100644 index 0000000..ee9f0c3 --- /dev/null +++ b/src/utils/workshopKnown.ts @@ -0,0 +1,36 @@ +type WorkshopLike = { + id?: string; + vpkName?: string; + workshopId?: string; +}; + +function normalizeId(value: unknown): string { + return String(value || '').trim(); +} + +export function findKnownWorkshopEntry( + entries: Record | undefined, + workshopId: string | undefined, +): T | undefined { + const id = normalizeId(workshopId); + if (!entries || !id) return undefined; + + // Read legacy ".vpk" keys, but keep workshop identity keyed by the raw + // publishedfileid everywhere new code can control. + const direct = entries[id] || entries[`${id}.vpk`]; + if (direct) return direct; + + return Object.values(entries).find((entry) => ( + normalizeId(entry?.workshopId) === id || + normalizeId(entry?.id) === id || + normalizeId(entry?.id) === `${id}.vpk` || + normalizeId(entry?.vpkName) === `${id}.vpk` + )); +} + +export function isKnownWorkshopItem( + entries: Record | undefined, + workshopId: string | undefined, +): boolean { + return Boolean(findKnownWorkshopEntry(entries, workshopId)); +} From 52a5aa6cab292ac48b03ac64a31015593d0845f7 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 05:00:32 +0800 Subject: [PATCH 43/81] Fix workshop collection snapshots and cache schema --- src-tauri/src/commands/handlers.rs | 605 +++++++++++++++++- .../workshop/WorkshopDetailModal.tsx | 16 +- src/components/workshop/ssrParser.test.ts | 56 ++ src/components/workshop/ssrParser.ts | 34 + src/components/workshop/types.ts | 2 + src/services/workshopClient.ts | 112 ++-- 6 files changed, 762 insertions(+), 63 deletions(-) diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 947cefc..d9bdf8e 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -508,6 +508,18 @@ fn persist_workshop_page_details_entry( if let Some(required) = details.get("requiredItems").cloned() { obj.insert("requiredItems".to_string(), required); } + if let Some(child_ids) = details.get("childItemIds").cloned() { + obj.insert("childItemIds".to_string(), child_ids); + } else if let Some(collection_items) = details.get("collectionItems").and_then(|v| v.as_array()) + { + let child_ids: Vec = collection_items + .iter() + .filter_map(|item| item.get("workshopId").and_then(|v| v.as_str())) + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + insert_optional_vec_string(obj, "childItemIds", Some(child_ids)); + } if let Some(parent_collections) = details.get("parentCollections").cloned() { obj.insert("parentCollections".to_string(), parent_collections); } @@ -901,28 +913,89 @@ pub fn save_db_internal( } fn load_workshop_cache(cache_path: &Path) -> HashMap { + let (items, authors) = load_workshop_cache_document(cache_path); + let mut items = items; + expand_author_directory_into_cache(&mut items, &authors); + expand_cached_item_compat_fields(&mut items); + items +} + +fn load_workshop_cache_document( + cache_path: &Path, +) -> ( + HashMap, + HashMap, +) { if !cache_path.exists() { - return HashMap::new(); + return (HashMap::new(), HashMap::new()); } let Ok(content) = fs::read_to_string(cache_path) else { - return HashMap::new(); + return (HashMap::new(), HashMap::new()); }; - if let Ok(map) = serde_json::from_str::>(&content) { - return map; - } + let Ok(value) = serde_json::from_str::(&content) else { + return (HashMap::new(), HashMap::new()); + }; - if let Ok(value) = serde_json::from_str::(&content) { - if let Some(items) = value.get("items").and_then(|v| v.as_object()) { - return items + let authors = value + .get("authors") + .and_then(|v| v.as_object()) + .map(|authors| { + authors .iter() .map(|(key, value)| (key.clone(), value.clone())) - .collect(); - } + .collect() + }) + .unwrap_or_default(); + + (workshop_cache_items_from_value(&value), authors) +} + +fn workshop_cache_items_from_value( + value: &serde_json::Value, +) -> HashMap { + let mut items = HashMap::new(); + if let Some(obj) = value.as_object() { + collect_workshop_cache_items(obj, &mut items); } + items +} - HashMap::new() +fn collect_workshop_cache_items( + obj: &serde_json::Map, + items: &mut HashMap, +) { + for (key, value) in obj { + if key == "schemaVersion" || key == "authors" { + continue; + } + if key == "items" { + if let Some(child_obj) = value.as_object() { + collect_workshop_cache_items(child_obj, items); + } + continue; + } + + let Some(value_obj) = value.as_object() else { + continue; + }; + let workshop_id = value_obj + .get("workshopId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .or_else(|| { + key.chars() + .all(|c| c.is_ascii_digit()) + .then(|| key.to_string()) + }); + + if let Some(workshop_id) = workshop_id { + items.insert(workshop_id, value.clone()); + } + } } fn json_string(value: &serde_json::Value, key: &str) -> Option { @@ -1040,6 +1113,12 @@ fn load_known_addons_as_workshop_cache( fn author_identity_values(obj: &serde_json::Map) -> HashSet { let mut ids = HashSet::new(); + if let Some(value) = obj.get("authorKey").and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + ids.insert(trimmed.to_lowercase()); + } + } for key in [ "creatorSteamId", "creatorAccountId", @@ -1082,6 +1161,299 @@ fn author_identity_values(obj: &serde_json::Map) -> H ids } +fn object_string(obj: &serde_json::Map, key: &str) -> Option { + obj.get(key) + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn profile_steam_id(url: &str) -> Option { + url.split("/profiles/") + .nth(1) + .and_then(|s| s.split('/').next()) + .map(str::trim) + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty()) + .map(str::to_string) +} + +fn profile_vanity_id(url: &str) -> Option { + url.split("/id/") + .nth(1) + .and_then(|s| s.split('/').next()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +fn author_directory_key( + steam_id: Option<&str>, + account_id: Option<&str>, + vanity_id: Option<&str>, + profile_url: Option<&str>, +) -> Option { + if let Some(steam_id) = steam_id + .map(str::trim) + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty()) + { + return Some(steam_id.to_string()); + } + if let Some(steam_id) = account_id.and_then(account_id_to_steam_id) { + return Some(steam_id); + } + if let Some(profile_steam_id) = profile_url.and_then(profile_steam_id) { + return Some(profile_steam_id); + } + if let Some(vanity_id) = vanity_id.map(str::trim).filter(|id| !id.is_empty()) { + return Some(format!("vanity:{}", vanity_id.to_lowercase())); + } + if let Some(profile_vanity_id) = profile_url.and_then(profile_vanity_id) { + return Some(format!("vanity:{}", profile_vanity_id.to_lowercase())); + } + profile_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(|url| format!("url:{}", url.to_lowercase())) +} + +fn author_key_for_item(obj: &serde_json::Map) -> Option { + object_string(obj, "authorKey").or_else(|| { + let profile_url = + object_string(obj, "creatorProfileUrl").or_else(|| object_string(obj, "authorUrl")); + author_directory_key( + object_string(obj, "creatorSteamId") + .or_else(|| object_string(obj, "authorSteamId")) + .or_else(|| { + object_string(obj, "creatorId") + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && id.len() >= 16) + }) + .as_deref(), + object_string(obj, "creatorAccountId") + .or_else(|| object_string(obj, "authorAccountId")) + .as_deref(), + object_string(obj, "creatorVanityId") + .or_else(|| object_string(obj, "authorVanityId")) + .as_deref(), + profile_url.as_deref(), + ) + }) +} + +fn author_identity_values_from_entry( + key: &str, + obj: &serde_json::Map, +) -> HashSet { + let mut ids = HashSet::from([key.to_lowercase()]); + for value in [ + object_string(obj, "steamId"), + object_string(obj, "accountId"), + object_string(obj, "vanityId"), + object_string(obj, "profileUrl"), + ] + .into_iter() + .flatten() + { + ids.insert(value.to_lowercase()); + if let Some(steam_id) = account_id_to_steam_id(&value) { + ids.insert(steam_id); + } + if let Some(steam_id) = profile_steam_id(&value) { + ids.insert(steam_id.to_lowercase()); + } + if let Some(vanity_id) = profile_vanity_id(&value) { + ids.insert(vanity_id.to_lowercase()); + ids.insert(format!("vanity:{}", vanity_id.to_lowercase())); + } + } + ids +} + +fn collect_author_directory( + cache: &HashMap, +) -> HashMap { + let mut authors: HashMap = HashMap::new(); + + for value in cache.values() { + let Some(obj) = value.as_object() else { + continue; + }; + let profile_url = + object_string(obj, "creatorProfileUrl").or_else(|| object_string(obj, "authorUrl")); + let steam_id = object_string(obj, "creatorSteamId") + .or_else(|| object_string(obj, "authorSteamId")) + .or_else(|| profile_url.as_deref().and_then(profile_steam_id)) + .or_else(|| { + object_string(obj, "creatorId") + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && id.len() >= 16) + }); + let account_id = object_string(obj, "creatorAccountId") + .or_else(|| object_string(obj, "authorAccountId")); + let vanity_id = object_string(obj, "creatorVanityId") + .or_else(|| object_string(obj, "authorVanityId")) + .or_else(|| profile_url.as_deref().and_then(profile_vanity_id)); + let Some(key) = author_directory_key( + steam_id.as_deref(), + account_id.as_deref(), + vanity_id.as_deref(), + profile_url.as_deref(), + ) else { + continue; + }; + + let mut entry = authors + .remove(&key) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + insert_non_empty_string(&mut entry, "key", &key); + if let Some(steam_id) = steam_id.as_deref() { + insert_non_empty_string(&mut entry, "steamId", steam_id); + } + if let Some(account_id) = account_id.as_deref() { + insert_non_empty_string(&mut entry, "accountId", account_id); + if !entry.contains_key("steamId") { + if let Some(steam_id) = account_id_to_steam_id(account_id) { + insert_non_empty_string(&mut entry, "steamId", &steam_id); + } + } + } + if let Some(vanity_id) = vanity_id.as_deref() { + insert_non_empty_string(&mut entry, "vanityId", vanity_id); + } + if let Some(profile_url) = profile_url.as_deref() { + insert_non_empty_string(&mut entry, "profileUrl", profile_url); + } + + let ids = author_identity_values(obj); + let ids_vec = ids.iter().cloned().collect::>(); + let name = object_string(obj, "creatorName").or_else(|| object_string(obj, "authorName")); + if let Some(name) = name.filter(|name| !looks_like_placeholder_author_name(name, &ids_vec)) + { + insert_non_empty_string(&mut entry, "name", &name); + } + authors.insert(key, serde_json::Value::Object(entry)); + } + + authors +} + +fn expand_author_directory_into_cache( + cache: &mut HashMap, + authors: &HashMap, +) { + if authors.is_empty() { + return; + } + let author_entries: Vec<( + String, + serde_json::Map, + HashSet, + )> = authors + .iter() + .filter_map(|(key, value)| { + let obj = value.as_object()?.clone(); + let ids = author_identity_values_from_entry(key, &obj); + Some((key.clone(), obj, ids)) + }) + .collect(); + + for value in cache.values_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + let ids = author_identity_values(obj); + let matched = author_key_for_item(obj) + .and_then(|key| { + author_entries + .iter() + .find(|(author_key, _, _)| author_key.eq_ignore_ascii_case(&key)) + }) + .or_else(|| { + author_entries + .iter() + .find(|(_, _, author_ids)| !ids.is_disjoint(author_ids)) + }); + + let Some((key, author, _)) = matched else { + continue; + }; + insert_non_empty_string(obj, "authorKey", key); + if let Some(name) = object_string(author, "name") { + insert_non_empty_string(obj, "creatorName", &name); + insert_non_empty_string(obj, "authorName", &name); + } + if let Some(profile_url) = object_string(author, "profileUrl") { + insert_non_empty_string(obj, "creatorProfileUrl", &profile_url); + insert_non_empty_string(obj, "authorUrl", &profile_url); + } + if let Some(steam_id) = object_string(author, "steamId") { + insert_non_empty_string(obj, "creatorSteamId", &steam_id); + if obj + .get("creatorId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .is_none() + { + insert_non_empty_string(obj, "creatorId", &steam_id); + } + } + if let Some(account_id) = object_string(author, "accountId") { + insert_non_empty_string(obj, "creatorAccountId", &account_id); + } + if let Some(vanity_id) = object_string(author, "vanityId") { + insert_non_empty_string(obj, "creatorVanityId", &vanity_id); + } + } +} + +fn expand_cached_item_compat_fields(cache: &mut HashMap) { + for value in cache.values_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + if !obj.contains_key("shortDescription") { + if let Some(description) = object_string(obj, "description") { + insert_non_empty_string(obj, "shortDescription", &description); + } + } + if !obj.contains_key("galleryUrls") { + if let Some(gallery) = obj.get("imageGallery").cloned() { + obj.insert("galleryUrls".to_string(), gallery); + } + } + } +} + +fn compact_workshop_cache_item_for_save( + value: &serde_json::Value, + authors: &HashMap, +) -> serde_json::Value { + let Some(source_obj) = value.as_object() else { + return value.clone(); + }; + let mut obj = source_obj.clone(); + if let Some(key) = author_key_for_item(&obj) { + if authors.contains_key(&key) { + insert_non_empty_string(&mut obj, "authorKey", &key); + obj.remove("creatorName"); + obj.remove("authorName"); + obj.remove("creatorProfileUrl"); + obj.remove("authorUrl"); + } + } + + let description = object_string(&obj, "description"); + let short_description = object_string(&obj, "shortDescription"); + if description.is_some() && description == short_description { + obj.remove("shortDescription"); + } + if obj.get("imageGallery") == obj.get("galleryUrls") { + obj.remove("galleryUrls"); + } + + serde_json::Value::Object(obj) +} + fn propagate_author_names(cache: &mut HashMap) { let learned: Vec<(String, Option, HashSet)> = cache .values() @@ -1180,9 +1552,28 @@ fn save_workshop_cache( fs::create_dir_all(parent) .map_err(|e| format!("Failed to create cache directory: {}", e))?; } + let authors = collect_author_directory(cache); + let compact_items: HashMap = cache + .iter() + .filter(|(key, value)| { + key.chars().all(|c| c.is_ascii_digit()) + || value + .get("workshopId") + .and_then(|v| v.as_str()) + .map(|id| !id.trim().is_empty()) + .unwrap_or(false) + }) + .map(|(key, value)| { + ( + key.clone(), + compact_workshop_cache_item_for_save(value, &authors), + ) + }) + .collect(); let json = serde_json::to_string_pretty(&serde_json::json!({ - "schemaVersion": 2, - "items": cache, + "schemaVersion": 3, + "items": compact_items, + "authors": authors, })) .map_err(|e| format!("Failed to serialize workshop cache: {}", e))?; fs::write(cache_path, json).map_err(|e| format!("Failed to write workshop cache: {}", e)) @@ -4482,6 +4873,49 @@ pub async fn persist_workshop_page_details( } } + if let Some(collection_items) = details.get("collectionItems").and_then(|v| v.as_array()) { + for item in collection_items { + let Some(child_id) = item.get("workshopId").and_then(|v| v.as_str()) else { + continue; + }; + let child_obj = cache_entry_object(&mut cache, child_id); + insert_non_empty_string(child_obj, "workshopId", child_id); + if let Some(title) = item.get("title").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "title", title); + } + if let Some(author_name) = item.get("authorName").and_then(|v| v.as_str()) { + let author_ids = [ + item.get("authorSteamId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + item.get("authorAccountId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + item.get("authorVanityId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ]; + insert_author_name_if_useful(child_obj, author_name, &author_ids); + } + if let Some(author_url) = item.get("authorUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "creatorProfileUrl", author_url); + insert_non_empty_string(child_obj, "authorUrl", author_url); + } + if let Some(author_steam_id) = item.get("authorSteamId").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "creatorSteamId", author_steam_id); + insert_non_empty_string(child_obj, "creatorId", author_steam_id); + } + if let Some(author_vanity_id) = item.get("authorVanityId").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "creatorVanityId", author_vanity_id); + } + insert_non_empty_string(child_obj, "lastSeenSource", &source); + insert_non_empty_string(child_obj, "lastSeenAt", &now); + } + } + if let Some(parent_collections) = details.get("parentCollections").and_then(|v| v.as_array()) { for item in parent_collections { let Some(collection_id) = item.get("workshopId").and_then(|v| v.as_str()) else { @@ -4555,9 +4989,10 @@ pub async fn append_workshop_crawl_log( mod tests { use super::{ ensure_background_workshop_fetch_allowed, extract_steamcommunity_error_message, - is_background_workshop_fetch_source, merge_known_addon_snapshots_into_cache, - persist_seen_workshop_item_entry, persist_workshop_page_details_entry, - propagate_author_names, workshop_cache_with_known_addons, + is_background_workshop_fetch_source, load_workshop_cache, + merge_known_addon_snapshots_into_cache, persist_seen_workshop_item_entry, + persist_workshop_page_details_entry, propagate_author_names, save_workshop_cache, + workshop_cache_with_known_addons, }; use crate::commands::types::WorkshopSeenItem; use serde_json::json; @@ -4891,4 +5326,142 @@ mod tests { Some("https://steamcommunity.com/id/perfectbuddy") ); } + + #[test] + fn workshop_cache_loader_skips_wrapper_keys_and_repairs_nested_items() { + let cache_path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-workshop_cache.json", + "nested-wrapper", + std::process::id() + )); + fs::write( + &cache_path, + serde_json::to_string(&json!({ + "schemaVersion": 3, + "authors": { + "76561198012020581": { + "name": "perfect_buddy", + "profileUrl": "https://steamcommunity.com/id/perfectbuddy", + "steamId": "76561198012020581", + "vanityId": "perfectbuddy" + } + }, + "items": { + "3560883926": { + "workshopId": "3560883926", + "authorKey": "76561198012020581", + "description": "full description", + "imageGallery": ["https://example.com/1.jpg"] + }, + "items": { + "3560886114": { + "workshopId": "3560886114", + "authorKey": "76561198012020581" + } + }, + "schemaVersion": 2 + } + })) + .unwrap(), + ) + .unwrap(); + + let cache = load_workshop_cache(&cache_path); + fs::remove_file(&cache_path).ok(); + + assert_eq!(cache.len(), 2); + assert!(cache.get("items").is_none()); + assert!(cache.get("schemaVersion").is_none()); + let item = cache.get("3560883926").unwrap(); + assert_eq!( + item.get("creatorName").and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + item.get("shortDescription").and_then(|v| v.as_str()), + Some("full description") + ); + assert_eq!( + item.get("galleryUrls") + .and_then(|v| v.as_array()) + .map(|v| v.len()), + Some(1) + ); + } + + #[test] + fn workshop_cache_save_uses_author_directory_and_compacts_alias_fields() { + let cache_path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-workshop_cache.json", + "author-directory", + std::process::id() + )); + fs::remove_file(&cache_path).ok(); + let cache = std::collections::HashMap::from([( + "3560883926".to_string(), + json!({ + "workshopId": "3560883926", + "creatorName": "perfect_buddy", + "authorName": "perfect_buddy", + "creatorProfileUrl": "https://steamcommunity.com/id/perfectbuddy", + "authorUrl": "https://steamcommunity.com/id/perfectbuddy", + "creatorSteamId": "76561198012020581", + "creatorVanityId": "perfectbuddy", + "description": "full description", + "shortDescription": "full description", + "imageGallery": ["https://example.com/1.jpg"], + "galleryUrls": ["https://example.com/1.jpg"] + }), + )]); + + save_workshop_cache(&cache_path, &cache).unwrap(); + let saved: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&cache_path).unwrap()).unwrap(); + let item = saved + .get("items") + .and_then(|v| v.get("3560883926")) + .and_then(|v| v.as_object()) + .unwrap(); + + assert_eq!(saved.get("schemaVersion").and_then(|v| v.as_u64()), Some(3)); + assert_eq!( + saved + .get("authors") + .and_then(|v| v.get("76561198012020581")) + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + item.get("authorKey").and_then(|v| v.as_str()), + Some("76561198012020581") + ); + assert!(item.get("creatorName").is_none()); + assert!(item.get("authorName").is_none()); + assert!(item.get("creatorProfileUrl").is_none()); + assert!(item.get("authorUrl").is_none()); + assert!(item.get("shortDescription").is_none()); + assert!(item.get("galleryUrls").is_none()); + + let expanded = load_workshop_cache(&cache_path); + fs::remove_file(&cache_path).ok(); + let expanded_item = expanded.get("3560883926").unwrap(); + assert_eq!( + expanded_item.get("creatorName").and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + expanded_item + .get("shortDescription") + .and_then(|v| v.as_str()), + Some("full description") + ); + assert_eq!( + expanded_item + .get("galleryUrls") + .and_then(|v| v.as_array()) + .map(|v| v.len()), + Some(1) + ); + } } diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx index 211fd21..e92c03e 100644 --- a/src/components/workshop/WorkshopDetailModal.tsx +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -101,7 +101,10 @@ export const WorkshopDetailModal: React.FC = ({ // ── Collection detail ──────────────────────────────────────────────────────── if (collection) { - const allWorkshopIds = collection.items.map((i) => i.workshopId); + const displayCollectionItems = pageDetails?.collectionItems?.length + ? pageDetails.collectionItems + : collection.items; + const allWorkshopIds = displayCollectionItems.map((i) => i.workshopId); const collectionId = collection.workshopId || allWorkshopIds[0]; const isKnownCollection = !!collectionId && !!groups?.some( (group) => group.workshopCollectionId?.trim() === collectionId, @@ -109,6 +112,7 @@ export const WorkshopDetailModal: React.FC = ({ // Use the large background image from the scraped page if available const heroImage = pageDetails?.backgroundImageUrl || collection.imagePath; const collectionCreatorName = pageDetails?.creatorName || collection.creatorName; + const collectionDescription = pageDetails?.description || collection.description; return (
@@ -149,7 +153,7 @@ export const WorkshopDetailModal: React.FC = ({
{t('workshop.detail.collectionItems')} - {t('workshop.detail.collectionItemsCount', { count: collection.items.length })} + {t('workshop.detail.collectionItemsCount', { count: displayCollectionItems.length })}
{collectionId && (
@@ -176,10 +180,10 @@ export const WorkshopDetailModal: React.FC = ({ {/* Right: description + items */}

{collection.title}

- {collection.description && ( + {collectionDescription && ( <>
{t('workshop.detail.descriptionLabel')}
-
{collection.description}
+
{collectionDescription}
)} @@ -187,7 +191,7 @@ export const WorkshopDetailModal: React.FC = ({ {t('workshop.detail.itemsInCollection')}
- {collection.items.map((ci) => { + {displayCollectionItems.map((ci) => { const isKnown = isKnownWorkshopItem(knownUninstalledAddons, ci.workshopId); const isDownloaded = isKnownWorkshopItem(addons, ci.workshopId); const shouldCacheRemote = isDownloaded || isKnown; @@ -233,7 +237,7 @@ export const WorkshopDetailModal: React.FC = ({ {t('workshop.detail.importAsGroup')} - diff --git a/src/components/workshop/ssrParser.test.ts b/src/components/workshop/ssrParser.test.ts index 4f5b820..6ce4bfd 100644 --- a/src/components/workshop/ssrParser.test.ts +++ b/src/components/workshop/ssrParser.test.ts @@ -24,4 +24,60 @@ describe('ssrParser', () => { expect(details.creatorName).toBe('perfect_buddy'); expect(details.creatorSteamId).toBe('76561198012020581'); }); + + test('parses collection children from Steam collection page DOM', () => { + const details = parseWorkshopPageDetails(` +
Early Days Campaign
+
+ Subscribe to all 6 parts.

Please rate part 1. +
+
+
+ +
+ +
Early Days PART 1/6
+
+
+ Created by + + perfect_buddy + +
+ +
UPDATE: Some performance improvements...
+
+
+
+
+ +
Early Days PART 2/6
+
+
+ Created by + + perfect_buddy + +
+
+
+
+ `); + + expect(details.description).toContain('Subscribe to all 6 parts.'); + expect(details.childItemIds).toEqual(['3560883926', '3560886114']); + expect(details.collectionItems?.map((item) => item.title)).toEqual([ + 'Early Days PART 1/6', + 'Early Days PART 2/6', + ]); + expect(details.collectionItems?.[0].authorName).toBe('perfect_buddy'); + expect(details.collectionItems?.[0].authorVanityId).toBe('perfectbuddy'); + expect(details.collectionItems?.[0].stars).toBe(5); + }); }); diff --git a/src/components/workshop/ssrParser.ts b/src/components/workshop/ssrParser.ts index cffa53d..6bbb401 100644 --- a/src/components/workshop/ssrParser.ts +++ b/src/components/workshop/ssrParser.ts @@ -660,6 +660,40 @@ export function parseWorkshopPageDetails(html: string): WorkshopPageDetails { }); } + const collectionItems: WorkshopItem[] = []; + all('.collectionChildren .collectionItem').forEach((node) => { + const itemLink = node.querySelector('a[href*="filedetails/?id="]') as HTMLAnchorElement | null; + const workshopId = itemLink?.href?.match(/id=(\d+)/)?.[1] || node.id?.match(/sharedfile_(\d+)/)?.[1] || ''; + if (!workshopId) return; + + const title = (node.querySelector('.workshopItemTitle')?.textContent || '').trim(); + const imagePath = toFullSizeUrl((node.querySelector('.workshopItemPreviewImage') as HTMLImageElement | null)?.src || ''); + const authorLink = node.querySelector('.workshopItemAuthorName a') as HTMLAnchorElement | null; + const authorName = (authorLink?.textContent || '').trim(); + const authorUrl = authorLink?.href || ''; + const authorIds = parseProfileIdentifiers(authorUrl); + const shortDescription = (node.querySelector('.workshopItemShortDesc')?.textContent || '').trim(); + const ratingSrc = (node.querySelector('.fileRating') as HTMLImageElement | null)?.src || ''; + const ratingStars = parseInt(ratingSrc.match(/(\d+)-star/)?.[1] || '0', 10) || 0; + + collectionItems.push({ + workshopId, + title, + imagePath, + authorName, + authorId: authorIds.vanityId || authorIds.steamId || '', + authorUrl, + authorSteamId: authorIds.steamId, + authorVanityId: authorIds.vanityId, + stars: ratingStars, + shortDescription, + }); + }); + if (collectionItems.length > 0) { + result.collectionItems = collectionItems; + result.childItemIds = collectionItems.map((item) => item.workshopId); + } + // 4. Parent collections const parentCollectionsDiv = doc.querySelector('.parentCollections'); if (parentCollectionsDiv) { diff --git a/src/components/workshop/types.ts b/src/components/workshop/types.ts index 3c0d7f5..829cdc2 100644 --- a/src/components/workshop/types.ts +++ b/src/components/workshop/types.ts @@ -56,6 +56,8 @@ export interface WorkshopPageDetails { imageGallery: string[]; tags: { category: string; name: string }[]; requiredItems: { title: string; workshopId: string }[]; + collectionItems?: WorkshopItem[]; + childItemIds?: string[]; parentCollections: { title: string; workshopId: string }[]; fileSizeDisplay?: string; postedDateText?: string; diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index 36f28b7..3727a35 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -182,6 +182,28 @@ function normalizeCachedRelations(value: any): { title: string; workshopId: stri .filter((item) => item.workshopId); } +function normalizeCachedWorkshopItems(value: any): WorkshopItem[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item?.workshopId && !item?.publishedfileid) return null; + return { + workshopId: normalizeText(item.workshopId || item.publishedfileid), + title: normalizeText(item.title), + imagePath: normalizeText(item.imagePath || item.previewUrl || item.preview_url), + authorName: normalizeText(item.authorName || item.creatorName || item.creator_name), + authorId: normalizeText(item.authorId || item.creatorId || item.creator), + authorUrl: normalizeText(item.authorUrl || item.creatorProfileUrl), + authorSteamId: normalizeText(item.authorSteamId || item.creatorSteamId) || undefined, + authorVanityId: normalizeText(item.authorVanityId || item.creatorVanityId) || undefined, + authorAccountId: normalizeText(item.authorAccountId || item.creatorAccountId) || undefined, + stars: Number(item.stars || item.ratingStars || 0), + shortDescription: normalizeText(item.shortDescription || item.description) || undefined, + }; + }) + .filter(Boolean) as WorkshopItem[]; +} + function mapCachedDetailToPageDetails(detail: any): WorkshopPageDetails { const steamDetails = detail?.steamDetails || {}; const gallery = detail?.imageGallery || detail?.galleryUrls || detail?.galleryPreviewUrls || []; @@ -198,6 +220,8 @@ function mapCachedDetailToPageDetails(detail: any): WorkshopPageDetails { imageGallery: Array.isArray(gallery) ? gallery.map(String).filter(Boolean) : [], tags: normalizeCachedTags(detail?.pageTags || detail?.tags || steamDetails?.tags), requiredItems: normalizeCachedRelations(detail?.requiredItems), + collectionItems: normalizeCachedWorkshopItems(detail?.collectionItems), + childItemIds: Array.isArray(detail?.childItemIds) ? detail.childItemIds.map(String).filter(Boolean) : undefined, parentCollections: normalizeCachedRelations(detail?.parentCollections), fileSizeDisplay: normalizeText(detail?.fileSizeDisplay || detail?.fileSize || steamDetails?.file_size) || undefined, postedDateText: normalizeText(detail?.postedDateText) || undefined, @@ -220,6 +244,12 @@ function normalizePageDetails(details: Partial | null | und requiredItems: Array.isArray(details?.requiredItems) ? details.requiredItems.filter((item) => normalizeText(item?.workshopId)) : [], + collectionItems: Array.isArray(details?.collectionItems) + ? details.collectionItems.filter((item) => normalizeText(item?.workshopId)) + : [], + childItemIds: Array.isArray(details?.childItemIds) + ? details.childItemIds.map(String).filter(Boolean) + : undefined, parentCollections: Array.isArray(details?.parentCollections) ? details.parentCollections.filter((item) => normalizeText(item?.workshopId)) : [], @@ -285,6 +315,8 @@ export function mergeWorkshopPageDetails( imageGallery: chooseArray(existing.imageGallery, next.imageGallery), tags: chooseArray(existing.tags, next.tags), requiredItems: chooseArray(existing.requiredItems, next.requiredItems), + collectionItems: chooseArray(existing.collectionItems, next.collectionItems), + childItemIds: chooseArray(existing.childItemIds, next.childItemIds), parentCollections: chooseArray(existing.parentCollections, next.parentCollections), fileSizeDisplay: normalizeText(next.fileSizeDisplay) || existing.fileSizeDisplay, postedDateText: normalizeText(next.postedDateText) || existing.postedDateText, @@ -308,11 +340,41 @@ function hasUsefulPageSnapshot(details: WorkshopPageDetails): boolean { details.description || details.imageGallery.length > 0 || details.requiredItems.length > 0 || + (details.collectionItems?.length || 0) > 0 || + (details.childItemIds?.length || 0) > 0 || details.parentCollections.length > 0 || details.tags.length > 0, ); } +function cachedCollectionResponse(workshopId: string, collection: any, cache: Record, source: string) { + const resolvedCollection = cachedDetailToWorkshopItem(workshopId, collection); + const childIds = collection.childItemIds || []; + const cachedItems = collection.collectionItems || []; + return { + source, + collection: { + ...collection, + publishedfileid: workshopId, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + (cachedItems.length > 0 + ? cachedItems.map((item: any) => ({ + ...cachedDetailToWorkshopItem(item.workshopId || item.publishedfileid, item), + ...item, + })) + : childIds.map((childId: string) => cache[childId] ? cachedDetailToWorkshopItem(childId, cache[childId]) : null)) + .filter(Boolean), + ), + }; +} + async function enrichItemsFromSnapshot(items: WorkshopItem[]): Promise { if (items.length === 0) return items; const cache = await readWorkshopItemCache(); @@ -687,27 +749,8 @@ export async function fetchWorkshopItem(workshopId: string) { export async function fetchWorkshopCollection(workshopId: string) { const cache = await readWorkshopItemCache(); const cachedCollection = cache[workshopId]; - if (cachedCollection) { - const resolvedCollection = cachedDetailToWorkshopItem(workshopId, cachedCollection); - const childIds = cachedCollection.childItemIds || []; - return { - source: 'snapshot', - collection: { - ...cachedCollection, - publishedfileid: workshopId, - title: resolvedCollection.title, - preview_url: resolvedCollection.imagePath, - creator_name: resolvedCollection.authorName, - creator: resolvedCollection.authorId, - creator_steam_id: resolvedCollection.authorSteamId, - creator_account_id: resolvedCollection.authorAccountId, - }, - items: rememberWorkshopItems( - childIds - .map((childId: string) => cache[childId] ? cachedDetailToWorkshopItem(childId, cache[childId]) : null) - .filter(Boolean), - ), - }; + if (cachedCollection && (cachedCollection.childItemIds?.length || cachedCollection.collectionItems?.length)) { + return cachedCollectionResponse(workshopId, cachedCollection, cache, 'snapshot'); } const capabilities = shouldUseSteamworksSdk() @@ -717,6 +760,9 @@ export async function fetchWorkshopCollection(workshopId: string) { try { const data = await invoke('query_workshop_collection', { workshopId }); reportWorkshopWarnings(data.warnings); + if (!data?.collection?.publishedfileid) { + throw new Error('Steam SDK returned empty collection details'); + } const resolvedCollection = rememberWorkshopItems([ mapSteamDetailToWorkshopItem(data.collection, data.source), ])[0]; @@ -742,6 +788,9 @@ export async function fetchWorkshopCollection(workshopId: string) { try { const data: any = await invoke('fetch_collection', { collectionId: workshopId }); + if (!data?.collection?.publishedfileid && cachedCollection) { + return cachedCollectionResponse(workshopId, cachedCollection, cache, 'cache'); + } const resolvedCollection = rememberWorkshopItems([ mapSteamDetailToWorkshopItem(data.collection, 'web-fallback'), ])[0]; @@ -763,26 +812,7 @@ export async function fetchWorkshopCollection(workshopId: string) { } catch (err) { const collection = cache[workshopId]; if (collection) { - const resolvedCollection = cachedDetailToWorkshopItem(workshopId, collection); - const childIds = collection.childItemIds || []; - return { - source: 'cache', - collection: { - ...collection, - publishedfileid: workshopId, - title: resolvedCollection.title, - preview_url: resolvedCollection.imagePath, - creator_name: resolvedCollection.authorName, - creator: resolvedCollection.authorId, - creator_steam_id: resolvedCollection.authorSteamId, - creator_account_id: resolvedCollection.authorAccountId, - }, - items: rememberWorkshopItems( - childIds - .map((childId: string) => cache[childId] ? cachedDetailToWorkshopItem(childId, cache[childId]) : null) - .filter(Boolean), - ), - }; + return cachedCollectionResponse(workshopId, collection, cache, 'cache'); } throw err; } From 9cd4b6a4159f31cc16346677b42dc6fe34e34c9a Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 05:13:30 +0800 Subject: [PATCH 44/81] Fix Steamworks SDK startup when disabled by settings --- src-tauri/src/commands/handlers.rs | 28 ++++- src-tauri/src/steam/service.rs | 162 +++++++++++++++-------------- src/hooks/useAddonManager.test.tsx | 53 ++++++++++ src/hooks/useAddonManager.ts | 16 ++- 4 files changed, 177 insertions(+), 82 deletions(-) diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index d9bdf8e..c693dbe 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -3814,6 +3814,25 @@ pub async fn open_url(url: String) -> Result<(), String> { pub async fn get_workshop_capabilities( state: State<'_, crate::AppState>, ) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Ok(WorkshopCapabilities { + bridge_available: false, + bridge_loaded: false, + bridge_initialized: false, + provider: "web-fallback".to_string(), + bridge_version: None, + last_error: Some("Steamworks SDK workshop source is disabled by settings".to_string()), + current_user_steam_id: None, + current_user_account_id: None, + can_query_items: false, + can_query_home: false, + can_download: false, + can_enumerate_installed: false, + can_enumerate_subscribed: false, + }); + } + drop(db); Ok(state.workshop_service.capabilities()) } @@ -4217,9 +4236,12 @@ pub async fn fetch_workshop_html( let (allow_html, sdk_query_available) = { let db = state.db.lock().await; let source_policy = SourcePolicy::from_settings(&db.settings); - let capabilities = state.workshop_service.capabilities(); - let sdk_query_available = source_policy.allow_bridge() - && (capabilities.can_query_items || capabilities.can_query_home); + let sdk_query_available = if source_policy.allow_bridge() { + let capabilities = state.workshop_service.capabilities(); + capabilities.can_query_items || capabilities.can_query_home + } else { + false + }; ( source_policy.allow_html(sdk_query_available), sdk_query_available, diff --git a/src-tauri/src/steam/service.rs b/src-tauri/src/steam/service.rs index 9b3da56..3bcd670 100644 --- a/src-tauri/src/steam/service.rs +++ b/src-tauri/src/steam/service.rs @@ -5,77 +5,111 @@ use super::types::{ WorkshopItemsResponse, }; use serde_json::{json, Value}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; -#[derive(Clone, Default)] -pub struct WorkshopService { +#[derive(Default)] +struct WorkshopServiceState { bridge: Option, bridge_error: Option, + load_attempted: bool, +} + +#[derive(Default)] +pub struct WorkshopService { + exe_dir: PathBuf, + state: Mutex, } impl WorkshopService { pub fn new(exe_dir: &Path) -> Self { - match WorkshopBridge::load_near(exe_dir) { - Ok(bridge) => Self { - bridge: Some(bridge), - bridge_error: None, - }, - Err(err) => Self { - bridge: None, - bridge_error: Some(err), - }, + Self { + exe_dir: exe_dir.to_path_buf(), + state: Mutex::new(WorkshopServiceState::default()), } } - pub fn capabilities(&self) -> WorkshopCapabilities { - if let Some(bridge) = &self.bridge { - bridge.capabilities() - } else { - WorkshopCapabilities { - bridge_available: false, - bridge_loaded: false, - bridge_initialized: false, - provider: "web-fallback".to_string(), - bridge_version: None, - last_error: self.bridge_error.clone(), - current_user_steam_id: None, - current_user_account_id: None, - can_query_items: false, - can_query_home: false, - can_download: false, - can_enumerate_installed: false, - can_enumerate_subscribed: false, + fn unavailable_capabilities(last_error: Option) -> WorkshopCapabilities { + WorkshopCapabilities { + bridge_available: false, + bridge_loaded: false, + bridge_initialized: false, + provider: "web-fallback".to_string(), + bridge_version: None, + last_error, + current_user_steam_id: None, + current_user_account_id: None, + can_query_items: false, + can_query_home: false, + can_download: false, + can_enumerate_installed: false, + can_enumerate_subscribed: false, + } + } + + fn ensure_bridge(&self) -> Result { + let mut state = self + .state + .lock() + .map_err(|_| "Steam bridge state mutex poisoned".to_string())?; + + if let Some(bridge) = &state.bridge { + return Ok(bridge.clone()); + } + + if state.load_attempted { + return Err(state + .bridge_error + .clone() + .unwrap_or_else(|| "Steam bridge unavailable".to_string())); + } + + match WorkshopBridge::load_near(&self.exe_dir) { + Ok(bridge) => { + state.bridge = Some(bridge.clone()); + state.bridge_error = None; + state.load_attempted = true; + Ok(bridge) } + Err(err) => { + state.bridge = None; + state.bridge_error = Some(err.clone()); + state.load_attempted = true; + Err(err) + } + } + } + + pub fn capabilities(&self) -> WorkshopCapabilities { + match self.ensure_bridge() { + Ok(bridge) => bridge.capabilities(), + Err(err) => Self::unavailable_capabilities(Some(err)), } } pub fn has_bridge(&self) -> bool { - self.bridge.is_some() + self.ensure_bridge().is_ok() } pub fn shutdown(&self) { - if let Some(bridge) = &self.bridge { - bridge.shutdown(); + if let Ok(mut state) = self.state.lock() { + if let Some(bridge) = state.bridge.take() { + bridge.shutdown(); + } + state.bridge_error = None; + state.load_attempted = false; } } pub fn bridge_fetch_details(&self, workshop_ids: &[String]) -> Result, String> { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("get_details", &json!({ "ids": workshop_ids }))?; serde_json::from_value(payload).map_err(|e| e.to_string()) } pub fn bridge_query_home(&self) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("query_home", &json!({}))?; serde_json::from_value(payload).map_err(|e| e.to_string()) @@ -85,22 +119,14 @@ impl WorkshopService { &self, query: &WorkshopBrowseQuery, ) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("query_items", query)?; serde_json::from_value(payload).map_err(|e| e.to_string()) } pub fn bridge_query_item(&self, workshop_id: &str) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("query_item", &json!({ "workshopId": workshop_id }))?; serde_json::from_value(payload).map_err(|e| e.to_string()) @@ -110,22 +136,14 @@ impl WorkshopService { &self, workshop_id: &str, ) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("query_collection", &json!({ "workshopId": workshop_id }))?; serde_json::from_value(payload).map_err(|e| e.to_string()) } pub fn bridge_request_download(&self, workshop_id: &str) -> Result<(), String> { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("request_download", &json!({ "workshopId": workshop_id }))?; let accepted = payload @@ -148,33 +166,21 @@ impl WorkshopService { &self, workshop_id: &str, ) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("get_download_status", &json!({ "workshopId": workshop_id }))?; serde_json::from_value(payload).map_err(|e| e.to_string()) } pub fn bridge_get_subscribed_items(&self) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("get_subscribed_items", &json!({}))?; serde_json::from_value(payload).map_err(|e| e.to_string()) } pub fn bridge_get_favorited_collections(&self) -> Result { - let bridge = self.bridge.as_ref().ok_or_else(|| { - self.bridge_error - .clone() - .unwrap_or_else(|| "Steam bridge unavailable".to_string()) - })?; + let bridge = self.ensure_bridge()?; let payload = bridge.call("get_favorited_collections", &json!({}))?; serde_json::from_value(payload).map_err(|e| e.to_string()) diff --git a/src/hooks/useAddonManager.test.tsx b/src/hooks/useAddonManager.test.tsx index 244a84d..0ca30f4 100644 --- a/src/hooks/useAddonManager.test.tsx +++ b/src/hooks/useAddonManager.test.tsx @@ -607,6 +607,59 @@ describe('useAddonManager', () => { expect(result.current.backgroundTasks).toHaveLength(0); }); + test('should not probe Steamworks capabilities when SDK source is disabled in source settings', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: { + ...mockSettings, + workshopSourceSettings: { + preset: 'conservative', + allowSteamworksSdk: false, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sourceOrder: ['steam-web-api', 'steamcommunity-html', 'steamworks-sdk'], + cacheRetention: 'keep', + }, + }, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockInvoke).not.toHaveBeenCalledWith('get_workshop_capabilities', undefined); + expect(result.current.backgroundTasks).toHaveLength(0); + }); + test('should request backend download cancellation when cancelling a running download task', async () => { let resolveDownload: ((value: DatabasePayload) => void) | undefined; const downloadPromise = new Promise((resolve) => { diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index 9acfbbd..8da69c3 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -256,6 +256,20 @@ export function useAddonManager() { }, [settings.disableSteamworksSdk, settings.workshopSourceSettings]); useEffect(() => { + if (loading) { + return; + } + + const shouldProbeCapabilities = !settings.disableSteamworksSdk + && (settings.workshopSourceSettings?.allowSteamworksSdk ?? true); + + if (!shouldProbeCapabilities) { + setWorkshopCapabilities(null); + setWorkshopCapabilitiesError(null); + setWorkshopCapabilitiesCheckedAt(null); + return; + } + let cancelled = false; void invoke('get_workshop_capabilities') @@ -275,7 +289,7 @@ export function useAddonManager() { return () => { cancelled = true; }; - }, []); + }, [loading, settings.disableSteamworksSdk, settings.workshopSourceSettings?.allowSteamworksSdk]); const steamworksUnavailableTask: BackgroundTask | null = (() => { if (loading || settings.suppressSdkUnavailableWarning || settings.disableSteamworksSdk) { From ba2923e7ccc3df132bc3f5ee26b1a9658f3a4f2b Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 16:23:21 +0800 Subject: [PATCH 45/81] Fix workshop dependency detail resolution --- src-tauri/src/commands/handlers.rs | 18 +++ src-tauri/src/commands/mod.rs | 2 +- src-tauri/src/lib.rs | 1 + src/components/DetailModal.tsx | 6 + src/components/WorkshopCommon.tsx | 34 ++++- src/components/workshop/types.ts | 1 + src/services/workshopClient.test.ts | 157 ++++++++++++++++++++++ src/services/workshopClient.ts | 195 +++++++++++++++++++++++----- 8 files changed, 382 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index c693dbe..4fe6f70 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -3874,6 +3874,24 @@ pub async fn query_workshop_item( state.workshop_service.bridge_query_item(&workshop_id) } +#[tauri::command] +pub async fn query_workshop_details( + workshop_ids: Vec, + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Err("Steamworks SDK workshop source is disabled".to_string()); + } + drop(db); + + Ok(WorkshopItemsResponse { + source: "steam-sdk".to_string(), + items: state.workshop_service.bridge_fetch_details(&workshop_ids)?, + warnings: Vec::new(), + }) +} + #[tauri::command] pub async fn query_workshop_collection( workshop_id: String, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6c07476..1272c69 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,7 +6,7 @@ pub use handlers::{ download_addon, fetch_collection, fetch_workshop_html, get_addons, get_background_tasks, get_cache_image, get_settings, get_workshop_cache, get_workshop_capabilities, group_action, move_addons, open_url, open_workshop, persist_workshop_page_details, query_workshop_collection, - query_workshop_home, query_workshop_item, query_workshop_items, record_workshop_items_seen, + query_workshop_details, query_workshop_home, query_workshop_item, query_workshop_items, record_workshop_items_seen, rename_addon, rename_addons, save_background_task_snapshot, save_settings, steam_sync, toggle_addons, }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 673a970..6386a89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -138,6 +138,7 @@ pub fn run() { commands::handlers::query_workshop_home, commands::handlers::query_workshop_items, commands::handlers::query_workshop_item, + commands::handlers::query_workshop_details, commands::handlers::query_workshop_collection, commands::handlers::fetch_workshop_html, commands::handlers::delete_addons, diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx index eb26660..0bdf76a 100644 --- a/src/components/DetailModal.tsx +++ b/src/components/DetailModal.tsx @@ -51,6 +51,12 @@ export const DetailModal: React.FC = ({ const snapshot = await getWorkshopPageSnapshot(workshopId); if (snapshot) { setPageDetails(snapshot); + try { + const data: DatabasePayload = await persistWorkshopPageDetails(workshopId, snapshot, 'addon-detail') as DatabasePayload; + onDatabaseUpdate?.(data); + } catch (err) { + console.error('Failed to persist workshop snapshot details:', err); + } } else { setPageDetails(null); } diff --git a/src/components/WorkshopCommon.tsx b/src/components/WorkshopCommon.tsx index 33caf82..5d851e6 100644 --- a/src/components/WorkshopCommon.tsx +++ b/src/components/WorkshopCommon.tsx @@ -1,7 +1,36 @@ import React from 'react'; import { Package, Link2, CheckCircle, FolderPlus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { isKnownWorkshopItem } from '../utils/workshopKnown'; +import { findKnownWorkshopEntry, isKnownWorkshopItem } from '../utils/workshopKnown'; + +function resolveRequiredItemTitle( + title: string | undefined, + workshopId: string, + addons: Record, + knownUninstalledAddons: Record, +): string { + const normalizedTitle = String(title || '').trim(); + if (normalizedTitle) { + return normalizedTitle; + } + + const knownEntry = findKnownWorkshopEntry(addons, workshopId) + || findKnownWorkshopEntry(knownUninstalledAddons, workshopId); + if (knownEntry) { + const knownTitle = String( + knownEntry.workshopDetails?.title + || knownEntry.steamDetails?.title + || knownEntry.addonInfo?.addontitle + || knownEntry.vpkName + || '', + ).trim(); + if (knownTitle) { + return knownTitle; + } + } + + return workshopId; +} interface RequiredItemsProps { requiredItems: { title: string; workshopId: string }[]; @@ -39,6 +68,7 @@ export const RequiredItems: React.FC = ({ {requiredItems.map((req) => { const reqIsKnown = isKnownWorkshopItem(knownUninstalledAddons, req.workshopId); const reqIsDownloaded = isKnownWorkshopItem(addons, req.workshopId); + const reqTitle = resolveRequiredItemTitle(req.title, req.workshopId, addons, knownUninstalledAddons); return (
= ({ gap: '4px', }} > - {req.title} + {reqTitle} {reqIsDownloaded && ( diff --git a/src/components/workshop/types.ts b/src/components/workshop/types.ts index 829cdc2..829d57a 100644 --- a/src/components/workshop/types.ts +++ b/src/components/workshop/types.ts @@ -44,6 +44,7 @@ export interface CollectionData { /** Extra details scraped from the Steam Community workshop page */ export interface WorkshopPageDetails { + fileType?: string; title?: string; previewUrl?: string; description?: string; diff --git a/src/services/workshopClient.test.ts b/src/services/workshopClient.test.ts index 1228b33..388aa21 100644 --- a/src/services/workshopClient.test.ts +++ b/src/services/workshopClient.test.ts @@ -28,6 +28,7 @@ vi.mock('../components/workshop/ssrParser', () => ({ vi.mock('../components/workshop/authorDirectory', () => ({ rememberWorkshopItems: (items: unknown) => mockRememberWorkshopItems(items), rememberWorkshopPageDetails: vi.fn(), + resolveWorkshopItemAuthor: (item: unknown) => item, })); describe('workshopClient', () => { @@ -354,4 +355,160 @@ describe('workshopClient', () => { workshopClient.setWorkshopWarningReporter(null); }); + + test('getWorkshopPageSnapshot derives requiredItems from SDK childItemIds for non-collection items', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({}); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_item') { + return Promise.resolve({ + source: 'steam-sdk', + item: { + publishedfileid: '100', + title: 'Parent Addon', + file_type: 'item', + child_item_ids: ['200', '300'], + }, + }); + } + if (cmd === 'query_workshop_details') { + return Promise.resolve({ + source: 'steam-sdk', + items: [ + { publishedfileid: '200', title: 'Child One' }, + { publishedfileid: '300', title: 'Child Two' }, + ], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([ + { title: 'Child One', workshopId: '200' }, + { title: 'Child Two', workshopId: '300' }, + ]); + expect(snapshot?.childItemIds).toEqual(['200', '300']); + }); + + test('getWorkshopPageSnapshot does not derive requiredItems from children for collections', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({}); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_item') { + return Promise.resolve({ + source: 'steam-sdk', + item: { + publishedfileid: '100', + title: 'Collection', + file_type: 'collection', + child_item_ids: ['200', '300'], + }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([]); + expect(snapshot?.childItemIds).toEqual(['200', '300']); + }); + + test('getWorkshopPageSnapshot derives requiredItems from cached childItemIds when legacy cache stored an empty requiredItems array', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + title: 'Cached Parent', + fileType: 'item', + requiredItems: [], + childItemIds: ['200', '300'], + }, + '200': { + workshopId: '200', + title: 'Child One', + }, + '300': { + workshopId: '300', + title: 'Child Two', + }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([ + { title: 'Child One', workshopId: '200' }, + { title: 'Child Two', workshopId: '300' }, + ]); + expect(snapshot?.childItemIds).toEqual(['200', '300']); + }); + + test('getWorkshopPageSnapshot uses SDK detail enrichment to fill missing cached required item titles', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + title: 'Cached Parent', + fileType: 'item', + requiredItems: [ + { title: '', workshopId: '200' }, + { title: '', workshopId: '300' }, + ], + childItemIds: ['200', '300'], + }, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_details') { + return Promise.resolve({ + source: 'steam-sdk', + items: [ + { publishedfileid: '200', title: 'Child One' }, + { publishedfileid: '300', title: 'Child Two' }, + ], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([ + { title: 'Child One', workshopId: '200' }, + { title: 'Child Two', workshopId: '300' }, + ]); + }); }); diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index 3727a35..a5f8a4f 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -204,52 +204,111 @@ function normalizeCachedWorkshopItems(value: any): WorkshopItem[] { .filter(Boolean) as WorkshopItem[]; } -function mapCachedDetailToPageDetails(detail: any): WorkshopPageDetails { - const steamDetails = detail?.steamDetails || {}; - const gallery = detail?.imageGallery || detail?.galleryUrls || detail?.galleryPreviewUrls || []; +function normalizeChildItemIds(value: any): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const ids = value.map(String).filter(Boolean); + return ids.length > 0 ? ids : undefined; +} + +function deriveRequiredItemsFromChildren( + childItemIds: string[] | undefined, + fileType: unknown, + cache?: Record, +): { title: string; workshopId: string }[] { + if (!Array.isArray(childItemIds) || childItemIds.length === 0) { + return []; + } + if (normalizeText(fileType).toLowerCase() === 'collection') { + return []; + } + return childItemIds.map((workshopId) => ({ + title: normalizeText(cache?.[workshopId]?.title), + workshopId, + })); +} + +function mapSteamDetailToPageDetails(detail: any, cache?: Record): WorkshopPageDetails { + const childItemIds = normalizeChildItemIds(detail?.child_item_ids || detail?.childItemIds); + const fileType = normalizeText(detail?.file_type || detail?.fileType) || undefined; return { - title: normalizeText(detail?.title || steamDetails?.title) || undefined, - previewUrl: normalizeText(detail?.previewUrl || detail?.imagePath || detail?.preview_url || steamDetails?.preview_url) || undefined, - description: normalizeText(detail?.description || steamDetails?.description || detail?.shortDescription) || undefined, - descriptionHtml: normalizeText(detail?.descriptionHtml) || undefined, - creatorName: normalizeText(detail?.creatorName || detail?.authorName || detail?.creator_name || steamDetails?.creator_name) || undefined, + fileType, + title: normalizeText(detail?.title) || undefined, + previewUrl: normalizeText(detail?.preview_url || detail?.previewUrl || detail?.imagePath) || undefined, + description: normalizeText(detail?.description || detail?.short_description || detail?.shortDescription) || undefined, + creatorName: normalizeText(detail?.creator_name || detail?.creatorName || detail?.authorName) || undefined, creatorProfileUrl: normalizeText(detail?.creatorProfileUrl || detail?.authorUrl) || undefined, - creatorSteamId: normalizeText(detail?.creatorSteamId || detail?.creator_steam_id || steamDetails?.creator) || undefined, - creatorVanityId: normalizeText(detail?.creatorVanityId || steamDetails?.creator_vanity_id) || undefined, - creatorAccountId: normalizeText(detail?.creatorAccountId || detail?.creator_account_id || steamDetails?.creator_account_id) || undefined, - imageGallery: Array.isArray(gallery) ? gallery.map(String).filter(Boolean) : [], - tags: normalizeCachedTags(detail?.pageTags || detail?.tags || steamDetails?.tags), - requiredItems: normalizeCachedRelations(detail?.requiredItems), + creatorSteamId: normalizeText(detail?.creator_steam_id || detail?.creatorSteamId || detail?.creator) || undefined, + creatorVanityId: normalizeText(detail?.creator_vanity_id || detail?.creatorVanityId) || undefined, + creatorAccountId: normalizeText(detail?.creator_account_id || detail?.creatorAccountId) || undefined, + imageGallery: Array.isArray(detail?.galleryUrls || detail?.galleryPreviewUrls) + ? (detail.galleryUrls || detail.galleryPreviewUrls).map(String).filter(Boolean) + : [], + tags: normalizeCachedTags(detail?.pageTags || detail?.tags), + requiredItems: normalizeCachedRelations(detail?.requiredItems).length > 0 + ? normalizeCachedRelations(detail?.requiredItems) + : deriveRequiredItemsFromChildren(childItemIds, fileType, cache), collectionItems: normalizeCachedWorkshopItems(detail?.collectionItems), - childItemIds: Array.isArray(detail?.childItemIds) ? detail.childItemIds.map(String).filter(Boolean) : undefined, + childItemIds, parentCollections: normalizeCachedRelations(detail?.parentCollections), - fileSizeDisplay: normalizeText(detail?.fileSizeDisplay || detail?.fileSize || steamDetails?.file_size) || undefined, + fileSizeDisplay: normalizeText(detail?.fileSizeDisplay || detail?.file_size || detail?.fileSize) || undefined, postedDateText: normalizeText(detail?.postedDateText) || undefined, updatedDateText: normalizeText(detail?.updatedDateText) || undefined, changeNoteCount: toNumericOrUndefined(detail?.changeNoteCount), ratingStars: toNumericOrUndefined(detail?.ratingStars), ratingCount: toNumericOrUndefined(detail?.ratingCount), uniqueVisitors: toNumericOrUndefined(detail?.uniqueVisitors), - currentSubscribers: toNumericOrUndefined(detail?.currentSubscribers), - currentFavorites: toNumericOrUndefined(detail?.currentFavorites), + currentSubscribers: toNumericOrUndefined(detail?.currentSubscribers || detail?.subscriptions), + currentFavorites: toNumericOrUndefined(detail?.currentFavorites || detail?.favorites || detail?.favorited), backgroundImageUrl: normalizeText(detail?.backgroundImageUrl) || undefined, }; } +function mapCachedDetailToPageDetails(detail: any, cache?: Record): WorkshopPageDetails { + const steamDetails = detail?.steamDetails || {}; + const pageDetails = mapSteamDetailToPageDetails({ + ...steamDetails, + ...detail, + title: detail?.title || steamDetails?.title, + preview_url: detail?.previewUrl || detail?.imagePath || detail?.preview_url || steamDetails?.preview_url, + description: detail?.description || steamDetails?.description || detail?.shortDescription, + creator_name: detail?.creatorName || detail?.authorName || detail?.creator_name || steamDetails?.creator_name, + creatorProfileUrl: detail?.creatorProfileUrl || detail?.authorUrl, + creator_steam_id: detail?.creatorSteamId || detail?.creator_steam_id || steamDetails?.creator, + creator_vanity_id: detail?.creatorVanityId || steamDetails?.creator_vanity_id, + creator_account_id: detail?.creatorAccountId || detail?.creator_account_id || steamDetails?.creator_account_id, + galleryUrls: detail?.imageGallery || detail?.galleryUrls || detail?.galleryPreviewUrls, + pageTags: detail?.pageTags || detail?.tags || steamDetails?.tags, + requiredItems: detail?.requiredItems, + collectionItems: detail?.collectionItems, + childItemIds: detail?.childItemIds, + parentCollections: detail?.parentCollections, + fileSizeDisplay: detail?.fileSizeDisplay || detail?.fileSize || steamDetails?.file_size, + file_type: detail?.fileType || detail?.file_type || steamDetails?.file_type, + }, cache); + return { + ...pageDetails, + descriptionHtml: normalizeText(detail?.descriptionHtml) || undefined, + }; +} + function normalizePageDetails(details: Partial | null | undefined): WorkshopPageDetails { + const childItemIds = normalizeChildItemIds(details?.childItemIds); + const cachedRequiredItems = Array.isArray(details?.requiredItems) + ? details.requiredItems.filter((item) => normalizeText(item?.workshopId)) + : []; + const requiredItems = cachedRequiredItems.length > 0 + ? cachedRequiredItems + : deriveRequiredItemsFromChildren(childItemIds, details?.fileType); return { ...details, + fileType: normalizeText(details?.fileType) || undefined, imageGallery: Array.isArray(details?.imageGallery) ? details.imageGallery.filter(Boolean) : [], tags: Array.isArray(details?.tags) ? details.tags.filter((tag) => normalizeText(tag?.name)) : [], - requiredItems: Array.isArray(details?.requiredItems) - ? details.requiredItems.filter((item) => normalizeText(item?.workshopId)) - : [], + requiredItems, collectionItems: Array.isArray(details?.collectionItems) ? details.collectionItems.filter((item) => normalizeText(item?.workshopId)) : [], - childItemIds: Array.isArray(details?.childItemIds) - ? details.childItemIds.map(String).filter(Boolean) - : undefined, + childItemIds, parentCollections: Array.isArray(details?.parentCollections) ? details.parentCollections.filter((item) => normalizeText(item?.workshopId)) : [], @@ -303,6 +362,7 @@ export function mergeWorkshopPageDetails( const existing = normalizePageDetails(current); const next = normalizePageDetails(incoming); const merged = resolvePageDetailsAuthor(normalizePageDetails({ + fileType: normalizeText(next.fileType) || existing.fileType, title: normalizeText(next.title) || existing.title, previewUrl: normalizeText(next.previewUrl) || existing.previewUrl, description: chooseDescription(existing.description, next.description), @@ -411,6 +471,58 @@ function normalizeText(value: unknown): string { return String(value || '').trim(); } +function hasMissingRequiredItemTitles(details: Partial | null | undefined): boolean { + return Array.isArray(details?.requiredItems) + && details.requiredItems.some((item) => normalizeText(item?.workshopId) && !normalizeText(item?.title)); +} + +async function enrichRequiredItemsWithSdkDetails( + details: WorkshopPageDetails, + cache?: Record, +): Promise { + if (!shouldUseSteamworksSdk() || !hasMissingRequiredItemTitles(details)) { + return details; + } + + const capabilities = await getWorkshopCapabilities().catch(() => null); + if (!capabilities?.canQueryItems) { + return details; + } + + const missingIds = [...new Set( + (details.requiredItems || []) + .filter((item) => normalizeText(item?.workshopId) && !normalizeText(item?.title)) + .map((item) => normalizeText(item.workshopId)), + )]; + if (missingIds.length === 0) { + return details; + } + + try { + const data = await invoke('query_workshop_details', { workshopIds: missingIds }); + reportWorkshopWarnings(data.warnings); + const titleById = new Map(); + for (const item of data.items || []) { + const workshopId = normalizeText(item?.publishedfileid || item?.workshopId); + if (!workshopId) continue; + const title = normalizeText(item?.title || cache?.[workshopId]?.title); + if (title) { + titleById.set(workshopId, title); + } + } + + return mergeWorkshopPageDetails(details, { + requiredItems: (details.requiredItems || []).map((item) => ({ + ...item, + title: normalizeText(item.title) || titleById.get(normalizeText(item.workshopId)) || item.title, + })), + }); + } catch (err) { + console.warn('Steam SDK required item title enrichment failed:', err); + return details; + } +} + function accountIdToSteamId(accountId: unknown): string { const normalized = normalizeText(accountId); if (!/^\d+$/.test(normalized)) return ''; @@ -828,7 +940,7 @@ export async function fetchWorkshopPageDetails(workshopId: string, source: strin try { const html = await fetchWorkshopHtml(url, source); const details = mergeWorkshopPageDetails(snapshot, parseWorkshopPageDetails(html)); - return details; + return enrichRequiredItemsWithSdkDetails(details); } catch (err) { if (snapshot) return snapshot; throw err; @@ -838,10 +950,35 @@ export async function fetchWorkshopPageDetails(workshopId: string, source: strin export async function getWorkshopPageSnapshot(workshopId: string): Promise { const cache = await readWorkshopItemCache(); const detail = cache[workshopId]; - if (!detail) return null; - const snapshot = mergeWorkshopPageDetails(null, mapCachedDetailToPageDetails(detail)); - if (!hasUsefulPageSnapshot(snapshot)) return null; - return snapshot; + if (detail) { + const snapshot = await enrichRequiredItemsWithSdkDetails( + mergeWorkshopPageDetails(null, mapCachedDetailToPageDetails(detail, cache)), + cache, + ); + if (hasUsefulPageSnapshot(snapshot)) { + return snapshot; + } + } + + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + if (!capabilities?.canQueryItems) { + return null; + } + + try { + const data = await invoke('query_workshop_item', { workshopId }); + reportWorkshopWarnings(data.warnings); + const snapshot = await enrichRequiredItemsWithSdkDetails( + mergeWorkshopPageDetails(null, mapSteamDetailToPageDetails(data.item, cache)), + cache, + ); + return hasUsefulPageSnapshot(snapshot) ? snapshot : null; + } catch (err) { + console.warn('Steam SDK snapshot query failed:', err); + return null; + } } export async function persistWorkshopPageDetails(workshopId: string, details: any, source: string) { From 1b6bf61aeeae67f9fddfe8c412122f4ce63b3d13 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Thu, 9 Jul 2026 21:38:06 +0800 Subject: [PATCH 46/81] feat: implement integrated steam workshop command handlers and settings UI --- README.md | 1 + docs/steamworks-sdk-details.md | 143 ++++++++++ src-tauri/src/commands/handlers.rs | 392 ++++++++++++++++++++++++--- src-tauri/src/commands/mod.rs | 6 +- src-tauri/src/commands/types.rs | 2 + src-tauri/src/steam/bridge.rs | 1 + src/components/SettingsView.test.tsx | 2 + src/components/SettingsView.tsx | 31 ++- src/hooks/useAddonManager.test.tsx | 4 +- src/hooks/useAddonManager.ts | 3 + src/i18n.tsx | 8 +- src/types/addon.ts | 1 + 12 files changed, 543 insertions(+), 51 deletions(-) create mode 100644 docs/steamworks-sdk-details.md diff --git a/README.md b/README.md index c33dfe7..9c1fc7b 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ - 如果你的网络环境随时能访问网页版创意工坊, 建议前往设置禁用 Steamworks SDK. - 如果 Left 4 Addons 在本次启动后已经调用过一次 Steamworks SDK, 那么即使随后在设置中禁用 Steamworks SDK, Steam 侧“正在游玩 L4D2”的状态也不保证会立即消失; 某些情况下需要完全关闭 Left 4 Addons 才会解除. - 如果 Steam 侧“正在游玩 L4D2”的状态是由 Left 4 Addons 拉起的, 那么从 Steam 客户端停止它会导致 Left 4 Addons 应用程序一并退出. + - 更完整的 SDK 搜索/下载限制与实现细节见 `docs/steamworks-sdk-details.md`. --- diff --git a/docs/steamworks-sdk-details.md b/docs/steamworks-sdk-details.md new file mode 100644 index 0000000..998250e --- /dev/null +++ b/docs/steamworks-sdk-details.md @@ -0,0 +1,143 @@ +# Steamworks SDK 细节与限制 + +本文档记录 Left 4 Addons 当前接入 Steamworks SDK 时已经确认的行为、限制和取舍. + +## 1. 总结 + +- Steamworks SDK 的查询结果与 Steam Community 网页端并不等价, 目前无法仅依靠 SDK 实现高可用性的精准搜索. +- L4A (Left 4 Addons) 可通过 Steamworks SDK 触发 Steam 下载附件, 但过程不可靠. 这也是为何我们要尽可能回退到“通过 URL 下载”方案的原因之一. + +## 2. 搜索行为限制 + +### 2.1 SDK 搜索不等同于网页搜索 + +当前使用的 SDK 搜索基于 `ISteamUGC` 文本检索能力. 实测它和网页端 `steamcommunity.com/workshop/browse` 的语义差异很大: + +多词查询经常劣化为宽松匹配 (逻辑近似 OR, 而不是网页端符合预期的短语或相关性匹配): + - 搜索 `Early Days` + - 网页端可返回 `Early Days` 战役相关 `Part 1-6` + - SDK 侧则可能返回 `Early 2008 style`、`Early Zombie`、`Day Break`、`Release Day Bots` + +单词查询结果同样可能严重偏离: + - 搜索 `Cambalache` + - 预期结果是 `Cambalache 2` + - SDK 结果里完全没有相关附件 + + +SDK 文本搜索目前完全无法复刻网页端的搜索体验. 如果需要“像网页那样”的精准搜索, 仍必须依赖网页抓取或混合方案. + +### 2.2 分类浏览无法纯 SDK 完整复原 + +当前分类浏览依赖 Steam Community 首页 SSR 数据里的 `declared_tags_v5` 等页面信息. + +- “分类树/分类浏览”目前是网页端或 SDK+HTML 混合能力 +- 截止目前, 仅靠纯 Steamworks SDK 不可能实现该功能. + +## 3. 下载行为限制 + +### 3.1 文件格式与较新创意工坊物品不同 + +对于较新的游戏 (如 Wallpaper Engine) , 创意工坊物品通常是一个包含多个文件的文件夹 (甚至涉及更复杂的着色器编译等) . + +但对 L4D2 而言, Workshop 物品由游戏自身处理并安装到 `steamapps/common/Left 4 Dead 2/left4dead2/addons/workshop/` 目录下. + +通过 SDK 触发的下载产物位于: + +`steamapps/workshop/content/550//_legacy.bin` + +> 注: _legacy.bin 似乎是 Steam 针对部分较老的单文件 (如起源引擎用的 `.vpk`) 的重命名机制, +> 但具体细节仍不透明. +> 尽管将 _legacy.bin 手动改名为 .vpk 后能够正常工作, 但这已经是所有下载问题中最小的一个. + +### 3.2 Steam 下载进度不可靠 + +Steam客户端的下载管理会出现非真实状态的情况: + +- Steam 下载管理里长时间显示 0 进度 / 0 网络 / 0 磁盘 +- 可能会进入“正在停止” +- 没有稳定可见的进度条 +- 突然显示已完成 + +虽然 L4A 内部获取的进度看起来正常, 但无法保证底层确实在正常工作. + +### 3.3 无法重新下载或只能保留两个副本 + +- 如果直接删除 `steamapps/workshop/content/550/...` 下的文件夹或里面的文件, + 再次请求后 Steam 也不会重新下载. +- 取消订阅后退出游戏进程 (无论是L4A还是求生之路 即游戏退出未运行的状态时), + Steam 不会删除目标文件/文件夹. +- Steam 可能还持久化保存了如 Manifest 状态. + 虽怀疑是 `steamapps/workshop/appworkshop_550.acf` 作祟, + 但删除后重启 Steam 仍然不起作用 + 这是 L4A 无法稳定通过 SDK 下载附件它的核心阻碍之一. + +在 Wallpaper Engine 侧测试时发现, +如果在 Steam 自动删除前手动删除了物品缓存 (无论是否已订阅) 都会导致严重状态错误. +最致命的影响之一是永久无法再次订阅该物品 +(除非去网页端订阅, 或换一台计算机操作, 但后续行为暂未测试, 具体后续行为也是个未知数). + +## 4. Steamworks SDK 无法直接获取下载 URL + +SDK 里确实有 `ISteamRemoteStorage::GetPublishedFileDetails` + +> `Deprecated - Only used with the deprecated RemoteStorage based Workshop API` + +这是旧版 RemoteStorage API 的接口,且其返回值中并没有暴露文件的直接下载链接。 + +虽然它看起来和网页端免鉴权的 Web API +即 (https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/) +名字几乎一样, 但 SDK 侧偏偏缺少了最核心的 `file_url` 字段, +导致无法在不额外请求网络 API . + +## 5. 从旧README中迁移过来的已知缺点 + +- 使用 Steamworks SDK 会使 Left 4 Addons 在 Steam 状态中“伪装”成求生之路, 以此来获取访问创意工坊的权限. +- **VAC 信任问题**: + 如果你在使用 L4A 调用 SDK 时启动游戏. 会导致游戏本体短暂不被 VAC 信任. + 这不会导致封号,但你必须彻底退出 L4A 后再启动游戏才能恢复正常。 +- 仅靠 SDK 无法满足现有需求, 但某些加速器只给加速 Steam 相关进程. 求生之路本体都是自己下的Addon 那些加速器那就是啥用没有 挂个梯子吧. +- 只要 L4A 调用过一次 SDK, Steam 就会一直显示你“正在玩求生之路”, 直到 L4A 进程完全结束. + 如果你在 Steam 库里好奇点了一下“停止”,被强制关掉的会是 L4A 本身. +- 只要 Left 4 Addons 调用一次 SDK, Steam 会显示你正在玩求生之路, 直到你退出 L4A. +- 由于 L4D2 创意工坊的 Addon 是游戏内/第三方直连下载 + 市面上那些仅加速特定进程的加速器对此毫无作用. + 建议根据自身网络环境决定是否禁用 Steamworks SDK, 或直接使用系统级代理工具. + +## 有趣的事实 + +你别管它有不有趣 我就是不知道放哪里了而已 + +- Watt Toolkit (Steam++) 是我目前唯一已知的免费加速器, + 且所有已知的Steam域名都会经过其提供的公益 CDN. + 它也可以加速 L4A 和几乎满速的创意工坊下载. +- L4A 调用 SDK 后, 你可以在另一台计算机串游 L4A 本体. + 但如果Tauri拉起的 WebView进程与 L4A 独立, 则会持续显示黑屏. +- UU 加速器的加速 Steam 是基于进程的. 可能与你想象的正好相反. +- 某些加速器虽然提供低延迟体验, 但通常有速率限制. + 很有可能你不挂加速器在可以直连的情况下 + 下载的速度的比挂着加速器要快得多 +- UU加速器加速求生之路只加速联机, 不加速创意工坊 + +至于为什么只有这两个加速器, 因为我只用过这两个. + +## 后记 + +我要写点有人味的东西, 这样你才不会觉得太枯燥(或许) 或者完全是用 AI 生成的垃圾什么的. + +清理了一下 GPT 生成的无意义描写. 依旧给GPT语擦屁股. 虽然我叫它狗屁通 但写出来的东西也不完全是狗屁不通. 跑分还怪高的嘞 + +没有网页版的创意工坊简直就是地狱, 这个 SDK 的搜索逻辑 ——小红车用户自适应太牛逼了. + +写了这么多缺点, Steamworks SDK 也并非没有好处. 相反效果还挺好的, 只是明显只靠它不是最佳解决方案. + +Steamworks SDK 特色功能: +- (几乎) 无速率限制的 API 请求. 或者 Steam 自己已经帮我们处理好了这点 +- 不需要爬网站就可以解析创意工坊物品的依赖 (这样不会战役Part冲突就瞎几把报“假阳性"了) +- 我可以一把抓到你的所有创意工坊订阅 (但是我好像拉不到收藏的合集 但自己导入L4A其实也很简单了) + +你别看我现在就写出来这3个 其实算是很强大的了 因为我总不可能爬几百个附件对应的网站. +也可以其实, 那 Too many request 伺候就你就不嘻嘻了. + +希望 V 社越做越好吧. 别跟我说史山的事, 至少我希望之后 SDK 和创意工坊相关的逻辑不会这么屎. 大概多久? 可能是 2028 年吧. 2028 年应该有足够强大的模型给史山擦屁股了. + +最近几周创意工坊网页更新很棒. SDK 只要能做一样的事那算是祖坟冒青烟了. diff --git a/src-tauri/src/commands/handlers.rs b/src-tauri/src/commands/handlers.rs index 4fe6f70..738cfec 100644 --- a/src-tauri/src/commands/handlers.rs +++ b/src-tauri/src/commands/handlers.rs @@ -115,6 +115,125 @@ fn source_position(source_order: &[String], source: &str, fallback: usize) -> us .unwrap_or(fallback) } +fn find_sdk_installed_workshop_file(install_folder: &Path) -> Result, String> { + if !install_folder.exists() { + return Ok(None); + } + + if install_folder.is_file() { + let filename = install_folder + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if filename.ends_with(".vpk") || filename.ends_with("_legacy.bin") { + return Ok(Some(install_folder.to_path_buf())); + } + return Ok(None); + } + + if !install_folder.is_dir() { + return Ok(None); + } + + let mut vpk_candidates = Vec::new(); + let mut legacy_candidates = Vec::new(); + let mut file_candidates = Vec::new(); + + for entry in fs::read_dir(install_folder).map_err(|e| { + format!( + "Failed to read SDK install folder {}: {}", + install_folder.display(), + e + ) + })? { + let entry = + entry.map_err(|e| format!("Failed to inspect SDK install folder entry: {}", e))?; + let file_type = entry + .file_type() + .map_err(|e| format!("Failed to read SDK install folder entry type: {}", e))?; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let filename = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if filename.ends_with(".vpk") { + vpk_candidates.push(path); + } else if filename.ends_with("_legacy.bin") { + legacy_candidates.push(path); + } else { + file_candidates.push(path); + } + } + + vpk_candidates.sort(); + legacy_candidates.sort(); + file_candidates.sort(); + + Ok(vpk_candidates + .into_iter() + .next() + .or_else(|| legacy_candidates.into_iter().next()) + .or_else(|| { + if file_candidates.len() == 1 { + file_candidates.into_iter().next() + } else { + None + } + })) +} + +fn import_sdk_workshop_file( + source_path: &Path, + workshop_id: &str, + workshop_dir: &Path, +) -> Result { + if !workshop_dir.exists() { + fs::create_dir_all(workshop_dir).map_err(|e| { + format!( + "Failed to create workshop directory {}: {}", + workshop_dir.display(), + e + ) + })?; + } + + let dest_path = workshop_dir.join(format!("{}.vpk", workshop_id)); + let temp_dest_path = workshop_dir.join(format!("{}.vpk.download", workshop_id)); + + if temp_dest_path.exists() { + fs::remove_file(&temp_dest_path).map_err(|e| { + format!( + "Failed to remove stale SDK import temp file {}: {}", + temp_dest_path.display(), + e + ) + })?; + } + + fs::copy(source_path, &temp_dest_path).map_err(|e| { + format!( + "Failed to copy SDK workshop file from {} to {}: {}", + source_path.display(), + temp_dest_path.display(), + e + ) + })?; + fs::rename(&temp_dest_path, &dest_path).map_err(|e| { + format!( + "Failed to finalize SDK workshop file from {} to {}: {}", + temp_dest_path.display(), + dest_path.display(), + e + ) + })?; + + Ok(dest_path) +} + fn resolve_cache_file(cache_dir: &Path, image_path: &str) -> Result { let filename = image_path.strip_prefix("/cache/").unwrap_or(image_path); let rel_path = Path::new(filename); @@ -724,6 +843,7 @@ pub fn load_db( enable_dummy_bypass: false, suppress_sdk_unavailable_warning: false, disable_steamworks_sdk: false, + force_steamworks_sdk_download: false, workshop_source_settings: WorkshopSourceSettings::default(), }, addons: HashMap::new(), @@ -1938,8 +2058,15 @@ async fn attempt_bridge_download( let expected_path = workshop_dir.join(format!("{}.vpk", workshop_id)); let expected_disabled_path = workshop_dir.join(format!("{}.vpk.disabled", workshop_id)); - - for _ in 0..40 { + let started_at = Instant::now(); + let timeout = Duration::from_secs(120); + let mut saw_download_activity = false; + let mut saw_install_folder = false; + let mut last_reported_percent = 0u32; + let mut last_status_log = Instant::now() - Duration::from_secs(30); + let mut last_status_signature = String::new(); + + while started_at.elapsed() < timeout { if is_download_cancelled(state, workshop_id)? { return Err(DOWNLOAD_CANCELLED_ERR.to_string()); } @@ -1950,12 +2077,39 @@ async fn attempt_bridge_download( Err(err) => return Err(err), }; + let item_states = status.item_state.join(","); + let install_folder = status.install_folder.as_deref().unwrap_or(""); + let installed_dir = if install_folder.trim().is_empty() { + None + } else { + Some(PathBuf::from(install_folder)) + }; + let status_signature = format!( + "installed={} downloaded={:?} total={:?} installFolder={} itemState={}", + status.installed, status.downloaded, status.total, install_folder, item_states + ); + if status_signature != last_status_signature + || last_status_log.elapsed() >= Duration::from_secs(5) + { + println!( + "Steam SDK download status for {}: {}", + workshop_id, status_signature + ); + last_status_signature = status_signature; + last_status_log = Instant::now(); + } + if let (Some(downloaded), Some(total)) = (status.downloaded, status.total) { - let percent = if total > 0 { + if downloaded > 0 || total > 0 { + saw_download_activity = true; + } + let raw_percent = if total > 0 { ((downloaded as f64 / total as f64) * 100.0) as u32 } else { 0 }; + let percent = raw_percent.max(last_reported_percent); + last_reported_percent = percent; let _ = app_handle.emit( "download-progress", DownloadProgress { @@ -1964,7 +2118,26 @@ async fn attempt_bridge_download( downloaded, total, source: "steam-sdk".to_string(), - phase: "download".to_string(), + phase: if status.installed { + "install".to_string() + } else { + "download".to_string() + }, + }, + ); + } + + if (status.installed || installed_dir.is_some()) && last_reported_percent < 99 { + last_reported_percent = 99; + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.to_string(), + percent: 99, + downloaded: status.downloaded.unwrap_or(0), + total: status.total.unwrap_or(0), + source: "steam-sdk".to_string(), + phase: "install".to_string(), }, ); } @@ -1984,13 +2157,60 @@ async fn attempt_bridge_download( return Ok(true); } - if status.installed && expected_path.exists() { - return Ok(true); + if let Some(ref folder) = installed_dir { + saw_install_folder = true; + if let Some(source_path) = find_sdk_installed_workshop_file(folder)? { + println!( + "Steam SDK install path resolved for {}: {} -> importing {}", + workshop_id, + folder.display(), + source_path.display() + ); + import_sdk_workshop_file(&source_path, workshop_id, workshop_dir)?; + let final_total = status.total.unwrap_or_else(|| { + fs::metadata(&source_path) + .map(|meta| meta.len()) + .unwrap_or(0) + }); + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.to_string(), + percent: 100, + downloaded: final_total, + total: final_total, + source: "steam-sdk".to_string(), + phase: "import".to_string(), + }, + ); + return Ok(true); + } else { + println!( + "Steam SDK install path present for {} but no importable file found yet: {}", + workshop_id, + folder.display() + ); + } + } + + if status.installed && !saw_install_folder { + eprintln!( + "Steam SDK marked workshop item {} installed without install folder; itemState={}, downloaded={:?}, total={:?}", + workshop_id, item_states, status.downloaded, status.total + ); } std::thread::sleep(std::time::Duration::from_millis(250)); } + eprintln!( + "Steam SDK workshop download did not complete within timeout for {}: saw_download_activity={}, saw_install_folder={}, expected_path_exists={}, item_state_polling_fell_through", + workshop_id, + saw_download_activity, + saw_install_folder, + expected_path.exists() || expected_disabled_path.exists(), + ); + Ok(false) } @@ -2666,6 +2886,7 @@ pub async fn save_settings( enable_dummy_bypass: bool, suppress_sdk_unavailable_warning: bool, disable_steamworks_sdk: bool, + force_steamworks_sdk_download: bool, workshop_source_settings: Option, state: State<'_, crate::AppState>, app_handle: AppHandle, @@ -2679,6 +2900,7 @@ pub async fn save_settings( db.settings.enable_dummy_bypass = enable_dummy_bypass; db.settings.suppress_sdk_unavailable_warning = suppress_sdk_unavailable_warning; db.settings.disable_steamworks_sdk = disable_steamworks_sdk; + db.settings.force_steamworks_sdk_download = force_steamworks_sdk_download; if let Some(workshop_source_settings) = workshop_source_settings { db.settings.workshop_source_settings = workshop_source_settings; } @@ -4469,11 +4691,12 @@ pub async fn download_addon( clear_download_cancellation(&state, &workshop_id)?; let result = async { - let (workshop_dir, source_policy) = { + let (workshop_dir, source_policy, force_sdk_download) = { let db = state.db.lock().await; ( PathBuf::from(&db.settings.workshop_dir), SourcePolicy::from_settings(&db.settings), + db.settings.force_steamworks_sdk_download, ) }; let details_list = fetch_steam_details_hybrid( @@ -4493,6 +4716,11 @@ pub async fn download_addon( .get("title") .and_then(|t| t.as_str()) .unwrap_or("Workshop Item"); + let file_url = details + .get("file_url") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + let prefer_sdk_download = force_sdk_download || file_url.is_none(); if !workshop_dir.exists() { fs::create_dir_all(&workshop_dir).map_err(|e| e.to_string())?; @@ -4518,46 +4746,87 @@ pub async fn download_addon( .map_err(|e| format!("Failed to remove stale download file: {}", e))?; } - match attempt_bridge_download( - &state, - &state.workshop_service, - &workshop_id, - &workshop_dir, - &app_handle, - source_policy.allow_bridge(), - ) - .await - { - Ok(true) => { - crate::watcher::suppress_internal_refresh(&state); - let mut db = state.db.lock().await; - scan_addons_internal( - &mut db, - &state.settings_path, - &state.groups_path, - &state.known_addons_path, - &state.cache_dir, - &state.workshop_service, - ) - .await?; - return Ok(database_with_workshop_cache( - &db, - &state.workshop_cache_path, - &state.known_addons_path, - )); + if prefer_sdk_download { + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + source_policy.allow_bridge(), + ) + .await + { + Ok(true) => { + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} } - Ok(false) => {} - Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), - Err(_) => {} } - let file_url = details - .get("file_url") - .and_then(|u| u.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| { - "Workshop item has no download URL (SDK did not install it and Web API has no direct file URL)".to_string() - })?; + let file_url = match file_url { + Some(url) => url, + None if !prefer_sdk_download => { + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + source_policy.allow_bridge(), + ) + .await + { + Ok(true) => { + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} + } + + return Err( + "Workshop item has no direct download URL, and Steamworks SDK fallback did not install it".to_string() + ); + } + None => { + return Err( + "Workshop item has no download URL (Steamworks SDK did not install it and no direct Web API file_url is available)".to_string() + ); + } + }; println!("Downloading: {} (URL: {})", title, file_url); let client = reqwest::Client::new(); @@ -4567,6 +4836,41 @@ pub async fn download_addon( .await .map_err(|e| format!("Download request failed: {}", e))?; if !response.status().is_success() { + if !prefer_sdk_download { + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + source_policy.allow_bridge(), + ) + .await + { + Ok(true) => { + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} + } + } + return Err(format!( "Download server responded with status {}", response.status() diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 1272c69..097923f 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,9 +6,9 @@ pub use handlers::{ download_addon, fetch_collection, fetch_workshop_html, get_addons, get_background_tasks, get_cache_image, get_settings, get_workshop_cache, get_workshop_capabilities, group_action, move_addons, open_url, open_workshop, persist_workshop_page_details, query_workshop_collection, - query_workshop_details, query_workshop_home, query_workshop_item, query_workshop_items, record_workshop_items_seen, - rename_addon, rename_addons, save_background_task_snapshot, save_settings, steam_sync, - toggle_addons, + query_workshop_details, query_workshop_home, query_workshop_item, query_workshop_items, + record_workshop_items_seen, rename_addon, rename_addons, save_background_task_snapshot, + save_settings, steam_sync, toggle_addons, }; pub use handlers::{load_db, save_db_internal}; pub use types::*; diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index 2add6e7..5c17c04 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -13,6 +13,8 @@ pub struct Settings { pub suppress_sdk_unavailable_warning: bool, #[serde(rename = "disableSteamworksSdk", default)] pub disable_steamworks_sdk: bool, + #[serde(rename = "forceSteamworksSdkDownload", default)] + pub force_steamworks_sdk_download: bool, #[serde(rename = "workshopSourceSettings", default)] pub workshop_source_settings: WorkshopSourceSettings, } diff --git a/src-tauri/src/steam/bridge.rs b/src-tauri/src/steam/bridge.rs index a509c96..3312d58 100644 --- a/src-tauri/src/steam/bridge.rs +++ b/src-tauri/src/steam/bridge.rs @@ -195,6 +195,7 @@ fn bridge_library_name() -> &'static str { } } +#[cfg_attr(target_os = "windows", allow(dead_code))] fn steam_api_library_name() -> &'static str { if cfg!(target_os = "windows") { "steam_api64.dll" diff --git a/src/components/SettingsView.test.tsx b/src/components/SettingsView.test.tsx index 8ecd7d1..61b0150 100644 --- a/src/components/SettingsView.test.tsx +++ b/src/components/SettingsView.test.tsx @@ -10,6 +10,7 @@ describe('SettingsView', () => { enableDummyBypass: false, suppressSdkUnavailableWarning: false, disableSteamworksSdk: false, + forceSteamworksSdkDownload: false, workshopSourceSettings: { preset: 'conservative', allowSteamworksSdk: true, @@ -45,6 +46,7 @@ describe('SettingsView', () => { false, false, true, + false, baseSettings.workshopSourceSettings, ); }); diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index a8446be..374cef8 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -12,6 +12,7 @@ interface SettingsViewProps { enableDummyBypass: boolean, suppressSdkUnavailableWarning: boolean, disableSteamworksSdk: boolean, + forceSteamworksSdkDownload: boolean, workshopSourceSettings: WorkshopSourceSettings, ) => Promise; } @@ -43,6 +44,7 @@ export const SettingsView: React.FC = ({ const [enableDummyBypass, setEnableDummyBypass] = useState(false); const [suppressSdkUnavailableWarning, setSuppressSdkUnavailableWarning] = useState(false); const [disableSteamworksSdk, setDisableSteamworksSdk] = useState(false); + const [forceSteamworksSdkDownload, setForceSteamworksSdkDownload] = useState(false); const [workshopSourceSettings, setWorkshopSourceSettings] = useState(DEFAULT_SOURCE_SETTINGS); useEffect(() => { @@ -50,6 +52,7 @@ export const SettingsView: React.FC = ({ setEnableDummyBypass(settings.enableDummyBypass || false); setSuppressSdkUnavailableWarning(settings.suppressSdkUnavailableWarning || false); setDisableSteamworksSdk(settings.disableSteamworksSdk || false); + setForceSteamworksSdkDownload(settings.forceSteamworksSdkDownload || false); setWorkshopSourceSettings({ ...DEFAULT_SOURCE_SETTINGS, ...(settings.workshopSourceSettings || {}), @@ -68,6 +71,7 @@ export const SettingsView: React.FC = ({ enableDummyBypass, suppressSdkUnavailableWarning, disableSteamworksSdk, + forceSteamworksSdkDownload, workshopSourceSettings, ); }; @@ -392,12 +396,37 @@ export const SettingsView: React.FC = ({ setDisableSteamworksSdk(e.target.checked)} + onChange={(e) => { + const checked = e.target.checked; + setDisableSteamworksSdk(checked); + if (checked) { + setForceSteamworksSdkDownload(false); + } + }} disabled={isSubmitting} />
+
+
+ +
+ {t('settings.forceSteamworksSdkDownloadDesc')} +
+
+ +
@@ -219,52 +211,23 @@ export const AddonCard: React.FC = ({
-
- -
- {groups.map(g => ( - - ))} - {itemGroup && ( - - )} - {groups.length === 0 && !itemGroup && ( - - )} -
-
+ {addon.workshopId ? ( -
- -
- - - -
-
+ ) : addonUrl ? ( + +
+
+
+ ); + } + + return ( +
+
+
+

{t('dependencyPrompt.title', '发现缺失的依赖项')}

+
+ {t('dependencyPrompt.desc', '这些组件是您已安装或正在下载的附件所需的依赖。')} + + {t('dependencyPrompt.goToSettings', '前往设置修改默认行为')} + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + setSearchQuery('')} /> + )} +
+
+ {t('dependencyPrompt.selectedCount', '已选: {{count}}', { count: selectedIds.size })} +
+
+ +
+
handleSelectAll(!allFilteredSelected)} + > +
+ {(allFilteredSelected || someFilteredSelected) && } +
+ {t('dependencyPrompt.selectAll', '全选 (本页)')} +
+
+ +
+ {filteredDependencies.length === 0 ? ( +
+ {t('dependencyPrompt.noResults', '没有找到匹配的依赖项')} +
+ ) : ( +
+ {filteredDependencies.map(addon => { + const id = addon.workshopId || addon.id; + const title = addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName || id; + const author = addon.steamDetails?.creator_name || addon.addonInfo?.addonauthor || addon.workshopDetails?.authorName || t('common.unknownAuthor'); + const imageSrc = addon.imagePath || addon.workshopDetails?.previewUrl; + const isSelected = selectedIds.has(id); + + return ( +
handleSelect(id, !isSelected)} + style={{ + display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 12px', + borderRadius: '8px', + background: isSelected ? 'var(--md-sys-color-secondary-container)' : 'var(--md-sys-color-surface-container)', + outline: isSelected ? '2px solid var(--md-sys-color-primary)' : '1px solid transparent', + cursor: 'pointer', transition: 'all 0.2s' + }} + > +
+ {imageSrc ? ( + + ) : ( + + )} +
+
+
+ {title} +
+
+ {author} + + ID: {id} +
+
+
+ ); + })} +
+ )} +
+ +
+ {isScanning ? ( + + ) : ( + + )} + + {selectedIds.size > 0 && ( + + )} +
+
+
+ ); +}; diff --git a/src/components/SettingsView.test.tsx b/src/components/SettingsView.test.tsx index 8007f5b..07dab87 100644 --- a/src/components/SettingsView.test.tsx +++ b/src/components/SettingsView.test.tsx @@ -20,6 +20,8 @@ describe('SettingsView', () => { allowSteamCommunityHtml: true, allowSdkHtmlHybrid: false, sdkHtmlScope: 'search', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], cacheRetention: 'keep', }, diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index bdb2ed8..7954c6d 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { FolderOpen, Info, RefreshCw, FlaskConical, Languages, Check, Cpu, Database, Download, AlertTriangle } from 'lucide-react'; -import { Settings, WorkshopSdkHtmlScope, WorkshopSourceSettings } from '../types/addon'; +import { DependencyRefreshMode, Settings, WorkshopSdkHtmlScope, WorkshopSourceSettings, DependencyMissingBehavior } from '../types/addon'; import { useTranslation } from 'react-i18next'; import { TransHTML } from './TransHTML'; import { DEFAULT_WORKSHOP_SOURCE_SETTINGS, normalizeWorkshopSourceSettings } from '../utils/workshopSourceSettings'; @@ -20,6 +20,7 @@ interface SettingsViewProps { disableSteamworksSdk: boolean, forceSteamworksSdkDownload: boolean, maxDownloadRetries: number, + dependencyMissingBehavior: DependencyMissingBehavior, workshopSourceSettings: WorkshopSourceSettings, ) => Promise; } @@ -31,6 +32,7 @@ const SOURCE_LABEL_KEYS: Record = { }; const SCOPE_OPTIONS: WorkshopSdkHtmlScope[] = ['disabled', 'search', 'navigation', 'all']; +const DEPENDENCY_REFRESH_OPTIONS: DependencyRefreshMode[] = ['always', 'cache-missing']; function clampDownloadConcurrency(value: number) { if (!Number.isFinite(value)) { @@ -53,6 +55,7 @@ export const SettingsView: React.FC = ({ const [disableSteamworksSdk, setDisableSteamworksSdk] = useState(false); const [forceSteamworksSdkDownload, setForceSteamworksSdkDownload] = useState(false); const [maxDownloadRetriesInput, setMaxDownloadRetriesInput] = useState('3'); + const [dependencyMissingBehavior, setDependencyMissingBehavior] = useState('ask'); const [workshopSourceSettings, setWorkshopSourceSettings] = useState(DEFAULT_WORKSHOP_SOURCE_SETTINGS); useEffect(() => { @@ -63,6 +66,7 @@ export const SettingsView: React.FC = ({ setDisableSteamworksSdk(settings.disableSteamworksSdk || false); setForceSteamworksSdkDownload(settings.forceSteamworksSdkDownload || false); setMaxDownloadRetriesInput(String(settings.maxDownloadRetries ?? 3)); + setDependencyMissingBehavior(settings.dependencyMissingBehavior || 'ask'); setWorkshopSourceSettings(normalizeWorkshopSourceSettings(settings.workshopSourceSettings)); }, [settings]); @@ -85,6 +89,7 @@ export const SettingsView: React.FC = ({ disableSteamworksSdk, forceSteamworksSdkDownload, maxRetries, + dependencyMissingBehavior, workshopSourceSettings, ); }; @@ -384,6 +389,29 @@ export const SettingsView: React.FC = ({
+
+ + setDependencyMissingBehavior(val as DependencyMissingBehavior)} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> + {dependencyMissingBehavior === 'ignore' && ( +
+ + {t('settings.behaviorIgnoreWarning', 'Warning: Ignoring dependencies may cause addons to not work properly. Dependency checks will be skipped.')} +
+ )} +
+
+
+ +

+ {t('settings.dependencyRefreshDesc')} +

+
+
+ + ({ value: mode, label: t(`settings.dependencyRefresh.${mode}`) }))} + value={workshopSourceSettings.dependencySdkRefresh} + onChange={(value) => updateSourceSettings({ dependencySdkRefresh: value as DependencyRefreshMode })} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> +
+
+ + ({ value: mode, label: t(`settings.dependencyRefresh.${mode}`) }))} + value={workshopSourceSettings.dependencyHtmlRefresh} + onChange={(value) => updateSourceSettings({ dependencyHtmlRefresh: value as DependencyRefreshMode })} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> +
+
+
+ {workshopSourceSettings.allowSteamCommunityHtml && (
@@ -341,6 +352,7 @@ export const TaskCenterModal: React.FC = ({ {failedTasks.map((task) => { const workshopId = task.targetIds[0]; const isWarningTask = task.kind === 'warning'; + const isPartialDependencyCheck = task.kind === 'dependency-check' && (task.dependencyCheck?.failedNodes.length || 0) > 0; const taskTitle = task.title || ( isWarningTask ? t('taskCenter.steamworksUnavailableTitle', 'Steamworks SDK 不可用') @@ -378,6 +390,8 @@ export const TaskCenterModal: React.FC = ({ ? t('taskCenter.warningTitle', '功能受限警告') : task.kind === 'download' ? t('taskCenter.downloadFailed', '下载失败') + : isPartialDependencyCheck + ? t('taskCenter.dependencyPartial', '依赖检查已完成,但部分节点失败') : t('taskCenter.syncFailed', '同步元数据失败')}
@@ -440,6 +454,11 @@ export const TaskCenterModal: React.FC = ({ {t('taskCenter.errorDetails', { err: task.error })}
)} + {isPartialDependencyCheck && task.dependencyCheck?.failedNodes.map((node) => ( +
+ {node.workshopId}: {node.error} +
+ ))}
); })} diff --git a/src/components/WorkshopBrowser.tsx b/src/components/WorkshopBrowser.tsx index 5a4250a..cb76d92 100644 --- a/src/components/WorkshopBrowser.tsx +++ b/src/components/WorkshopBrowser.tsx @@ -53,6 +53,7 @@ export const WorkshopBrowser: React.FC = ({ knownUninstalledAddons, downloadProgress, onDownload, + onDownloadMany, onOpenLink, onImportCollection, onRecordSeenItems, @@ -546,6 +547,7 @@ export const WorkshopBrowser: React.FC = ({ collection={selectedCollection} onClose={() => { setSelectedItem(null); setSelectedCollection(null); }} onDownload={onDownload} + onDownloadMany={onDownloadMany} onOpenLink={onOpenLink} onImportCollection={onImportCollection} onItemNavigate={viewItemDetails} diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx index 54761ec..f9c7a62 100644 --- a/src/components/workshop/WorkshopDetailModal.tsx +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -25,6 +25,7 @@ interface WorkshopDetailModalProps { } | null; onClose: () => void; onDownload: WorkshopBrowserProps['onDownload']; + onDownloadMany?: WorkshopBrowserProps['onDownloadMany']; onOpenLink: WorkshopBrowserProps['onOpenLink']; onImportCollection: WorkshopBrowserProps['onImportCollection']; /** Navigate to an addon detail within the app */ @@ -46,6 +47,7 @@ export const WorkshopDetailModal: React.FC = ({ collection, onClose, onDownload, + onDownloadMany, onOpenLink, onImportCollection, onItemNavigate, @@ -237,7 +239,18 @@ export const WorkshopDetailModal: React.FC = ({ {t('workshop.detail.importAsGroup')} - diff --git a/src/components/workshop/types.ts b/src/components/workshop/types.ts index 69c4ae4..25fda3f 100644 --- a/src/components/workshop/types.ts +++ b/src/components/workshop/types.ts @@ -97,6 +97,7 @@ export interface WorkshopBrowserProps { knownUninstalledAddons: Record; downloadProgress: Record; onDownload: (workshopId: string, title?: string, imagePath?: string) => void; + onDownloadMany?: (items: { workshopId: string; title?: string; imagePath?: string }[]) => void; onOpenLink: (url: string) => void; onImportCollection: (name: string, itemIds: string[]) => void; onRecordSeenItems?: (items: WorkshopItem[], source?: string) => void; diff --git a/src/hooks/useAddonManager.test.tsx b/src/hooks/useAddonManager.test.tsx index 7ad6896..ed1d168 100644 --- a/src/hooks/useAddonManager.test.tsx +++ b/src/hooks/useAddonManager.test.tsx @@ -61,6 +61,8 @@ describe('useAddonManager', () => { allowSteamCommunityHtml: true, allowSdkHtmlHybrid: false, sdkHtmlScope: 'search', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], cacheRetention: 'keep', }, diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index e472a6d..57bb5e7 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -50,6 +50,7 @@ export function useAddonManager() { suppressSdkUnavailableWarning: false, disableSteamworksSdk: false, forceSteamworksSdkDownload: false, + dependencyMissingBehavior: 'ask', workshopSourceSettings: DEFAULT_WORKSHOP_SOURCE_SETTINGS, }); const [loading, setLoading] = useState(true); @@ -160,6 +161,7 @@ export function useAddonManager() { backgroundTasks: managedBackgroundTasks, enqueueDownloads, enqueueWorkshopCrawl, + enqueueDependencyCheck, recordSeenItems, cancelTask, retryTask, @@ -508,6 +510,7 @@ export function useAddonManager() { disableSteamworksSdk: boolean, forceSteamworksSdkDownload: boolean, maxDownloadRetries: number, + dependencyMissingBehavior?: string, workshopSourceSettings?: WorkshopSourceSettings, ) => { if (isSubmitting) return; @@ -522,6 +525,7 @@ export function useAddonManager() { disableSteamworksSdk, forceSteamworksSdkDownload, maxDownloadRetries, + dependencyMissingBehavior, workshopSourceSettings, }, }); @@ -757,10 +761,28 @@ export function useAddonManager() { }); }; + const downloadAddons = async ( + items: (string | { workshopId: string; title?: string; imagePath?: string })[], + source = 'batch-download', + ) => { + const workshopIds = items + .map((item) => typeof item === 'string' ? item : item.workshopId) + .map((id) => id.trim()) + .filter(Boolean); + if (workshopIds.length === 0) return; + setDownloadProgress((prev) => { + const next = { ...prev }; + workshopIds.forEach((workshopId) => { + next[workshopId] = next[workshopId] ?? 0; + }); + return next; + }); + enqueueDownloads(items, source); + }; + // Download addon const downloadAddon = async (workshopId: string, title?: string, imagePath?: string) => { - setDownloadProgress(prev => ({ ...prev, [workshopId]: 0 })); - enqueueDownloads([{ workshopId, title, imagePath }], 'single-download'); + await downloadAddons([{ workshopId, title, imagePath }], 'single-download'); }; // Delete addon(s) @@ -1201,14 +1223,7 @@ export function useAddonManager() { // Batch download uninstalled addons const handleBatchDownload = async (workshopIds: string[]) => { if (workshopIds.length === 0) return; - setDownloadProgress(prev => { - const next = { ...prev }; - workshopIds.forEach((wId) => { - next[wId] = next[wId] ?? 0; - }); - return next; - }); - enqueueDownloads(workshopIds, 'batch-download'); + await downloadAddons(workshopIds, 'batch-download'); addToast(t('toasts.batchDownloadSuccess', { count: workshopIds.length }), 'success'); }; @@ -1530,10 +1545,12 @@ export function useAddonManager() { handleBatchAddToGroup, handleFilterTabChange, downloadAddon, + downloadAddons, deleteAddons, executeRealDelete, handleBatchDownload, enqueueWorkshopCrawl, + enqueueDependencyCheck, recordSeenItems, cancelTask, retryTask, diff --git a/src/hooks/useBackgroundTasks.test.tsx b/src/hooks/useBackgroundTasks.test.tsx new file mode 100644 index 0000000..23df79d --- /dev/null +++ b/src/hooks/useBackgroundTasks.test.tsx @@ -0,0 +1,71 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useBackgroundTasks } from './useBackgroundTasks'; + +const { mockInvoke, mockFetchDependencySnapshot } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockFetchDependencySnapshot: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (command: string, args?: Record) => mockInvoke(command, args), +})); + +vi.mock('../services/workshopClient', () => ({ + fetchWorkshopDependencySnapshot: (workshopId: string) => mockFetchDependencySnapshot(workshopId), + fetchWorkshopItem: vi.fn(), +})); + +describe('useBackgroundTasks dependency checks', () => { + beforeEach(() => { + mockInvoke.mockReset(); + mockFetchDependencySnapshot.mockReset(); + mockInvoke.mockImplementation((command) => { + if (command === 'get_background_tasks') return Promise.resolve([]); + if (command === 'persist_workshop_page_details') { + return Promise.resolve({ addons: {}, knownUninstalledAddons: {}, groups: [], settings: {} }); + } + return Promise.resolve(); + }); + }); + + test('visits a dependency chain once and terminates a cycle', async () => { + const relations: Record = { + A: ['B'], + B: ['C'], + C: ['A'], + }; + mockFetchDependencySnapshot.mockImplementation(async (workshopId: string) => ({ + imageGallery: [], + tags: [], + parentCollections: [], + requiredItems: relations[workshopId].map((id) => ({ workshopId: id, title: id })), + })); + const updateLocalState = vi.fn(); + const { result } = renderHook(() => useBackgroundTasks({ + enabled: true, + downloadConcurrency: 1, + addons: {}, + knownUninstalledAddons: {}, + updateLocalState, + onDownloadSuccess: vi.fn(), + onDownloadCancelled: vi.fn(), + onTaskError: vi.fn(), + })); + + await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_background_tasks', undefined)); + act(() => { + result.current.enqueueDependencyCheck(['A'], 'test'); + }); + + await waitFor(() => { + const task = result.current.backgroundTasks.find((candidate) => candidate.kind === 'dependency-check'); + expect(task?.status).toBe('completed'); + expect(task?.dependencyCheck?.completedCount).toBe(3); + }); + + expect(mockFetchDependencySnapshot.mock.calls.map(([id]) => id)).toEqual(['A', 'B', 'C']); + expect(mockInvoke.mock.calls.filter(([command]) => command === 'persist_workshop_page_details')).toHaveLength(3); + expect(updateLocalState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/hooks/useBackgroundTasks.ts b/src/hooks/useBackgroundTasks.ts index 701df41..94a23d1 100644 --- a/src/hooks/useBackgroundTasks.ts +++ b/src/hooks/useBackgroundTasks.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { Addon, BackgroundTask, DatabasePayload, Settings } from '../types/addon'; -import { fetchWorkshopItem } from '../services/workshopClient'; +import { fetchWorkshopDependencySnapshot, fetchWorkshopItem } from '../services/workshopClient'; const WORKSHOP_CRAWL_COOLDOWN_MS = 6000; @@ -55,9 +55,12 @@ export function useBackgroundTasks({ const tasksRef = useRef([]); const activeDownloadsRef = useRef(0); const crawlRunningRef = useRef(false); + const dependencyRunningRef = useRef(false); const lastCrawlAtRef = useRef(0); const processDownloadQueueRef = useRef<() => void>(() => {}); const processCrawlQueueRef = useRef<() => void>(() => {}); + const processDependencyQueueRef = useRef<() => void>(() => {}); + const enqueueDependencyCheckRef = useRef<(workshopIds: string[], source?: string) => string | null>(() => null); const commitTasks = useCallback((tasks: BackgroundTask[]) => { const next = trimTasks(tasks); @@ -89,6 +92,7 @@ export function useBackgroundTasks({ setTimeout(() => { processDownloadQueueRef.current(); processCrawlQueueRef.current(); + processDependencyQueueRef.current(); }, 0); }) .catch((err) => console.error('Failed to load background tasks:', err)); @@ -211,6 +215,7 @@ export function useBackgroundTasks({ if (newTasks.length === 0) return; commitTasks([...tasksRef.current, ...newTasks]); + enqueueDependencyCheckRef.current(newTasks.map((task) => task.targetIds[0]), 'download-dependency-check'); processDownloadQueueRef.current(); }, [addons, knownUninstalledAddons, commitTasks]); @@ -313,6 +318,159 @@ export function useBackgroundTasks({ processCrawlQueueRef.current(); }, [addons, knownUninstalledAddons, commitTasks]); + const runDependencyCheckTask = useCallback(async (task: BackgroundTask) => { + const rootIds = task.dependencyCheck?.rootIds?.length + ? task.dependencyCheck.rootIds + : task.targetIds; + const seedIds = task.dependencyCheck?.seedIds?.length + ? task.dependencyCheck.seedIds + : rootIds; + if (seedIds.length === 0) return; + + dependencyRunningRef.current = true; + const pending = [...new Set(seedIds.map((id) => id.trim()).filter(Boolean))]; + const visited = new Set(); + const failedNodes: { workshopId: string; error: string }[] = []; + let completedCount = 0; + let latestData: DatabasePayload | null = null; + patchTask(task.id, { + status: 'running', + progress: 0, + startedAt: nowIso(), + finishedAt: undefined, + error: undefined, + dependencyCheck: { + rootIds, + seedIds, + discoveredCount: pending.length, + completedCount: 0, + failedNodes: [], + }, + }); + + try { + while (pending.length > 0) { + const currentTask = tasksRef.current.find((candidate) => candidate.id === task.id); + if (currentTask?.status === 'cancelled') return; + + const workshopId = pending.shift()!; + if (visited.has(workshopId)) continue; + visited.add(workshopId); + + try { + const details = await fetchWorkshopDependencySnapshot(workshopId); + const data = await invoke('persist_workshop_page_details', { + workshopId, + details, + source: 'dependency-check', + }); + if (isDatabasePayload(data)) { + latestData = data; + } + + for (const dependency of details.requiredItems || []) { + const dependencyId = dependency.workshopId?.trim(); + if (dependencyId && !visited.has(dependencyId) && !pending.includes(dependencyId)) { + pending.push(dependencyId); + } + } + } catch (err) { + failedNodes.push({ workshopId, error: String(err) }); + } + + completedCount += 1; + const discoveredCount = visited.size + pending.length; + const currentProgress = tasksRef.current.find((candidate) => candidate.id === task.id)?.progress || 0; + patchTask(task.id, { + progress: Math.max(currentProgress, Math.min(99, Math.ceil((completedCount / Math.max(discoveredCount, 1)) * 100))), + dependencyCheck: { + rootIds, + seedIds, + discoveredCount, + completedCount, + failedNodes: [...failedNodes], + }, + }); + } + + if (latestData) { + updateLocalState(latestData); + } + const currentTask = tasksRef.current.find((candidate) => candidate.id === task.id); + if (currentTask?.status !== 'cancelled') { + patchTask(task.id, { + status: 'completed', + progress: 100, + finishedAt: nowIso(), + error: failedNodes.length > 0 ? `${failedNodes.length} dependency node(s) could not be resolved` : undefined, + dependencyCheck: { + rootIds, + seedIds, + discoveredCount: visited.size, + completedCount, + failedNodes, + }, + }); + } + } finally { + dependencyRunningRef.current = false; + processDependencyQueueRef.current(); + } + }, [patchTask, updateLocalState]); + + const processDependencyQueue = useCallback(() => { + if (dependencyRunningRef.current) return; + const next = tasksRef.current.find((task) => task.kind === 'dependency-check' && task.status === 'queued'); + if (next) { + void runDependencyCheckTask(next); + } + }, [runDependencyCheckTask]); + + useEffect(() => { + processDependencyQueueRef.current = processDependencyQueue; + }, [processDependencyQueue]); + + const enqueueDependencyCheck = useCallback((workshopIds: string[], source = 'manual-dependency-check'): string | null => { + const rootIds = [...new Set(workshopIds.map((id) => id.trim()).filter(Boolean))]; + if (rootIds.length === 0) return null; + + const activeRootIds = new Set( + tasksRef.current + .filter((task) => task.kind === 'dependency-check' && ['queued', 'running'].includes(task.status)) + .flatMap((task) => task.dependencyCheck?.rootIds || task.targetIds), + ); + const newRootIds = rootIds.filter((id) => !activeRootIds.has(id)); + if (newRootIds.length === 0) return null; + + const id = taskId('dependency-check', newRootIds[0]); + const task: BackgroundTask = { + id, + kind: 'dependency-check', + status: 'queued', + source, + targetIds: newRootIds, + progress: 0, + createdAt: nowIso(), + title: newRootIds.length === 1 + ? `Dependency check (${newRootIds[0]})` + : `Dependency check (${newRootIds.length} items)`, + dependencyCheck: { + rootIds: newRootIds, + seedIds: newRootIds, + discoveredCount: newRootIds.length, + completedCount: 0, + failedNodes: [], + }, + }; + commitTasks([...tasksRef.current, task]); + processDependencyQueueRef.current(); + return id; + }, [commitTasks]); + + useEffect(() => { + enqueueDependencyCheckRef.current = enqueueDependencyCheck; + }, [enqueueDependencyCheck]); + const recordSeenItems = useCallback(async (items: any[], source = 'workshop-browser') => { if (items.length === 0) return; try { @@ -380,6 +538,8 @@ export function useBackgroundTasks({ setTimeout(() => processDownloadQueueRef.current(), 0); } else if (task.kind === 'workshop-crawl') { setTimeout(() => processCrawlQueueRef.current(), 0); + } else if (task.kind === 'dependency-check') { + setTimeout(() => processDependencyQueueRef.current(), 0); } }, [onDownloadCancelled, patchTask]); @@ -387,19 +547,33 @@ export function useBackgroundTasks({ const task = tasksRef.current.find((t) => t.id === id); if (!task) return; + const retryIds = task.kind === 'dependency-check' + ? task.dependencyCheck?.failedNodes.map((node) => node.workshopId).filter(Boolean) + : undefined; patchTask(id, { status: 'queued', progress: 0, error: undefined, createdAt: nowIso(), startedAt: undefined, - finishedAt: undefined + finishedAt: undefined, + dependencyCheck: task.kind === 'dependency-check' + ? { + rootIds: task.dependencyCheck?.rootIds || task.targetIds, + seedIds: retryIds?.length ? retryIds : task.dependencyCheck?.rootIds || task.targetIds, + discoveredCount: retryIds?.length || task.dependencyCheck?.rootIds?.length || task.targetIds.length, + completedCount: 0, + failedNodes: [], + } + : task.dependencyCheck, }); if (task.kind === 'download') { setTimeout(() => processDownloadQueueRef.current(), 0); } else if (task.kind === 'workshop-crawl') { setTimeout(() => processCrawlQueueRef.current(), 0); + } else if (task.kind === 'dependency-check') { + setTimeout(() => processDependencyQueueRef.current(), 0); } }, [patchTask]); @@ -414,6 +588,7 @@ export function useBackgroundTasks({ backgroundTasks, enqueueDownloads, enqueueWorkshopCrawl, + enqueueDependencyCheck, recordSeenItems, upsertWarningTask, cancelTask, diff --git a/src/locales/en.json b/src/locales/en.json index a655c11..110c227 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -66,6 +66,9 @@ "noTasks": "No active tasks", "noTasksDesc": "Active downloads or workshop crawls will appear here", "syncing": "Syncing workshop info...", + "dependencyChecking": "Checking dependencies...", + "dependencyQueued": "Waiting to check dependencies", + "dependencyPartial": "Dependency check completed with failed nodes", "syncDesc": "Fetching and updating Steam Workshop metadata for addons", "downloading": "Downloading addons...", "warning": "Task warnings", @@ -299,6 +302,14 @@ "sourcePresetSdkOnly": "Steam/SDK Only: no Steam Web API or web crawling", "sourcePresetOffline": "Offline Mode: local files and cache only", "sourcePresetHybrid": "Allow all HTML fetch scopes", + "dependencyRefreshTitle": "Dependency check refresh policy", + "dependencyRefreshDesc": "SDK queries can usually refresh dependency relations safely; Steam Community page fetches remain protected by the global rate limit.", + "dependencySdkRefreshLabel": "Steamworks SDK", + "dependencyHtmlRefreshLabel": "Steam Community HTML", + "dependencyRefresh": { + "always": "Refresh every check", + "cache-missing": "Fetch only when missing from cache" + }, "sourceHybridWarning": "Using Steamworks SDK and steamcommunity crawling at the same time may make Steam Community IP rate limiting more likely. Enable this only when you need web-only author, tag, or page detail enrichment.", "sourceSteamworksSdk": "Steamworks SDK", "sourceSteamWebApi": "Steam Web API", @@ -342,7 +353,12 @@ ], "licenseTitle": "Open Source License", "licenseDesc": "This project is open source under the MIT License.", - "languageLabel": "Interface Language:" + "languageLabel": "Interface Language:", + "dependencyMissingBehaviorLabel": "Behavior when dependencies are missing", + "behaviorAlways": "Always download", + "behaviorAsk": "Ask [Default]", + "behaviorIgnore": "Ignore (Not recommended)", + "behaviorIgnoreWarning": "Warning: Ignoring dependencies may cause addons to not work properly. Dependency checks will be skipped." }, "settingsModal": { "title": "Configure Addon Directory Path" @@ -516,5 +532,22 @@ "confirmQuestion": "Are you sure you want to proceed with the deletion?", "btnWorkshopOnly": "Delete Workshop Content Only", "btnDeleteAll": "Delete All" + }, + "dependencyPrompt": { + "warningTitle": "Are you sure?", + "warningDesc": "While a few addons may have optional dependencies, please ensure you have a good reason not to download any of them and understand the impact on the addons and the game.", + "goBack": "Go Back", + "confirmCancel": "Confirm", + "title": "Missing Dependencies Found", + "desc": "These items are required dependencies for addons you have installed or are currently downloading.", + "goToSettings": "Go to settings to change default behavior", + "search": "Search dependencies...", + "selectedCount": "Selected: {{count}}", + "selectAll": "Select All (This Page)", + "noResults": "No matching dependencies found", + "remindLater": "Remind me later", + "cancelAll": "I don't want to download any", + "downloadFirst": "Download these first", + "download": "Download Selected" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 09de077..0fdcd1c 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -66,6 +66,9 @@ "noTasks": "暂无活动任务", "noTasksDesc": "下载组件或同步创意工坊时将会在此处显示", "syncing": "正在同步创意工坊信息...", + "dependencyChecking": "正在检查依赖...", + "dependencyQueued": "等待检查依赖", + "dependencyPartial": "依赖检查已完成,但部分节点失败", "syncDesc": "正在获取和更新所有组件的最新 Steam 创意工坊元数据", "downloading": "正在下载组件...", "warning": "任务出现警告", @@ -299,6 +302,14 @@ "sourcePresetSdkOnly": "仅 Steam/SDK:不使用 Steam Web API 或网页爬取", "sourcePresetOffline": "离线模式:只使用本地文件和缓存", "sourcePresetHybrid": "允许所有网页抓取作用域", + "dependencyRefreshTitle": "依赖检查刷新策略", + "dependencyRefreshDesc": "SDK 查询通常可以刷新最新依赖关系;网页抓取会受到 Steam Community 全局限速保护。", + "dependencySdkRefreshLabel": "Steamworks SDK", + "dependencyHtmlRefreshLabel": "Steam Community 网页", + "dependencyRefresh": { + "always": "每次检查时刷新", + "cache-missing": "仅缓存缺失时抓取" + }, "sourceHybridWarning": "同时使用 Steamworks SDK 和 steamcommunity 网页爬取可能更容易触发 Steam 社区的 IP 请求速率限制。仅在确实需要网页补全作者、标签或页面详情时开启。", "sourceSteamworksSdk": "Steamworks SDK", "sourceSteamWebApi": "Steam Web API", @@ -342,7 +353,12 @@ ], "licenseTitle": "开源许可", "licenseDesc": "本项目遵循 MIT 协议开源。", - "languageLabel": "界面语言 (Language):" + "languageLabel": "界面语言 (Language):", + "dependencyMissingBehaviorLabel": "依赖项缺失时行为", + "behaviorAlways": "始终下载", + "behaviorAsk": "询问 [默认]", + "behaviorIgnore": "忽略 (不推荐)", + "behaviorIgnoreWarning": "警告: 忽略依赖项可能会导致附件无法正常工作。将会跳过依赖检查。" }, "settingsModal": { "title": "设置附加组件加载路径" @@ -516,5 +532,22 @@ "confirmQuestion": "确定要继续删除操作吗?", "btnWorkshopOnly": "仅删除创意工坊内容", "btnDeleteAll": "删除所有" + }, + "dependencyPrompt": { + "warningTitle": "您确定吗?", + "warningDesc": "虽然一些少数附件确实有一些可选的依赖组件. 但请确认您有充足的理由不会下载它们其中任何一个组件, 和这么做对组件还有游戏的影响.", + "goBack": "返回", + "confirmCancel": "确定", + "title": "发现缺失的依赖项", + "desc": "这些组件是您已安装或正在下载的附件所需的依赖。", + "goToSettings": "前往设置修改默认行为", + "search": "搜索依赖项...", + "selectedCount": "已选: {{count}}", + "selectAll": "全选 (本页)", + "noResults": "没有找到匹配的依赖项", + "remindLater": "稍后提醒我", + "cancelAll": "我都不想下载", + "downloadFirst": "先下载这些", + "download": "下载选中项" } } diff --git a/src/services/workshopClient.test.ts b/src/services/workshopClient.test.ts index 14121c9..06f50c1 100644 --- a/src/services/workshopClient.test.ts +++ b/src/services/workshopClient.test.ts @@ -5,12 +5,14 @@ const { mockParseHomepageSections, mockParseSSRItems, mockParseTagCategories, + mockParseWorkshopPageDetails, mockRememberWorkshopItems, } = vi.hoisted(() => ({ mockInvoke: vi.fn(), mockParseHomepageSections: vi.fn(), mockParseSSRItems: vi.fn(), mockParseTagCategories: vi.fn(), + mockParseWorkshopPageDetails: vi.fn(), mockRememberWorkshopItems: vi.fn((items: unknown) => items), })); @@ -22,7 +24,7 @@ vi.mock('../components/workshop/ssrParser', () => ({ parseHomepageSections: (html: string) => mockParseHomepageSections(html), parseSSRItems: (html: string, source: string) => mockParseSSRItems(html, source), parseTagCategories: (html: string) => mockParseTagCategories(html), - parseWorkshopPageDetails: vi.fn(), + parseWorkshopPageDetails: (html: string) => mockParseWorkshopPageDetails(html), })); vi.mock('../components/workshop/authorDirectory', () => ({ @@ -38,6 +40,7 @@ describe('workshopClient', () => { mockParseHomepageSections.mockReset(); mockParseSSRItems.mockReset(); mockParseTagCategories.mockReset(); + mockParseWorkshopPageDetails.mockReset(); mockRememberWorkshopItems.mockClear(); localStorage.clear(); }); @@ -180,6 +183,8 @@ describe('workshopClient', () => { allowSteamCommunityHtml: true, allowSdkHtmlHybrid: true, sdkHtmlScope: 'all', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], cacheRetention: 'keep', }); @@ -279,6 +284,8 @@ describe('workshopClient', () => { allowSteamWebApi: true, allowSteamCommunityHtml: true, sdkHtmlScope: 'navigation', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], cacheRetention: 'keep', }); @@ -570,4 +577,81 @@ describe('workshopClient', () => { { title: 'Child Two', workshopId: '300' }, ]); }); + + test('fetchWorkshopDependencySnapshot refreshes SDK dependency relations even when a cache entry exists', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + lastPageFetchedAt: '2026-01-01T00:00:00Z', + requiredItems: [{ title: 'Old dependency', workshopId: '200' }], + }, + '300': { workshopId: '300', title: 'Fresh dependency' }, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ bridgeAvailable: true, canQueryItems: true, canQueryHome: true }); + } + if (cmd === 'query_workshop_item') { + return Promise.resolve({ + source: 'steam-sdk', + item: { publishedfileid: '100', file_type: 'item', child_item_ids: ['300'] }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setWorkshopSourceSettings({ + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: false, + sdkHtmlScope: 'search', + sourceOrder: ['steamworks-sdk', 'steam-web-api'], + cacheRetention: 'keep', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + }); + + const snapshot = await workshopClient.fetchWorkshopDependencySnapshot('100'); + + expect(snapshot.requiredItems).toEqual([{ title: 'Fresh dependency', workshopId: '300' }]); + expect(mockInvoke).toHaveBeenCalledWith('query_workshop_item', { workshopId: '100' }); + }); + + test('fetchWorkshopDependencySnapshot reuses a cached page relation when SDK is unavailable', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + lastPageFetchedAt: '2026-01-01T00:00:00Z', + requiredItems: [{ title: 'Cached dependency', workshopId: '200' }], + }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setSteamworksSdkDisabled(true); + workshopClient.setWorkshopSourceSettings({ + preset: 'conservative', + allowSteamworksSdk: false, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + sdkHtmlScope: 'search', + sourceOrder: ['steamworks-sdk', 'steam-web-api'], + cacheRetention: 'keep', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + }); + + const snapshot = await workshopClient.fetchWorkshopDependencySnapshot('100'); + + expect(snapshot.requiredItems).toEqual([{ title: 'Cached dependency', workshopId: '200' }]); + expect(mockInvoke).not.toHaveBeenCalledWith('fetch_workshop_html', expect.anything()); + }); }); diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts index b06aa38..211b326 100644 --- a/src/services/workshopClient.ts +++ b/src/services/workshopClient.ts @@ -940,6 +940,81 @@ export async function fetchWorkshopPageDetails(workshopId: string, source: strin } } +/** + * Resolve the dependency relation for one item without letting an item snapshot + * hide a newer SDK relation. HTML is intentionally cache-first to respect the + * Community fetch gate. + */ +export async function fetchWorkshopDependencySnapshot(workshopId: string): Promise { + const cache = await readWorkshopItemCache(); + const cachedEntry = cache[workshopId]; + const cachedDetails = cachedEntry + ? mapCachedDetailToPageDetails(cachedEntry, cache) + : null; + const hasCachedPage = Boolean(cachedEntry?.lastPageFetchedAt); + const hasCachedDependencyRelation = hasCachedPage || + Array.isArray(cachedEntry?.requiredItems) || + Array.isArray(cachedEntry?.childItemIds); + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + const canUseSdk = Boolean(capabilities?.canQueryItems); + const shouldRefreshSdk = workshopSourceSettings.dependencySdkRefresh === 'always' || !hasCachedDependencyRelation; + let details = cachedDetails; + let sdkProvidedRelation = false; + + if (canUseSdk && shouldRefreshSdk) { + try { + const data = await invoke('query_workshop_item', { workshopId }); + reportWorkshopWarnings(data.warnings); + const sdkDetails = await enrichRequiredItemsWithSdkDetails( + mapSteamDetailToPageDetails(data.item, cache), + cache, + ); + sdkProvidedRelation = Array.isArray(data.item?.child_item_ids) || + Array.isArray(data.item?.childItemIds) || + Boolean(normalizeText(data.item?.file_type || data.item?.fileType)); + details = mergeWorkshopPageDetails(cachedDetails, sdkDetails); + if (sdkProvidedRelation) { + details = { + ...details, + fileType: sdkDetails.fileType || details.fileType, + childItemIds: sdkDetails.childItemIds, + requiredItems: sdkDetails.requiredItems, + }; + } + } catch (err) { + console.warn('Steam SDK dependency query failed:', err); + } + } + + const shouldFetchHtml = shouldUseSteamCommunityHtmlFor('dependency-check', capabilities) && ( + workshopSourceSettings.dependencyHtmlRefresh === 'always' || !hasCachedPage + ); + if (shouldFetchHtml) { + try { + const url = `https://steamcommunity.com/sharedfiles/filedetails/?id=${workshopId}`; + const html = await fetchWorkshopHtml(url, 'dependency-check'); + const htmlDetails = parseWorkshopPageDetails(html); + details = { + ...mergeWorkshopPageDetails(details, htmlDetails), + requiredItems: htmlDetails.requiredItems, + }; + } catch (err) { + if (!sdkProvidedRelation && !hasCachedDependencyRelation) { + throw err; + } + console.warn('Steam Community dependency page fetch failed:', err); + } + } + + if (!details || (!sdkProvidedRelation && !hasCachedDependencyRelation && !shouldFetchHtml)) { + throw new Error('No enabled source can determine this item\'s dependencies'); + } + + return enrichRequiredItemsWithSdkDetails(details, cache); +} + export async function getWorkshopPageSnapshot(workshopId: string): Promise { const cache = await readWorkshopItemCache(); const detail = cache[workshopId]; diff --git a/src/types/addon.ts b/src/types/addon.ts index aeb29e9..59d26da 100644 --- a/src/types/addon.ts +++ b/src/types/addon.ts @@ -108,7 +108,7 @@ export interface Addon { export interface BackgroundTask { id: string; - kind: 'download' | 'workshop-crawl' | 'warning'; + kind: 'download' | 'workshop-crawl' | 'dependency-check' | 'warning'; status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; source?: string; targetIds: string[]; @@ -120,6 +120,13 @@ export interface BackgroundTask { finishedAt?: string; title?: string; imagePath?: string; + dependencyCheck?: { + rootIds: string[]; + seedIds?: string[]; + discoveredCount: number; + completedCount: number; + failedNodes: { workshopId: string; error: string }[]; + }; } export interface Group { @@ -141,6 +148,10 @@ export interface MasterCollection { icon?: string; } +export type DependencyMissingBehavior = 'always' | 'ask' | 'ignore'; +export type WorkshopSdkHtmlScope = 'disabled' | 'search' | 'navigation' | 'all'; +export type DependencyRefreshMode = 'always' | 'cache-missing'; + export interface Settings { workshopDir: string; loadingDir: string; @@ -150,11 +161,10 @@ export interface Settings { disableSteamworksSdk: boolean; forceSteamworksSdkDownload?: boolean; maxDownloadRetries?: number; + dependencyMissingBehavior?: DependencyMissingBehavior; workshopSourceSettings?: WorkshopSourceSettings; } -export type WorkshopSdkHtmlScope = 'disabled' | 'search' | 'navigation' | 'all'; - export interface WorkshopSourceSettings { preset: 'conservative' | 'sdk-only' | 'offline' | 'hybrid'; allowSteamworksSdk: boolean; @@ -162,6 +172,8 @@ export interface WorkshopSourceSettings { allowSteamCommunityHtml: boolean; allowSdkHtmlHybrid?: boolean; sdkHtmlScope: WorkshopSdkHtmlScope; + dependencySdkRefresh: DependencyRefreshMode; + dependencyHtmlRefresh: DependencyRefreshMode; sourceOrder: string[]; cacheRetention: 'keep'; } diff --git a/src/utils/workshopSourceSettings.ts b/src/utils/workshopSourceSettings.ts index 07805e3..4022e74 100644 --- a/src/utils/workshopSourceSettings.ts +++ b/src/utils/workshopSourceSettings.ts @@ -1,4 +1,4 @@ -import type { WorkshopSdkHtmlScope, WorkshopSourceSettings } from '../types/addon'; +import type { DependencyRefreshMode, WorkshopSdkHtmlScope, WorkshopSourceSettings } from '../types/addon'; export const DEFAULT_WORKSHOP_SOURCE_SETTINGS: WorkshopSourceSettings = { preset: 'conservative', @@ -7,6 +7,8 @@ export const DEFAULT_WORKSHOP_SOURCE_SETTINGS: WorkshopSourceSettings = { allowSteamCommunityHtml: true, allowSdkHtmlHybrid: false, sdkHtmlScope: 'search', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], cacheRetention: 'keep', }; @@ -25,6 +27,10 @@ export function resolveWorkshopSdkHtmlScope( return 'search'; } +function resolveDependencyRefreshMode(value: DependencyRefreshMode | string | null | undefined, fallback: DependencyRefreshMode): DependencyRefreshMode { + return value === 'always' || value === 'cache-missing' ? value : fallback; +} + export function normalizeWorkshopSourceSettings( settings?: Partial | null, ): WorkshopSourceSettings { @@ -41,6 +47,14 @@ export function normalizeWorkshopSourceSettings( preset: normalizedPreset as WorkshopSourceSettings['preset'], allowSdkHtmlHybrid: sdkHtmlScope === 'all', sdkHtmlScope, + dependencySdkRefresh: resolveDependencyRefreshMode( + settings?.dependencySdkRefresh, + DEFAULT_WORKSHOP_SOURCE_SETTINGS.dependencySdkRefresh, + ), + dependencyHtmlRefresh: resolveDependencyRefreshMode( + settings?.dependencyHtmlRefresh, + DEFAULT_WORKSHOP_SOURCE_SETTINGS.dependencyHtmlRefresh, + ), sourceOrder: settings?.sourceOrder?.length ? settings.sourceOrder : DEFAULT_WORKSHOP_SOURCE_SETTINGS.sourceOrder, @@ -61,7 +75,7 @@ export function shouldAllowSteamCommunityHtmlSource( return true; } - if (source === 'addon-detail' || source === 'workshop-detail') { + if (source === 'addon-detail' || source === 'workshop-detail' || source === 'dependency-check') { return true; } From d97b6b286c5177ad969aab37441d051e7edc3e51 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Sun, 12 Jul 2026 17:17:03 +0800 Subject: [PATCH 71/81] test: fix npm test failures --- src/components/SettingsView.test.tsx | 3 ++ src/hooks/useBackgroundTasks.test.tsx | 41 +++++++++++++++++++++++++++ src/hooks/useBackgroundTasks.ts | 20 +++++++++++-- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/components/SettingsView.test.tsx b/src/components/SettingsView.test.tsx index 07dab87..b465c2c 100644 --- a/src/components/SettingsView.test.tsx +++ b/src/components/SettingsView.test.tsx @@ -55,6 +55,7 @@ describe('SettingsView', () => { true, false, 3, + 'ask', baseSettings.workshopSourceSettings, ); }); @@ -87,6 +88,7 @@ describe('SettingsView', () => { false, false, 3, + 'ask', baseSettings.workshopSourceSettings, ); }); @@ -117,6 +119,7 @@ describe('SettingsView', () => { false, false, 3, + 'ask', expect.objectContaining({ preset: 'hybrid', sdkHtmlScope: 'all', diff --git a/src/hooks/useBackgroundTasks.test.tsx b/src/hooks/useBackgroundTasks.test.tsx index 23df79d..2e6b59a 100644 --- a/src/hooks/useBackgroundTasks.test.tsx +++ b/src/hooks/useBackgroundTasks.test.tsx @@ -1,3 +1,4 @@ +import React, { StrictMode } from 'react'; import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { useBackgroundTasks } from './useBackgroundTasks'; @@ -68,4 +69,44 @@ describe('useBackgroundTasks dependency checks', () => { expect(mockInvoke.mock.calls.filter(([command]) => command === 'persist_workshop_page_details')).toHaveLength(3); expect(updateLocalState).toHaveBeenCalledTimes(1); }); + + test('restores a persisted download only once under StrictMode', async () => { + let resolveDownload: (() => void) | undefined; + const downloadPromise = new Promise((resolve) => { + resolveDownload = resolve; + }); + const restoredTask = { + id: 'download_A_restored', + kind: 'download' as const, + status: 'running' as const, + targetIds: ['A'], + progress: 42, + createdAt: new Date().toISOString(), + }; + + mockInvoke.mockImplementation((command) => { + if (command === 'get_background_tasks') return Promise.resolve([restoredTask]); + if (command === 'download_addon') return downloadPromise; + return Promise.resolve(); + }); + + renderHook(() => useBackgroundTasks({ + enabled: true, + downloadConcurrency: 2, + addons: {}, + knownUninstalledAddons: {}, + updateLocalState: vi.fn(), + onDownloadSuccess: vi.fn(), + onDownloadCancelled: vi.fn(), + onTaskError: vi.fn(), + }), { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(mockInvoke.mock.calls.filter(([command]) => command === 'download_addon')).toHaveLength(1); + }); + + resolveDownload?.(); + }); }); diff --git a/src/hooks/useBackgroundTasks.ts b/src/hooks/useBackgroundTasks.ts index 94a23d1..6e9cbf1 100644 --- a/src/hooks/useBackgroundTasks.ts +++ b/src/hooks/useBackgroundTasks.ts @@ -54,6 +54,7 @@ export function useBackgroundTasks({ const [backgroundTasks, setBackgroundTasks] = useState([]); const tasksRef = useRef([]); const activeDownloadsRef = useRef(0); + const runningDownloadTaskIdsRef = useRef>(new Set()); const crawlRunningRef = useRef(false); const dependencyRunningRef = useRef(false); const lastCrawlAtRef = useRef(0); @@ -82,26 +83,36 @@ export function useBackgroundTasks({ }, [commitTasks]); useEffect(() => { + let active = true; + void invoke('get_background_tasks') .then((tasks) => { + if (!active) return; + const safeTasks = Array.isArray(tasks) ? tasks : []; const restored = safeTasks.map((task) => ( task.status === 'running' ? { ...task, status: 'queued' as const } : task )); commitTasks(restored); setTimeout(() => { + if (!active) return; processDownloadQueueRef.current(); processCrawlQueueRef.current(); processDependencyQueueRef.current(); }, 0); }) .catch((err) => console.error('Failed to load background tasks:', err)); + + return () => { + active = false; + }; }, [commitTasks]); const runDownloadTask = useCallback(async (task: BackgroundTask) => { const workshopId = task.targetIds[0]; - if (!workshopId) return; + if (!workshopId || runningDownloadTaskIdsRef.current.has(task.id)) return; + runningDownloadTaskIdsRef.current.add(task.id); activeDownloadsRef.current += 1; patchTask(task.id, { status: 'running', progress: 0, startedAt: nowIso(), error: undefined }); @@ -147,6 +158,7 @@ export function useBackgroundTasks({ } } } finally { + runningDownloadTaskIdsRef.current.delete(task.id); activeDownloadsRef.current = Math.max(0, activeDownloadsRef.current - 1); processDownloadQueueRef.current(); } @@ -155,7 +167,11 @@ export function useBackgroundTasks({ const processDownloadQueue = useCallback(() => { const concurrency = Math.max(1, Math.trunc(downloadConcurrency || 1)); while (activeDownloadsRef.current < concurrency) { - const next = tasksRef.current.find((task) => task.kind === 'download' && task.status === 'queued'); + const next = tasksRef.current.find((task) => ( + task.kind === 'download' && + task.status === 'queued' && + !runningDownloadTaskIdsRef.current.has(task.id) + )); if (!next) return; void runDownloadTask(next); } From fa1b42a4a07f1be04caebbb1d69948a9ea45259f Mon Sep 17 00:00:00 2001 From: Akkariin Date: Sun, 12 Jul 2026 17:33:04 +0800 Subject: [PATCH 72/81] fix(workshop): dependency check can ignored workshop items --- src/components/DependencyPromptContainer.tsx | 36 +++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/components/DependencyPromptContainer.tsx b/src/components/DependencyPromptContainer.tsx index 3a6c712..991567e 100644 --- a/src/components/DependencyPromptContainer.tsx +++ b/src/components/DependencyPromptContainer.tsx @@ -46,28 +46,46 @@ export const DependencyPromptContainer: React.FC const missingIds = new Set(); // Check requiredItems of all addons we have (installed, disabled, downloading, or in knownUninstalled) - // Actually, maybe we only care about installed addons (dirType !== 'none') or addons currently being downloaded. - // Let's gather all addons that user "owns" or is trying to own. - const ownedIds = new Set(); + const ownedAddons = new Set(); // 1. Addons that are installed Object.values(addons).forEach(addon => { if (addon.dirType !== 'none') { - ownedIds.add(addon.workshopId || addon.id); + ownedAddons.add(addon); } }); + // Helper to find addon by workshopId + const findAddon = (id: string) => { + const normalizedId = String(id || '').trim(); + if (!normalizedId) return undefined; + + const direct = addons[normalizedId] || addons[`${normalizedId}.vpk`] || + knownUninstalledAddons[normalizedId] || knownUninstalledAddons[`${normalizedId}.vpk`]; + if (direct) return direct; + + return Object.values(addons).find(a => + a.workshopId === normalizedId || a.id === normalizedId || a.id === `${normalizedId}.vpk` || a.vpkName === `${normalizedId}.vpk` + ) || Object.values(knownUninstalledAddons).find(a => + a.workshopId === normalizedId || a.id === normalizedId || a.id === `${normalizedId}.vpk` || a.vpkName === `${normalizedId}.vpk` + ); + }; + // 2. Addons that are in download queue activeDownloadTasks.forEach(task => { - task.targetIds.forEach(id => ownedIds.add(id)); + task.targetIds.forEach(id => { + const addon = findAddon(id); + if (addon) { + ownedAddons.add(addon); + } + }); }); // Gather their required items const requiredItems = new Map(); - ownedIds.forEach(id => { - const addon = addons[id] || knownUninstalledAddons[id]; - if (addon && addon.workshopDetails?.requiredItems) { + ownedAddons.forEach(addon => { + if (addon.workshopDetails?.requiredItems) { addon.workshopDetails.requiredItems.forEach(req => { requiredItems.set(req.workshopId, req); }); @@ -87,7 +105,7 @@ export const DependencyPromptContainer: React.FC // Map ids back to full Addon objects for the UI return Array.from(missingIds).map(id => { - const existing = addons[id] || knownUninstalledAddons[id]; + const existing = findAddon(id); if (existing) return existing; // Fallback if not found in db yet From 916cb726bf0fb68eb09d25218aee11ff29c2867a Mon Sep 17 00:00:00 2001 From: Akkariin Date: Sun, 12 Jul 2026 17:43:58 +0800 Subject: [PATCH 73/81] fix(workshop): use raw workshop IDs as identifiers --- src-tauri/src/commands/handlers/addons.rs | 42 +++------- src-tauri/src/commands/handlers/mod.rs | 85 +++++++++++++++----- src/components/DependencyPromptContainer.tsx | 7 +- src/utils/workshopKnown.test.ts | 21 +++++ src/utils/workshopKnown.ts | 9 +-- 5 files changed, 101 insertions(+), 63 deletions(-) create mode 100644 src/utils/workshopKnown.test.ts diff --git a/src-tauri/src/commands/handlers/addons.rs b/src-tauri/src/commands/handlers/addons.rs index 149d7b7..0ba5752 100644 --- a/src-tauri/src/commands/handlers/addons.rs +++ b/src-tauri/src/commands/handlers/addons.rs @@ -499,12 +499,8 @@ pub async fn group_action( let mut new_workshop_ids: Vec = Vec::new(); if source == "workshop-import" { for raw_id in &raw_ids { - let workshop_id = raw_id.replace(".vpk", ""); - // Check both with and without .vpk suffix - let vpk_key = format!("{}.vpk", workshop_id); - if !db.addons.contains_key(&vpk_key) - && !db.known_uninstalled_addons.contains_key(&vpk_key) - && !db.addons.contains_key(&workshop_id) + let workshop_id = raw_id.trim().to_string(); + if !db.addons.contains_key(&workshop_id) && !db.known_uninstalled_addons.contains_key(&workshop_id) { db.known_uninstalled_addons.insert( @@ -570,23 +566,12 @@ pub async fn group_action( } } - let filtered_vpks: Vec = ids + let filtered_ids: Vec = ids .into_iter() .filter_map(|n| { - // Already a valid key - if db.addons.contains_key(&n) - || db.known_uninstalled_addons.contains_key(&n) - || n.ends_with(".vpk") - { + if db.addons.contains_key(&n) || db.known_uninstalled_addons.contains_key(&n) { return Some(n); } - // Raw workshop ID → try with .vpk suffix - let vpk_key = format!("{}.vpk", n); - if db.addons.contains_key(&vpk_key) - || db.known_uninstalled_addons.contains_key(&vpk_key) - { - return Some(vpk_key); - } None }) .collect(); @@ -660,7 +645,7 @@ pub async fn group_action( db.groups.push(Group { id: group_id.clone(), name, - addons: filtered_vpks, + addons: filtered_ids, tags, workshop_collection_id, master_collection_ids: if mc_ids.is_empty() { @@ -697,18 +682,9 @@ pub async fn group_action( let valid_ids: Vec = ids .into_iter() .filter_map(|n| { - if db.addons.contains_key(&n) - || db.known_uninstalled_addons.contains_key(&n) - || n.ends_with(".vpk") - { + if db.addons.contains_key(&n) || db.known_uninstalled_addons.contains_key(&n) { return Some(n); } - let vpk_key = format!("{}.vpk", n); - if db.addons.contains_key(&vpk_key) - || db.known_uninstalled_addons.contains_key(&vpk_key) - { - return Some(vpk_key); - } None }) .collect(); @@ -747,11 +723,11 @@ pub async fn group_action( group.workshop_collection_id = workshop_collection_id; if let Some(vpks) = ids { - let filtered_vpks: Vec = vpks + let filtered_ids: Vec = vpks .into_iter() - .filter(|n| valid_addon_names.contains(n) || n.ends_with(".vpk")) + .filter(|id| valid_addon_names.contains(id)) .collect(); - group.addons = filtered_vpks; + group.addons = filtered_ids; } } } else if action == "auto-group" { diff --git a/src-tauri/src/commands/handlers/mod.rs b/src-tauri/src/commands/handlers/mod.rs index b051021..0756395 100644 --- a/src-tauri/src/commands/handlers/mod.rs +++ b/src-tauri/src/commands/handlers/mod.rs @@ -494,9 +494,7 @@ fn is_known_workshop_id(known_addons_path: &Path, groups_path: &Path, workshop_i } let known_addons = load_known_addons(known_addons_path); - if known_addons.contains_key(workshop_id) - || known_addons.contains_key(&format!("{}.vpk", workshop_id)) - { + if known_addons.contains_key(workshop_id) { return true; } @@ -504,11 +502,9 @@ fn is_known_workshop_id(known_addons_path: &Path, groups_path: &Path, workshop_i return true; } - known_addons.values().any(|entry| { - entry.id == workshop_id - || entry.vpk_name == format!("{}.vpk", workshop_id) - || entry.workshop_id.as_deref() == Some(workshop_id) - }) + known_addons + .values() + .any(|entry| entry.id == workshop_id || entry.workshop_id.as_deref() == Some(workshop_id)) } fn ensure_background_workshop_fetch_allowed( @@ -834,10 +830,36 @@ fn load_known_addons(known_addons_path: &Path) -> HashMap>(&content) { - return parsed + let mut normalized = HashMap::new(); + for (key, mut entry) in parsed .into_iter() .filter(|(_, entry)| !is_dummy_addon_info(&entry.addon_info)) - .collect(); + { + let Some(workshop_id) = entry + .workshop_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + normalized.insert(key, entry); + continue; + }; + + entry.id = workshop_id.to_string(); + let is_canonical_key = key == workshop_id; + match normalized.entry(workshop_id.to_string()) { + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(entry); + } + std::collections::hash_map::Entry::Occupied(mut slot) + if is_canonical_key => + { + slot.insert(entry); + } + std::collections::hash_map::Entry::Occupied(_) => {} + } + } + return normalized; } } } @@ -1028,7 +1050,13 @@ fn load_known_addons_from_legacy_db( if addon.is_dummy || is_dummy_addon_info(&addon.addon_info) { continue; } - let id = addon.id.clone(); + let id = addon + .workshop_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or(&addon.id) + .to_string(); migrated.insert( id.clone(), KnownAddonEntry { @@ -2297,13 +2325,7 @@ fn upsert_known_addon_entry( return; } - let legacy_key = format!("{}.vpk", workshop_id); - let existing = known_addons - .get(workshop_id) - .cloned() - .or_else(|| known_addons.get(&legacy_key).cloned()); - - known_addons.remove(&legacy_key); + let existing = known_addons.get(workshop_id).cloned(); let preview_url = details .and_then(|value| value.get("preview_url")) @@ -2584,7 +2606,7 @@ fn rename_requires_name_change(addon: &Addon, sanitized: &str) -> bool { mod tests { use super::{ ensure_background_workshop_fetch_allowed, extract_steamcommunity_error_message, - is_background_workshop_fetch_source, load_workshop_cache, + is_background_workshop_fetch_source, load_known_addons, load_workshop_cache, merge_known_addon_snapshots_into_cache, move_or_copy_file, move_requires_dir_change, normalize_master_collection_group_refs, parse_content_range_start, persist_seen_workshop_item_entry, persist_workshop_page_details_entry, @@ -2825,6 +2847,31 @@ mod tests { assert!(manual.is_ok()); } + #[test] + fn normalizes_known_workshop_entries_to_raw_ids() { + let path = write_known_addons_file( + "normalize-workshop-id", + json!({ + "12345.vpk": { + "id": "12345.vpk", + "vpkName": "12345.vpk", + "workshopId": "12345", + "addonInfo": {}, + "hasImage": false, + "imagePath": null, + "steamDetails": null + } + }), + ); + + let entries = load_known_addons(&path); + fs::remove_file(&path).ok(); + + assert_eq!(entries.len(), 1); + assert!(!entries.contains_key("12345.vpk")); + assert_eq!(entries["12345"].id, "12345"); + } + #[test] fn background_fetch_allows_known_collection_ids() { let known_addons_path = write_known_addons_file("collection-allowed", json!({})); diff --git a/src/components/DependencyPromptContainer.tsx b/src/components/DependencyPromptContainer.tsx index 991567e..ce968f0 100644 --- a/src/components/DependencyPromptContainer.tsx +++ b/src/components/DependencyPromptContainer.tsx @@ -60,14 +60,13 @@ export const DependencyPromptContainer: React.FC const normalizedId = String(id || '').trim(); if (!normalizedId) return undefined; - const direct = addons[normalizedId] || addons[`${normalizedId}.vpk`] || - knownUninstalledAddons[normalizedId] || knownUninstalledAddons[`${normalizedId}.vpk`]; + const direct = addons[normalizedId] || knownUninstalledAddons[normalizedId]; if (direct) return direct; return Object.values(addons).find(a => - a.workshopId === normalizedId || a.id === normalizedId || a.id === `${normalizedId}.vpk` || a.vpkName === `${normalizedId}.vpk` + a.workshopId === normalizedId || a.id === normalizedId ) || Object.values(knownUninstalledAddons).find(a => - a.workshopId === normalizedId || a.id === normalizedId || a.id === `${normalizedId}.vpk` || a.vpkName === `${normalizedId}.vpk` + a.workshopId === normalizedId || a.id === normalizedId ); }; diff --git a/src/utils/workshopKnown.test.ts b/src/utils/workshopKnown.test.ts new file mode 100644 index 0000000..7f84f84 --- /dev/null +++ b/src/utils/workshopKnown.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; +import { findKnownWorkshopEntry, isKnownWorkshopItem } from './workshopKnown'; + +describe('workshop known entries', () => { + test('uses the raw workshop ID as the identifier', () => { + const entry = { id: '12345', workshopId: '12345' }; + const entries = { '12345': entry }; + + expect(findKnownWorkshopEntry(entries, '12345')).toBe(entry); + expect(isKnownWorkshopItem(entries, '12345')).toBe(true); + }); + + test('does not treat a VPK filename as a workshop identifier', () => { + const entries = { + '12345.vpk': { id: '12345.vpk', workshopId: undefined }, + }; + + expect(findKnownWorkshopEntry(entries, '12345')).toBeUndefined(); + expect(isKnownWorkshopItem(entries, '12345')).toBe(false); + }); +}); diff --git a/src/utils/workshopKnown.ts b/src/utils/workshopKnown.ts index ee9f0c3..6b9c710 100644 --- a/src/utils/workshopKnown.ts +++ b/src/utils/workshopKnown.ts @@ -1,6 +1,5 @@ type WorkshopLike = { id?: string; - vpkName?: string; workshopId?: string; }; @@ -15,16 +14,12 @@ export function findKnownWorkshopEntry( const id = normalizeId(workshopId); if (!entries || !id) return undefined; - // Read legacy ".vpk" keys, but keep workshop identity keyed by the raw - // publishedfileid everywhere new code can control. - const direct = entries[id] || entries[`${id}.vpk`]; + const direct = entries[id]; if (direct) return direct; return Object.values(entries).find((entry) => ( normalizeId(entry?.workshopId) === id || - normalizeId(entry?.id) === id || - normalizeId(entry?.id) === `${id}.vpk` || - normalizeId(entry?.vpkName) === `${id}.vpk` + normalizeId(entry?.id) === id )); } From 20093bc62443bab4645f91afbc2a16ed0b6e3913 Mon Sep 17 00:00:00 2001 From: Akkariin Date: Sun, 12 Jul 2026 19:34:41 +0800 Subject: [PATCH 74/81] fix(workshop): prompt and label missing dependencies --- src-tauri/src/commands/handlers/db.rs | 1 + src-tauri/src/commands/handlers/settings.rs | 7 ++ src-tauri/src/commands/handlers/workshop.rs | 14 ++++ src-tauri/src/commands/types.rs | 31 +++++++ src-tauri/src/lib.rs | 2 + .../DependencyPromptContainer.test.tsx | 84 +++++++++++++++++++ src/components/DependencyPromptContainer.tsx | 80 +++++++++++++++--- src/components/DependencyPromptModal.tsx | 18 +++- src/hooks/useBackgroundTasks.test.tsx | 5 ++ src/hooks/useBackgroundTasks.ts | 28 +++++++ src/locales/en.json | 1 + src/locales/zh.json | 1 + src/types/addon.ts | 6 ++ 13 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 src/components/DependencyPromptContainer.test.tsx diff --git a/src-tauri/src/commands/handlers/db.rs b/src-tauri/src/commands/handlers/db.rs index cbb1ba2..068e30e 100644 --- a/src-tauri/src/commands/handlers/db.rs +++ b/src-tauri/src/commands/handlers/db.rs @@ -27,6 +27,7 @@ pub fn load_db( disable_steamworks_sdk: false, force_steamworks_sdk_download: false, max_download_retries: 3, + dependency_missing_behavior: "ask".to_string(), workshop_source_settings: WorkshopSourceSettings::default(), }, addons: HashMap::new(), diff --git a/src-tauri/src/commands/handlers/settings.rs b/src-tauri/src/commands/handlers/settings.rs index 0bcfc81..10adf7d 100644 --- a/src-tauri/src/commands/handlers/settings.rs +++ b/src-tauri/src/commands/handlers/settings.rs @@ -12,6 +12,8 @@ pub struct SaveSettingsPayload { disable_steamworks_sdk: bool, force_steamworks_sdk_download: bool, max_download_retries: u32, + #[serde(default = "crate::commands::types::default_dependency_missing_behavior")] + dependency_missing_behavior: String, workshop_source_settings: Option, } @@ -35,6 +37,7 @@ pub async fn save_settings( disable_steamworks_sdk, force_steamworks_sdk_download, max_download_retries, + dependency_missing_behavior, workshop_source_settings, } = payload; @@ -50,6 +53,10 @@ pub async fn save_settings( db.settings.disable_steamworks_sdk = disable_steamworks_sdk; db.settings.force_steamworks_sdk_download = force_steamworks_sdk_download; db.settings.max_download_retries = max_download_retries; + db.settings.dependency_missing_behavior = match dependency_missing_behavior.as_str() { + "always" | "ask" | "ignore" => dependency_missing_behavior, + _ => "ask".to_string(), + }; if let Some(workshop_source_settings) = workshop_source_settings { db.settings.workshop_source_settings = workshop_source_settings; } diff --git a/src-tauri/src/commands/handlers/workshop.rs b/src-tauri/src/commands/handlers/workshop.rs index 232400c..614995a 100644 --- a/src-tauri/src/commands/handlers/workshop.rs +++ b/src-tauri/src/commands/handlers/workshop.rs @@ -759,6 +759,12 @@ pub async fn record_workshop_items_seen( source: Option, state: State<'_, crate::AppState>, ) -> Result { + // Browse snapshots and dependency details both replace the cache file. Keep + // their read-modify-write sections atomic with respect to one another. + let cache_write_guard = state + .workshop_cache_write_lock + .lock() + .map_err(|_| "Failed to acquire workshop cache write lock".to_string())?; let mut cache = load_workshop_cache(&state.workshop_cache_path); let now = chrono::Utc::now().to_rfc3339(); let source = source.unwrap_or_else(|| "unknown".to_string()); @@ -780,6 +786,7 @@ pub async fn record_workshop_items_seen( merge_known_addon_snapshots_into_cache(&mut cache, &state.known_addons_path); propagate_author_names(&mut cache); save_workshop_cache(&state.workshop_cache_path, &cache)?; + drop(cache_write_guard); let db = state.db.lock().await; Ok(database_with_workshop_cache( &db, @@ -799,6 +806,12 @@ pub async fn persist_workshop_page_details( return Err("Missing workshop id".to_string()); } + // See record_workshop_items_seen: avoid a late seen-item write erasing this + // item's requiredItems relation. + let cache_write_guard = state + .workshop_cache_write_lock + .lock() + .map_err(|_| "Failed to acquire workshop cache write lock".to_string())?; let mut cache = load_workshop_cache(&state.workshop_cache_path); let now = chrono::Utc::now().to_rfc3339(); let source = source.unwrap_or_else(|| "unknown".to_string()); @@ -899,6 +912,7 @@ pub async fn persist_workshop_page_details( merge_known_addon_snapshots_into_cache(&mut cache, &state.known_addons_path); propagate_author_names(&mut cache); save_workshop_cache(&state.workshop_cache_path, &cache)?; + drop(cache_write_guard); let db = state.db.lock().await; Ok(database_with_workshop_cache( &db, diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs index fcd3f52..5e891ef 100644 --- a/src-tauri/src/commands/types.rs +++ b/src-tauri/src/commands/types.rs @@ -19,6 +19,8 @@ pub struct Settings { pub force_steamworks_sdk_download: bool, #[serde(rename = "maxDownloadRetries", default = "default_max_download_retries")] pub max_download_retries: u32, + #[serde(rename = "dependencyMissingBehavior", default = "default_dependency_missing_behavior")] + pub dependency_missing_behavior: String, #[serde(rename = "workshopSourceSettings", default)] pub workshop_source_settings: WorkshopSourceSettings, } @@ -60,6 +62,10 @@ fn default_max_download_retries() -> u32 { 3 } +pub fn default_dependency_missing_behavior() -> String { + "ask".to_string() +} + fn default_workshop_source_preset() -> String { "conservative".to_string() } @@ -105,6 +111,31 @@ impl Default for WorkshopSourceSettings { } } +#[cfg(test)] +mod tests { + use super::Settings; + + #[test] + fn dependency_missing_behavior_defaults_and_serializes() { + let mut settings: Settings = serde_json::from_value(serde_json::json!({ + "workshopDir": "workshop", + "loadingDir": "loading" + })) + .unwrap(); + + assert_eq!(settings.dependency_missing_behavior, "ask"); + + settings.dependency_missing_behavior = "always".to_string(); + assert_eq!( + serde_json::to_value(settings) + .unwrap() + .get("dependencyMissingBehavior") + .and_then(|value| value.as_str()), + Some("always"), + ); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Group { #[serde(default)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0060d9f..b277e20 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ pub struct AppState { pub download_cache_dir: PathBuf, pub workshop_service: steam::WorkshopService, pub db: Mutex, + pub workshop_cache_write_lock: StdMutex<()>, pub addon_watcher: StdMutex, pub cancelled_downloads: StdMutex>, pub runtime_dir: PathBuf, @@ -108,6 +109,7 @@ pub fn run() { download_cache_dir, workshop_service: steam::WorkshopService::new(&bridge_base_dir), db: Mutex::new(db), + workshop_cache_write_lock: StdMutex::new(()), addon_watcher: StdMutex::new(watcher::AddonWatcherController::default()), cancelled_downloads: StdMutex::new(HashSet::new()), runtime_dir: runtime_dir.clone(), diff --git a/src/components/DependencyPromptContainer.test.tsx b/src/components/DependencyPromptContainer.test.tsx new file mode 100644 index 0000000..f89de0d --- /dev/null +++ b/src/components/DependencyPromptContainer.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { DependencyPromptContainer } from './DependencyPromptContainer'; +import type { BackgroundTask, Settings } from '../types/addon'; + +const settings: Settings = { + workshopDir: '', + loadingDir: '', + downloadConcurrency: 2, + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + dependencyMissingBehavior: 'ask', +}; + +const runningCheck: BackgroundTask = { + id: 'dependency-check-root', + kind: 'dependency-check', + status: 'running', + targetIds: ['root'], + progress: 50, + createdAt: '2026-07-12T00:00:00Z', + dependencyCheck: { + rootIds: ['root'], + discoveredCount: 2, + completedCount: 1, + failedNodes: [], + discoveredDependencies: [], + }, +}; + +describe('DependencyPromptContainer', () => { + test('prompts for dependencies discovered for an unknown workshop item', async () => { + const onDownload = vi.fn(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('Dependency addon')).toBeDefined(); + expect(screen.getByText('Dependency author')).toBeDefined(); + }); + + fireEvent.click(screen.getByText('下载选中项')); + expect(onDownload).toHaveBeenCalledWith([{ + workshopId: 'dependency', + title: 'Dependency addon', + imagePath: 'https://example.com/dependency.jpg', + }]); + }); +}); diff --git a/src/components/DependencyPromptContainer.tsx b/src/components/DependencyPromptContainer.tsx index ce968f0..90d4bcf 100644 --- a/src/components/DependencyPromptContainer.tsx +++ b/src/components/DependencyPromptContainer.tsx @@ -1,13 +1,17 @@ import React, { useEffect, useState, useRef } from 'react'; import { Addon, BackgroundTask, Settings } from '../types/addon'; -import { DependencyPromptModal } from './DependencyPromptModal'; +import { DependencyDownloadItem, DependencyPromptModal } from './DependencyPromptModal'; + +type DiscoveredDependency = NonNullable< + NonNullable['discoveredDependencies'] +>[number]; interface DependencyPromptContainerProps { addons: Record; knownUninstalledAddons: Record; backgroundTasks: BackgroundTask[]; settings: Settings; - onDownload: (items: string[]) => void; + onDownload: (items: DependencyDownloadItem[]) => void; onGoToSettings: () => void; } @@ -24,10 +28,20 @@ export const DependencyPromptContainer: React.FC const [modalOpen, setModalOpen] = useState(false); const [missingDependencies, setMissingDependencies] = useState([]); const [isScanning, setIsScanning] = useState(false); + + const toDownloadItems = (items: Addon[]): DependencyDownloadItem[] => ( + items.map((addon) => ({ + workshopId: addon.workshopId || addon.id, + title: addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName, + imagePath: addon.imagePath || addon.workshopDetails?.previewUrl, + })) + ); // Track previous task counts to detect completion const prevDownloadCountRef = useRef(0); const prevScanCountRef = useRef(0); + const pendingDependencyEvaluationRef = useRef(false); + const pendingDependencyCandidatesRef = useRef>(new Map()); // To avoid repeatedly prompting for the same missing dependencies if user clicks "Cancel" or "Ignore", // we can maintain a Set of ignored workshop IDs per session. @@ -42,7 +56,7 @@ export const DependencyPromptContainer: React.FC const currentlyScanning = currentScanCount > 0; // Compute missing dependencies globally - const calculateMissingDependencies = () => { + const calculateMissingDependencies = (taskDependencies: DiscoveredDependency[] = []) => { const missingIds = new Set(); // Check requiredItems of all addons we have (installed, disabled, downloading, or in knownUninstalled) @@ -81,7 +95,7 @@ export const DependencyPromptContainer: React.FC }); // Gather their required items - const requiredItems = new Map(); + const requiredItems = new Map(); ownedAddons.forEach(addon => { if (addon.workshopDetails?.requiredItems) { @@ -91,6 +105,13 @@ export const DependencyPromptContainer: React.FC } }); + taskDependencies.forEach((dependency) => { + const workshopId = dependency.workshopId?.trim(); + if (workshopId) { + requiredItems.set(workshopId, { ...dependency, workshopId }); + } + }); + // Filter out dependencies that are already installed or in download queue requiredItems.forEach((_, reqId) => { const isInstalled = Object.values(addons).some(a => (a.workshopId === reqId || a.id === reqId) && a.dirType !== 'none'); @@ -117,9 +138,20 @@ export const DependencyPromptContainer: React.FC fileSize: 0, filesCount: 0, workshopId: id, + imagePath: reqInfo?.previewUrl, addonInfo: { addontitle: reqInfo?.title || id - } + }, + steamDetails: { + title: reqInfo?.title || id, + creator_name: reqInfo?.creatorName, + }, + workshopDetails: { + title: reqInfo?.title || id, + previewUrl: reqInfo?.previewUrl, + creatorName: reqInfo?.creatorName, + authorName: reqInfo?.creatorName, + }, } as Addon; }); }; @@ -130,15 +162,36 @@ export const DependencyPromptContainer: React.FC const scansFinished = prevScanCountRef.current > 0 && currentScanCount === 0; if (downloadsFinished || scansFinished) { + pendingDependencyEvaluationRef.current = true; + if (scansFinished) { + backgroundTasks + .filter((task) => task.kind === 'dependency-check' && task.status === 'completed') + .flatMap((task) => task.dependencyCheck?.discoveredDependencies || []) + .forEach((dependency) => { + const workshopId = dependency.workshopId?.trim(); + if (workshopId) { + pendingDependencyCandidatesRef.current.set(workshopId, { + workshopId, + title: dependency.title, + previewUrl: dependency.previewUrl, + creatorName: dependency.creatorName, + }); + } + }); + } + } + + if (pendingDependencyEvaluationRef.current) { if (behavior === 'ignore') { - // Do nothing + pendingDependencyEvaluationRef.current = false; } else { - const missing = calculateMissingDependencies(); + const missing = calculateMissingDependencies([...pendingDependencyCandidatesRef.current.values()]); if (missing.length > 0) { + pendingDependencyEvaluationRef.current = false; if (behavior === 'always') { // Automatically download them - onDownload(missing.map(m => m.workshopId || m.id)); + onDownload(toDownloadItems(missing)); } else if (behavior === 'ask') { // Check if we are already showing the modal. // We can update the missing dependencies list or open the modal. @@ -147,7 +200,8 @@ export const DependencyPromptContainer: React.FC setModalOpen(true); } } else if (!currentlyScanning && currentDownloadCount === 0) { - // Close modal if no missing deps and everything finished + // The dependency task can complete before its database payload reaches + // this component. Keep evaluating when addon state changes. setModalOpen(false); } } @@ -155,7 +209,7 @@ export const DependencyPromptContainer: React.FC // Update modal state dynamically if it's open (e.g. scanning state changes) setIsScanning(currentlyScanning); // We can also dynamically update the missing list if new ones are found or some started downloading - const missing = calculateMissingDependencies(); + const missing = calculateMissingDependencies([...pendingDependencyCandidatesRef.current.values()]); if (missing.length === 0) { setModalOpen(false); } else { @@ -167,10 +221,10 @@ export const DependencyPromptContainer: React.FC prevScanCountRef.current = currentScanCount; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [currentDownloadCount, currentScanCount, behavior]); + }, [currentDownloadCount, currentScanCount, behavior, addons, knownUninstalledAddons, backgroundTasks]); - const handleDownload = (ids: string[]) => { - onDownload(ids); + const handleDownload = (items: DependencyDownloadItem[]) => { + onDownload(items); setModalOpen(false); }; diff --git a/src/components/DependencyPromptModal.tsx b/src/components/DependencyPromptModal.tsx index 0164774..2495225 100644 --- a/src/components/DependencyPromptModal.tsx +++ b/src/components/DependencyPromptModal.tsx @@ -4,11 +4,17 @@ import { Search, X, AlertTriangle, Package, Check } from 'lucide-react'; import { Addon } from '../types/addon'; import { CacheImage } from './CacheImage'; +export interface DependencyDownloadItem { + workshopId: string; + title?: string; + imagePath?: string; +} + interface DependencyPromptModalProps { open: boolean; missingDependencies: Addon[]; isScanning: boolean; - onDownload: (ids: string[]) => void; + onDownload: (items: DependencyDownloadItem[]) => void; onCancel: () => void; onGoToSettings: () => void; } @@ -73,7 +79,15 @@ export const DependencyPromptModal: React.FC = ({ const handleDownload = () => { if (selectedIds.size > 0) { - onDownload(Array.from(selectedIds)); + onDownload( + missingDependencies + .filter((addon) => selectedIds.has(addon.workshopId || addon.id)) + .map((addon) => ({ + workshopId: addon.workshopId || addon.id, + title: addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName, + imagePath: addon.imagePath || addon.workshopDetails?.previewUrl, + })), + ); } }; diff --git a/src/hooks/useBackgroundTasks.test.tsx b/src/hooks/useBackgroundTasks.test.tsx index 2e6b59a..a4026ab 100644 --- a/src/hooks/useBackgroundTasks.test.tsx +++ b/src/hooks/useBackgroundTasks.test.tsx @@ -63,6 +63,11 @@ describe('useBackgroundTasks dependency checks', () => { const task = result.current.backgroundTasks.find((candidate) => candidate.kind === 'dependency-check'); expect(task?.status).toBe('completed'); expect(task?.dependencyCheck?.completedCount).toBe(3); + expect(task?.dependencyCheck?.discoveredDependencies).toEqual(expect.arrayContaining([ + expect.objectContaining({ workshopId: 'B', title: 'B' }), + expect.objectContaining({ workshopId: 'C', title: 'C' }), + expect.objectContaining({ workshopId: 'A', title: 'A' }), + ])); }); expect(mockFetchDependencySnapshot.mock.calls.map(([id]) => id)).toEqual(['A', 'B', 'C']); diff --git a/src/hooks/useBackgroundTasks.ts b/src/hooks/useBackgroundTasks.ts index 6e9cbf1..7e913ae 100644 --- a/src/hooks/useBackgroundTasks.ts +++ b/src/hooks/useBackgroundTasks.ts @@ -347,6 +347,12 @@ export function useBackgroundTasks({ const pending = [...new Set(seedIds.map((id) => id.trim()).filter(Boolean))]; const visited = new Set(); const failedNodes: { workshopId: string; error: string }[] = []; + const discoveredDependencies = new Map(); let completedCount = 0; let latestData: DatabasePayload | null = null; patchTask(task.id, { @@ -361,6 +367,7 @@ export function useBackgroundTasks({ discoveredCount: pending.length, completedCount: 0, failedNodes: [], + discoveredDependencies: [], }, }); @@ -384,8 +391,25 @@ export function useBackgroundTasks({ latestData = data; } + const currentItem = discoveredDependencies.get(workshopId); + discoveredDependencies.set(workshopId, { + workshopId, + title: details.title?.trim() || currentItem?.title, + previewUrl: details.previewUrl?.trim() || currentItem?.previewUrl, + creatorName: details.creatorName?.trim() || currentItem?.creatorName, + }); + for (const dependency of details.requiredItems || []) { const dependencyId = dependency.workshopId?.trim(); + if (dependencyId) { + const existing = discoveredDependencies.get(dependencyId); + discoveredDependencies.set(dependencyId, { + workshopId: dependencyId, + title: existing?.title || dependency.title?.trim() || undefined, + previewUrl: existing?.previewUrl, + creatorName: existing?.creatorName, + }); + } if (dependencyId && !visited.has(dependencyId) && !pending.includes(dependencyId)) { pending.push(dependencyId); } @@ -405,6 +429,7 @@ export function useBackgroundTasks({ discoveredCount, completedCount, failedNodes: [...failedNodes], + discoveredDependencies: [...discoveredDependencies.values()], }, }); } @@ -425,6 +450,7 @@ export function useBackgroundTasks({ discoveredCount: visited.size, completedCount, failedNodes, + discoveredDependencies: [...discoveredDependencies.values()], }, }); } @@ -476,6 +502,7 @@ export function useBackgroundTasks({ discoveredCount: newRootIds.length, completedCount: 0, failedNodes: [], + discoveredDependencies: [], }, }; commitTasks([...tasksRef.current, task]); @@ -580,6 +607,7 @@ export function useBackgroundTasks({ discoveredCount: retryIds?.length || task.dependencyCheck?.rootIds?.length || task.targetIds.length, completedCount: 0, failedNodes: [], + discoveredDependencies: [], } : task.dependencyCheck, }); diff --git a/src/locales/en.json b/src/locales/en.json index 110c227..6155c4c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -19,6 +19,7 @@ "scanning": "Scanning...", "error": "Error", "success": "Success", + "unknownAuthor": "Unknown author", "search": "Search" }, "categories": { diff --git a/src/locales/zh.json b/src/locales/zh.json index 0fdcd1c..6e21c2e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -19,6 +19,7 @@ "scanning": "正在扫描...", "error": "错误", "success": "成功", + "unknownAuthor": "未知作者", "search": "搜索" }, "categories": { diff --git a/src/types/addon.ts b/src/types/addon.ts index 59d26da..8319258 100644 --- a/src/types/addon.ts +++ b/src/types/addon.ts @@ -126,6 +126,12 @@ export interface BackgroundTask { discoveredCount: number; completedCount: number; failedNodes: { workshopId: string; error: string }[]; + discoveredDependencies?: { + workshopId: string; + title?: string; + previewUrl?: string; + creatorName?: string; + }[]; }; } From 5ea717e0bef0e1385885af25e311d78e8428c7af Mon Sep 17 00:00:00 2001 From: Akkariin Date: Sun, 12 Jul 2026 20:02:03 +0800 Subject: [PATCH 75/81] fix: preserve workshop cover images without gallery --- .../workshop/WorkshopDetailModal.test.tsx | 43 ++++++++++++++++++- .../workshop/WorkshopDetailModal.tsx | 6 +-- src/components/workshop/ssrParser.test.ts | 19 ++++++++ src/components/workshop/ssrParser.ts | 4 +- 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/components/workshop/WorkshopDetailModal.test.tsx b/src/components/workshop/WorkshopDetailModal.test.tsx index 2d8de76..6059600 100644 --- a/src/components/workshop/WorkshopDetailModal.test.tsx +++ b/src/components/workshop/WorkshopDetailModal.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { WorkshopDetailModal } from './WorkshopDetailModal'; import type { WorkshopItem, WorkshopPageDetails } from './types'; @@ -88,4 +88,45 @@ describe('WorkshopDetailModal', () => { expect(description).toBe('Subscribe to ALL 6 PARTS.\n\nPlease rate part 1.'); expect(description).not.toBe('Subscribe to ALL 6 PART'); }); + + test('uses the scraped cover for a no-gallery item and its download task', async () => { + const pageDetails = { + ...createPageDetails(), + previewUrl: 'https://images.steamusercontent.com/ugc/cover-image/', + }; + const onDownload = vi.fn(); + mockGetWorkshopPageSnapshot.mockResolvedValue(pageDetails); + mockFetchWorkshopPageDetails.mockResolvedValue(pageDetails); + + render( + , + ); + + const cover = await screen.findByAltText('Early Days PART 1/6') as HTMLImageElement; + expect(cover.src).toBe('https://images.steamusercontent.com/ugc/cover-image/'); + + fireEvent.click(screen.getByRole('button', { name: /下载/i })); + expect(onDownload).toHaveBeenCalledWith( + '3560883926', + 'Early Days PART 1/6', + 'https://images.steamusercontent.com/ugc/cover-image/', + ); + }); }); diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx index f9c7a62..324c3e6 100644 --- a/src/components/workshop/WorkshopDetailModal.tsx +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -294,7 +294,7 @@ export const WorkshopDetailModal: React.FC = ({ // Build gallery: cover image first, then scraped screenshots const scrapedGallery = pageDetails?.imageGallery || []; - const coverUrl = item.imagePath || ''; + const coverUrl = pageDetails?.previewUrl || item.imagePath || ''; // Deduplicate: if cover is already in scraped gallery, don't add it twice const gallery = coverUrl ? [coverUrl, ...scrapedGallery.filter((u) => u !== coverUrl)] @@ -322,7 +322,7 @@ export const WorkshopDetailModal: React.FC = ({ @@ -430,7 +430,7 @@ export const WorkshopDetailModal: React.FC = ({