From ae8afbb15814117fa5e0444212cdece0ec177bc5 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:37:41 +0400 Subject: [PATCH] refactor: api cleanup --- AGENTS.md | 24 + src-tauri/src/api_client.rs | 2 +- src-tauri/src/api_server.rs | 409 +++++++++++------- src-tauri/src/auto_updater.rs | 46 +- src-tauri/src/browser_version_manager.rs | 35 +- src-tauri/src/cookie_manager.rs | 2 +- src-tauri/src/downloaded_browsers_registry.rs | 101 +++-- src-tauri/src/downloader.rs | 144 +++--- src-tauri/src/extension_manager.rs | 39 +- src-tauri/src/group_manager.rs | 16 + src-tauri/src/lib.rs | 16 +- src-tauri/src/platform_browser.rs | 171 +------- src-tauri/src/profile/manager.rs | 24 +- src-tauri/src/profile_importer.rs | 25 +- src-tauri/src/proxy_manager.rs | 8 + src/components/custom-toast.tsx | 4 +- src/components/import-profile-dialog.tsx | 6 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/es.json | 6 +- src/i18n/locales/fr.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/ko.json | 6 +- src/i18n/locales/pt.json | 6 +- src/i18n/locales/ru.json | 6 +- src/i18n/locales/vi.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/backend-errors.ts | 9 + 27 files changed, 604 insertions(+), 531 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 33601fb..daf00b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,30 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA Raw error strings reach the user untranslated; that's the bug pattern this rule blocks. +## REST API (`src-tauri/src/api_server.rs`) — endpoints must stay in the OpenAPI spec + +The served `/openapi.json` comes from the hand-maintained `ApiDoc` derive (`#[derive(OpenApi)]` with `paths(...)`, `components(schemas(...))`, `tags(...)`) — NOT from the router. The `OpenApiRouter`-generated spec is discarded (`let (v1_routes, _) = ...`), so a handler registered on the router but missing from `ApiDoc` silently disappears from the spec (this happened to the extension and VPN-export endpoints once). + +**Any endpoint modification — adding, removing, or changing a route, request/response schema, or status code — must be reflected in the OpenAPI spec in the same change:** + +1. Keep the handler's `#[utoipa::path]` annotation accurate (path, request body, every reachable response status). +2. Add/remove the handler in `ApiDoc`'s `paths(...)` list and any new schema types in `components(schemas(...))`. +3. Extend the `openapi_*` regression tests in `api_server.rs::tests` (they assert spec coverage and that optional fields stay optional). +4. `#[schema(value_type = Object)]` on an `Option` field erases the optionality and wrongly marks it required — use `value_type = Option` (or drop the attribute for natively supported types). + +### Error status conventions (known errors) + +Handlers route manager errors through `manager_error_response`, which maps message content onto a consistent status and passes the text through as the response body: + +- `401` — missing/invalid bearer token (auth middleware; empty body). +- `402` — the five automation endpoints (`run`, `open-url`, `kill`, `batch/run`, `batch/stop`) without a paid plan, and expired-proxy (`PROXY_PAYMENT_REQUIRED`) checks. +- `404` — entity not found (`… not found` / `*_NOT_FOUND`). +- `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input. +- `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import. +- `500` — internal failures (IO, network, poisoned locks). + +Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies. + ## Sub-page Dialog mode A `` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs: diff --git a/src-tauri/src/api_client.rs b/src-tauri/src/api_client.rs index 33ff636..0ee658c 100644 --- a/src-tauri/src/api_client.rs +++ b/src-tauri/src/api_client.rs @@ -278,7 +278,7 @@ impl ApiClient { { Ok(response) => { if !response.status().is_success() { - last_err = Some(format!("HTTP {}", response.status())); + last_err = Some(format!("HTTP {}", response.status().as_u16())); } else { match response.json::().await { Ok(info) => { diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index c6e659b..e96b0a0 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -55,9 +55,8 @@ pub struct ApiProfileResponse { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateProfileRequest { pub name: String, - /// Browser engine. Must be `"wayfern"` (anti-detect Chromium) - /// (anti-detect Firefox). Any other value (e.g. `"chromium"`) is rejected with - /// 400. + /// Browser engine. Must be `"wayfern"` (anti-detect Chromium). Any other + /// value (e.g. `"chromium"`) is rejected with 400. pub browser: String, /// Optional. Omit (or pass `"latest"`) to use the newest already-downloaded /// version of the chosen browser. A concrete version must already be @@ -72,12 +71,7 @@ pub struct CreateProfileRequest { /// Omit it, or pass an empty object `{}`, to have a fresh fingerprint /// generated automatically at creation. Provide a `fingerprint` field to /// pin a specific one. - #[schema(value_type = Object)] - /// Wayfern fingerprint/config. Send only when `browser` is `"wayfern"`. - /// Omit it, or pass an empty object `{}`, to have a fresh fingerprint - /// generated automatically at creation. Provide a `fingerprint` field to - /// pin a specific one. - #[schema(value_type = Object)] + #[schema(value_type = Option)] pub wayfern_config: Option, pub group_id: Option, pub tags: Option>, @@ -94,7 +88,6 @@ pub struct UpdateProfileRequest { pub vpn_id: Option, pub launch_hook: Option, pub release_type: Option, - #[schema(value_type = Object)] pub group_id: Option, pub tags: Option>, pub extension_group_id: Option, @@ -143,7 +136,7 @@ struct CreateProxyRequest { #[derive(Debug, Deserialize, ToSchema)] struct UpdateProxyRequest { name: Option, - #[schema(value_type = Object)] + #[schema(value_type = Option)] proxy_settings: Option, } @@ -316,10 +309,15 @@ struct BatchStopResponse { delete_proxy, get_vpns, get_vpn, + export_vpn, import_vpn, create_vpn, update_vpn, delete_vpn, + get_extensions, + get_extension_groups, + delete_extension_api, + delete_extension_group_api, download_browser_api, get_browser_versions, check_browser_downloaded, @@ -337,6 +335,7 @@ struct BatchStopResponse { CreateProxyRequest, UpdateProxyRequest, ApiVpnResponse, + ApiVpnExportResponse, ImportVpnRequest, CreateVpnRequest, UpdateVpnRequest, @@ -361,6 +360,7 @@ struct BatchStopResponse { (name = "tags", description = "Tag management endpoints"), (name = "proxies", description = "Proxy management endpoints"), (name = "vpns", description = "VPN management endpoints"), + (name = "extensions", description = "Extension management endpoints"), (name = "browsers", description = "Browser management endpoints"), (name = "cookies", description = "Cookie management endpoints"), ), @@ -468,7 +468,6 @@ impl ApiServer { .routes(routes!(download_browser_api)) .routes(routes!(get_browser_versions)) .routes(routes!(check_browser_downloaded)) - .routes(routes!(get_wayfern_token, refresh_wayfern_token)) .split_for_parts(); let api = ApiDoc::openapi(); @@ -679,6 +678,71 @@ pub async fn get_api_server_status() -> Result, String> { } // API Handlers - Profiles +/// Maps a manager-layer error onto a consistent HTTP status: 404 for missing +/// entities, 400 for validation/duplicate/client-input errors, 500 for +/// everything else (IO and other internal failures). The error text passes +/// through as the response body so API clients get a diagnostic instead of a +/// bare status code. Matching is on message content because the managers +/// return plain strings (some are the JSON `{"code": ...}` strings shared +/// with the Tauri commands). +fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { + let msg = err.to_string(); + + // Structured {"code": ...} errors from the shared managers classify exactly. + if let Ok(value) = serde_json::from_str::(&msg) { + if let Some(code) = value.get("code").and_then(|c| c.as_str()) { + let status = if code.ends_with("_NOT_FOUND") { + StatusCode::NOT_FOUND + } else if code == "INTERNAL_ERROR" { + StatusCode::INTERNAL_SERVER_ERROR + } else { + // Validation-style codes (NAME_CANNOT_BE_EMPTY, GROUP_ALREADY_EXISTS, + // WAYFERN_VERSION_NOT_AVAILABLE, ...). + StatusCode::BAD_REQUEST + }; + return (status, msg); + } + } + + // Plain-text manager messages: match the known phrases narrowly so raw + // OS/serde/network error text (e.g. "invalid type: ..." from a corrupt + // store) falls through to 500 instead of masquerading as a client error. + let lower = msg.to_lowercase(); + let status = if lower.contains("not found") { + StatusCode::NOT_FOUND + } else if lower.contains("already exists") + || lower.contains("cannot set both") + || lower.contains("cannot edit") + || lower.contains("cannot delete") + || lower.contains("cannot open url") + || lower.contains("invalid browser") + || lower.contains("invalid profile id") + || lower.contains("unsupported browser") + || lower.contains("not supported on your platform") + || lower.contains("is not downloaded") + || lower.contains("terms and conditions") + { + StatusCode::BAD_REQUEST + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + (status, msg) +} + +/// Real per-group profile counts, computed from the profile list (the same +/// source of truth the GUI uses). +fn group_profile_counts() -> std::collections::HashMap { + let mut counts = std::collections::HashMap::new(); + if let Ok(profiles) = ProfileManager::instance().list_profiles() { + for profile in profiles { + if let Some(group_id) = profile.group_id { + *counts.entry(group_id).or_insert(0) += 1; + } + } + } + counts +} + #[utoipa::path( get, path = "/v1/profiles", @@ -963,41 +1027,37 @@ async fn update_profile( Path(id): Path, State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { let profile_manager = ProfileManager::instance(); if request.proxy_id.as_deref().is_some_and(|s| !s.is_empty()) && request.vpn_id.as_deref().is_some_and(|s| !s.is_empty()) { - return Err(StatusCode::BAD_REQUEST); + return Err(( + StatusCode::BAD_REQUEST, + "Cannot set both proxy_id and vpn_id".to_string(), + )); } // Update profile fields if let Some(new_name) = request.name { - if profile_manager - .rename_profile(&state.app_handle, &id, &new_name) - .is_err() - { - return Err(StatusCode::BAD_REQUEST); + if let Err(e) = profile_manager.rename_profile(&state.app_handle, &id, &new_name) { + return Err(manager_error_response(e)); } } if let Some(version) = request.version { - if profile_manager - .update_profile_version(&state.app_handle, &id, &version) - .is_err() - { - return Err(StatusCode::BAD_REQUEST); + if let Err(e) = profile_manager.update_profile_version(&state.app_handle, &id, &version) { + return Err(manager_error_response(e)); } } if let Some(proxy_id) = request.proxy_id { - if profile_manager + if let Err(e) = profile_manager .update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id)) .await - .is_err() { - return Err(StatusCode::BAD_REQUEST); + return Err(manager_error_response(e)); } } @@ -1007,12 +1067,11 @@ async fn update_profile( } else { Some(vpn_id) }; - if profile_manager + if let Err(e) = profile_manager .update_profile_vpn(state.app_handle.clone(), &id, normalized) .await - .is_err() { - return Err(StatusCode::BAD_REQUEST); + return Err(manager_error_response(e)); } } @@ -1023,29 +1082,22 @@ async fn update_profile( Some(launch_hook) }; - if profile_manager - .update_profile_launch_hook(&state.app_handle, &id, normalized) - .is_err() - { - return Err(StatusCode::BAD_REQUEST); + if let Err(e) = profile_manager.update_profile_launch_hook(&state.app_handle, &id, normalized) { + return Err(manager_error_response(e)); } } if let Some(group_id) = request.group_id { - if profile_manager - .assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id)) - .is_err() + if let Err(e) = + profile_manager.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id)) { - return Err(StatusCode::BAD_REQUEST); + return Err(manager_error_response(e)); } } if let Some(tags) = request.tags { - if profile_manager - .update_profile_tags(&state.app_handle, &id, tags) - .is_err() - { - return Err(StatusCode::BAD_REQUEST); + if let Err(e) = profile_manager.update_profile_tags(&state.app_handle, &id, tags) { + return Err(manager_error_response(e)); } // Update tag manager with new tags from all profiles @@ -1062,34 +1114,31 @@ async fn update_profile( } else { Some(extension_group_id) }; - if profile_manager - .update_profile_extension_group(&id, ext_group) - .is_err() - { - return Err(StatusCode::BAD_REQUEST); + if let Err(e) = profile_manager.update_profile_extension_group(&id, ext_group) { + return Err(manager_error_response(e)); } } if let Some(proxy_bypass_rules) = request.proxy_bypass_rules { - if profile_manager - .update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules) - .is_err() + if let Err(e) = + profile_manager.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules) { - return Err(StatusCode::BAD_REQUEST); + return Err(manager_error_response(e)); } } if let Some(sync_mode) = request.sync_mode { - if crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode) - .await - .is_err() + if let Err(e) = + crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), sync_mode).await { - return Err(StatusCode::BAD_REQUEST); + return Err(manager_error_response(e)); } } // Return updated profile - get_profile(Path(id), State(state)).await + get_profile(Path(id), State(state)) + .await + .map_err(|status| (status, String::new())) } #[utoipa::path( @@ -1102,6 +1151,7 @@ async fn update_profile( (status = 204, description = "Profile deleted successfully"), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), + (status = 404, description = "Profile not found"), (status = 500, description = "Internal server error") ), security( @@ -1112,11 +1162,11 @@ async fn update_profile( async fn delete_profile( Path(id): Path, State(state): State, -) -> Result { +) -> Result { let profile_manager = ProfileManager::instance(); match profile_manager.delete_profile(&state.app_handle, &id) { Ok(_) => Ok(StatusCode::NO_CONTENT), - Err(_) => Err(StatusCode::BAD_REQUEST), + Err(e) => Err(manager_error_response(e)), } } @@ -1138,22 +1188,21 @@ async fn get_groups( State(_state): State, ) -> Result>, StatusCode> { match GROUP_MANAGER.lock() { - Ok(manager) => { - match manager.get_all_groups() { - Ok(groups) => { - let api_groups = groups - .into_iter() - .map(|group| ApiGroupResponse { - id: group.id, - name: group.name, - profile_count: 0, // Would need profile list to calculate this - }) - .collect(); - Ok(Json(api_groups)) - } - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Ok(manager) => match manager.get_all_groups() { + Ok(groups) => { + let counts = group_profile_counts(); + let api_groups = groups + .into_iter() + .map(|group| ApiGroupResponse { + profile_count: counts.get(&group.id).copied().unwrap_or(0), + id: group.id, + name: group.name, + }) + .collect(); + Ok(Json(api_groups)) } - } + Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -1184,9 +1233,9 @@ async fn get_group( Ok(groups) => { if let Some(group) = groups.into_iter().find(|g| g.id == id) { Ok(Json(ApiGroupResponse { + profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0), id: group.id, name: group.name, - profile_count: 0, })) } else { Err(StatusCode::NOT_FOUND) @@ -1216,7 +1265,7 @@ async fn get_group( async fn create_group( State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { match GROUP_MANAGER.lock() { Ok(manager) => match manager.create_group(&state.app_handle, request.name) { Ok(group) => Ok(Json(ApiGroupResponse { @@ -1224,9 +1273,12 @@ async fn create_group( name: group.name, profile_count: 0, })), - Err(_) => Err(StatusCode::BAD_REQUEST), + Err(e) => Err(manager_error_response(e)), }, - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "group manager unavailable".to_string(), + )), } } @@ -1253,17 +1305,20 @@ async fn update_group( Path(id): Path, State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { match GROUP_MANAGER.lock() { Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) { Ok(group) => Ok(Json(ApiGroupResponse { + profile_count: group_profile_counts().get(&group.id).copied().unwrap_or(0), id: group.id, name: group.name, - profile_count: 0, })), - Err(_) => Err(StatusCode::BAD_REQUEST), + Err(e) => Err(manager_error_response(e)), }, - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "group manager unavailable".to_string(), + )), } } @@ -1275,8 +1330,8 @@ async fn update_group( ), responses( (status = 204, description = "Group deleted successfully"), - (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), + (status = 404, description = "Group not found"), (status = 500, description = "Internal server error") ), security( @@ -1287,13 +1342,16 @@ async fn update_group( async fn delete_group( Path(id): Path, State(state): State, -) -> Result { +) -> Result { match GROUP_MANAGER.lock() { Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) { Ok(_) => Ok(StatusCode::NO_CONTENT), - Err(_) => Err(StatusCode::BAD_REQUEST), + Err(e) => Err(manager_error_response(e)), }, - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "group manager unavailable".to_string(), + )), } } @@ -1402,7 +1460,7 @@ async fn get_proxy( async fn create_proxy( State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { let result = PROXY_MANAGER.create_stored_proxy( &state.app_handle, request.name.clone(), @@ -1415,7 +1473,7 @@ async fn create_proxy( name: proxy.name, proxy_settings: proxy.proxy_settings, })), - Err(_) => Err(StatusCode::BAD_REQUEST), + Err(e) => Err(manager_error_response(e)), } } @@ -1442,7 +1500,7 @@ async fn update_proxy( Path(id): Path, State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { let result = PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings); @@ -1452,7 +1510,7 @@ async fn update_proxy( name: proxy.name, proxy_settings: proxy.proxy_settings, })), - Err(_) => Err(StatusCode::NOT_FOUND), + Err(e) => Err(manager_error_response(e)), } } @@ -1464,8 +1522,9 @@ async fn update_proxy( ), responses( (status = 204, description = "Proxy deleted successfully"), - (status = 400, description = "Bad request"), + (status = 400, description = "Bad request (e.g. cloud-managed proxy)"), (status = 401, description = "Unauthorized"), + (status = 404, description = "Proxy not found"), (status = 500, description = "Internal server error") ), security( @@ -1476,10 +1535,10 @@ async fn update_proxy( async fn delete_proxy( Path(id): Path, State(state): State, -) -> Result { +) -> Result { match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) { Ok(_) => Ok(StatusCode::NO_CONTENT), - Err(_) => Err(StatusCode::BAD_REQUEST), + Err(e) => Err(manager_error_response(e)), } } @@ -1770,6 +1829,7 @@ async fn get_extension_groups( (status = 204, description = "Extension deleted"), (status = 401, description = "Unauthorized"), (status = 404, description = "Extension not found"), + (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])), tag = "extensions" @@ -1777,12 +1837,12 @@ async fn get_extension_groups( async fn delete_extension_api( Path(id): Path, State(state): State, -) -> Result { +) -> Result { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .delete_extension(&state.app_handle, &id) .map(|_| StatusCode::NO_CONTENT) - .map_err(|_| StatusCode::NOT_FOUND) + .map_err(manager_error_response) } #[utoipa::path( @@ -1793,6 +1853,7 @@ async fn delete_extension_api( (status = 204, description = "Extension group deleted"), (status = 401, description = "Unauthorized"), (status = 404, description = "Extension group not found"), + (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])), tag = "extensions" @@ -1800,12 +1861,12 @@ async fn delete_extension_api( async fn delete_extension_group_api( Path(id): Path, State(state): State, -) -> Result { +) -> Result { let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); mgr .delete_group(&state.app_handle, &id) .map(|_| StatusCode::NO_CONTENT) - .map_err(|_| StatusCode::NOT_FOUND) + .map_err(manager_error_response) } // API Handler - Run Profile with Remote Debugging @@ -1820,7 +1881,9 @@ async fn delete_extension_group_api( (status = 200, description = "Profile launched successfully", body = RunProfileResponse), (status = 400, description = "Cannot launch cross-OS profile"), (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), + (status = 409, description = "Profile is locked by another team member"), (status = 500, description = "Internal server error") ), security( @@ -1905,7 +1968,9 @@ async fn run_profile( request_body = OpenUrlRequest, responses( (status = 200, description = "URL opened successfully"), + (status = 400, description = "Cannot open URL with a cross-OS profile"), (status = 401, description = "Unauthorized"), + (status = 402, description = "Active paid plan with browser automation required"), (status = 404, description = "Profile not found"), (status = 500, description = "Internal server error") ), @@ -1918,12 +1983,12 @@ async fn open_url_in_profile( Path(id): Path, State(state): State, Json(request): Json, -) -> Result { +) -> Result { if !crate::cloud_auth::CLOUD_AUTH .can_use_browser_automation() .await { - return Err(StatusCode::PAYMENT_REQUIRED); + return Err((StatusCode::PAYMENT_REQUIRED, String::new())); } let browser_runner = crate::browser_runner::BrowserRunner::instance(); @@ -1931,7 +1996,7 @@ async fn open_url_in_profile( browser_runner .open_url_with_profile(state.app_handle.clone(), id, request.url) .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + .map_err(manager_error_response)?; Ok(StatusCode::OK) } @@ -2230,9 +2295,11 @@ async fn import_profile_cookies( path = "/v1/browsers/download", request_body = DownloadBrowserRequest, responses( - (status = 200, description = "Browser download initiated", body = DownloadBrowserResponse), + (status = 200, description = "Browser downloaded (or already present)", body = DownloadBrowserResponse), + (status = 400, description = "Invalid browser or version not available for download"), (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") + (status = 409, description = "This browser version is already being downloaded"), + (status = 500, description = "Internal server error (e.g. network failure)") ), security( ("bearer_auth" = []) @@ -2242,20 +2309,27 @@ async fn import_profile_cookies( async fn download_browser_api( State(state): State, Json(request): Json, -) -> Result, StatusCode> { +) -> Result, (StatusCode, String)> { match crate::downloader::download_browser( state.app_handle.clone(), request.browser.clone(), - request.version.clone(), + request.version, ) .await { - Ok(_) => Ok(Json(DownloadBrowserResponse { + // Echo the version the downloader actually installed, not the requested one. + Ok(version) => Ok(Json(DownloadBrowserResponse { browser: request.browser, - version: request.version, + version, status: "downloaded".to_string(), })), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => { + if e.contains("already being downloaded") { + Err((StatusCode::CONFLICT, e)) + } else { + Err(manager_error_response(e)) + } + } } } @@ -2268,6 +2342,7 @@ async fn download_browser_api( ), responses( (status = 200, description = "List of available browser versions", body = Vec), + (status = 400, description = "Unsupported browser"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error") ), @@ -2279,7 +2354,7 @@ async fn download_browser_api( async fn get_browser_versions( Path(browser): Path, State(_state): State, -) -> Result>, StatusCode> { +) -> Result>, (StatusCode, String)> { let version_manager = crate::browser_version_manager::BrowserVersionManager::instance(); match version_manager @@ -2287,7 +2362,7 @@ async fn get_browser_versions( .await { Ok(result) => Ok(Json(result.versions)), - Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + Err(e) => Err(manager_error_response(e)), } } @@ -2317,57 +2392,6 @@ async fn check_browser_downloaded( Ok(Json(is_downloaded)) } -// API Handlers - Wayfern Token - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct WayfernTokenResponse { - pub token: Option, -} - -#[utoipa::path( - get, - path = "/v1/wayfern-token", - responses( - (status = 200, description = "Current wayfern token", body = WayfernTokenResponse), - (status = 401, description = "Unauthorized"), - ), - security( - ("bearer_auth" = []) - ), - tag = "wayfern" -)] -async fn get_wayfern_token( - State(_state): State, -) -> Result, StatusCode> { - let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; - Ok(Json(WayfernTokenResponse { token })) -} - -#[utoipa::path( - post, - path = "/v1/wayfern-token/refresh", - responses( - (status = 200, description = "Refreshed wayfern token", body = WayfernTokenResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Failed to refresh token"), - ), - security( - ("bearer_auth" = []) - ), - tag = "wayfern" -)] -async fn refresh_wayfern_token( - State(_state): State, -) -> Result, (StatusCode, String)> { - crate::cloud_auth::CLOUD_AUTH - .request_wayfern_token() - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; - - let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; - Ok(Json(WayfernTokenResponse { token })) -} - #[cfg(test)] mod tests { use super::*; @@ -2415,7 +2439,68 @@ mod tests { let is_valid = |b: &str| b == "wayfern"; assert!(is_valid("wayfern")); assert!(!is_valid("chromium")); - assert!(!is_valid("firefox")); assert!(!is_valid("")); } + + fn schema_required(spec: &serde_json::Value, schema: &str) -> Vec { + spec["components"]["schemas"][schema]["required"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } + + // `#[schema(value_type = Object)]` on an `Option` erases the optionality + // and marks the field required in the served spec; these fields must stay + // optional so generated clients aren't forced to send them. + #[test] + fn openapi_optional_fields_are_not_required() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + + let create_profile = schema_required(&spec, "CreateProfileRequest"); + assert!( + !create_profile.iter().any(|f| f == "wayfern_config"), + "wayfern_config must be optional, required list: {create_profile:?}" + ); + + let update_profile = schema_required(&spec, "UpdateProfileRequest"); + assert!( + !update_profile.iter().any(|f| f == "group_id"), + "group_id must be optional, required list: {update_profile:?}" + ); + + let update_proxy = schema_required(&spec, "UpdateProxyRequest"); + assert!( + !update_proxy.iter().any(|f| f == "proxy_settings"), + "proxy_settings must be optional on update, required list: {update_proxy:?}" + ); + } + + // The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)` + // list, not from the router — endpoints registered on the router but missing + // from ApiDoc silently disappear from the spec. Lock in the ones that were + // once dropped, and that removed endpoints stay gone. + #[test] + fn openapi_spec_covers_registered_routes() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + let paths = spec["paths"].as_object().expect("paths object"); + + for path in [ + "/v1/vpns/{id}/export", + "/v1/extensions", + "/v1/extension-groups", + "/v1/extensions/{id}", + "/v1/extension-groups/{id}", + ] { + assert!(paths.contains_key(path), "missing from ApiDoc: {path}"); + } + + assert!( + !paths.keys().any(|p| p.contains("wayfern-token")), + "wayfern-token endpoints were removed and must stay out of the spec" + ); + } } diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 74c5651..f659bd2 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -802,13 +802,13 @@ mod tests { let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf()); let mut state = AutoUpdateState::default(); - state.disabled_browsers.insert("firefox".to_string()); + state.disabled_browsers.insert("testbrowser".to_string()); state .auto_update_downloads - .insert("firefox-1.1.0".to_string()); + .insert("testbrowser-1.1.0".to_string()); state.pending_updates.push(UpdateNotification { id: "test".to_string(), - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), current_version: "1.0.0".to_string(), new_version: "1.1.0".to_string(), affected_profiles: vec!["profile1".to_string()], @@ -830,9 +830,11 @@ mod tests { serde_json::from_str(&content).expect("Failed to deserialize state"); assert_eq!(loaded_state.disabled_browsers.len(), 1); - assert!(loaded_state.disabled_browsers.contains("firefox")); + assert!(loaded_state.disabled_browsers.contains("testbrowser")); assert_eq!(loaded_state.auto_update_downloads.len(), 1); - assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0")); + assert!(loaded_state + .auto_update_downloads + .contains("testbrowser-1.1.0")); assert_eq!(loaded_state.pending_updates.len(), 1); assert_eq!(loaded_state.pending_updates[0].id, "test"); } @@ -871,16 +873,16 @@ mod tests { // Initially not disabled (empty state file means default state) let state = AutoUpdateState::default(); assert!( - !state.disabled_browsers.contains("firefox"), - "Firefox should not be disabled initially" + !state.disabled_browsers.contains("testbrowser"), + "testbrowser should not be disabled initially" ); // Start update (should disable) let mut state = AutoUpdateState::default(); - state.disabled_browsers.insert("firefox".to_string()); + state.disabled_browsers.insert("testbrowser".to_string()); state .auto_update_downloads - .insert("firefox-1.1.0".to_string()); + .insert("testbrowser-1.1.0".to_string()); let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state"); std::fs::write(&state_file, json).expect("Failed to write state file"); @@ -889,18 +891,20 @@ mod tests { let loaded_state: AutoUpdateState = serde_json::from_str(&content).expect("Failed to deserialize state"); assert!( - loaded_state.disabled_browsers.contains("firefox"), - "Firefox should be disabled" + loaded_state.disabled_browsers.contains("testbrowser"), + "testbrowser should be disabled" ); assert!( - loaded_state.auto_update_downloads.contains("firefox-1.1.0"), - "Firefox download should be tracked" + loaded_state + .auto_update_downloads + .contains("testbrowser-1.1.0"), + "testbrowser download should be tracked" ); // Complete update (should enable) let mut state = loaded_state; - state.disabled_browsers.remove("firefox"); - state.auto_update_downloads.remove("firefox-1.1.0"); + state.disabled_browsers.remove("testbrowser"); + state.auto_update_downloads.remove("testbrowser-1.1.0"); let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state"); std::fs::write(&state_file, json).expect("Failed to write final state file"); @@ -909,12 +913,14 @@ mod tests { let final_state: AutoUpdateState = serde_json::from_str(&content).expect("Failed to deserialize final state"); assert!( - !final_state.disabled_browsers.contains("firefox"), - "Firefox should be enabled again" + !final_state.disabled_browsers.contains("testbrowser"), + "testbrowser should be enabled again" ); assert!( - !final_state.auto_update_downloads.contains("firefox-1.1.0"), - "Firefox download should not be tracked anymore" + !final_state + .auto_update_downloads + .contains("testbrowser-1.1.0"), + "testbrowser download should not be tracked anymore" ); } @@ -945,7 +951,7 @@ mod tests { let mut state = AutoUpdateState::default(); state.pending_updates.push(UpdateNotification { id: "test_notification".to_string(), - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), current_version: "1.0.0".to_string(), new_version: "1.1.0".to_string(), affected_profiles: vec!["profile1".to_string()], diff --git a/src-tauri/src/browser_version_manager.rs b/src-tauri/src/browser_version_manager.rs index af13b1c..4fbb76b 100644 --- a/src-tauri/src/browser_version_manager.rs +++ b/src-tauri/src/browser_version_manager.rs @@ -109,7 +109,7 @@ impl BrowserVersionManager { self.api_client.is_cache_expired(browser) } - /// Get the latest Wayfern version (cached first) + /// Get the latest Wayfern version (fresh cache first) pub async fn get_browser_release_types( &self, browser: &str, @@ -118,17 +118,30 @@ impl BrowserVersionManager { return Err(format!("Unsupported browser: {browser}").into()); } - if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) { - return Ok(BrowserReleaseTypes { - stable: cached_versions.first().map(|v| v.version.clone()), - }); + // Only trust an unexpired cache. A stale entry can point at a version that + // is no longer published — the downloader rejects such requests, so serving + // it here would make every download started from this list fail. + if !self.api_client.is_cache_expired(browser) { + if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) { + return Ok(BrowserReleaseTypes { + stable: cached_versions.first().map(|v| v.version.clone()), + }); + } } - let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?; - - Ok(BrowserReleaseTypes { - stable: detailed_versions.first().map(|v| v.version.clone()), - }) + // Expired or missing cache: fetch fresh, falling back to whatever cache + // exists when the network is unavailable. + match self.fetch_browser_versions_detailed(browser, false).await { + Ok(detailed_versions) => Ok(BrowserReleaseTypes { + stable: detailed_versions.first().map(|v| v.version.clone()), + }), + Err(e) => match self.get_cached_browser_versions_detailed(browser) { + Some(cached_versions) => Ok(BrowserReleaseTypes { + stable: cached_versions.first().map(|v| v.version.clone()), + }), + None => Err(e), + }, + } } /// Fetch browser versions with optional caching @@ -440,7 +453,7 @@ mod tests { assert!(wayfern_info.url.contains("download.wayfern.com")); - let unsupported_result = service.get_download_info("firefox", "1.0.0"); + let unsupported_result = service.get_download_info("testbrowser", "1.0.0"); assert!(unsupported_result.is_err()); } } diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 7c2a009..4bc5f2b 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -1427,7 +1427,7 @@ mod tests { .unwrap(); // Imported session cookies are promoted to persistent with a far-future - // expiry so an imported login survives relaunch (mirrors the Firefox writer). + // expiry so an imported login survives relaunch. assert_eq!(has_expires, 1); assert_eq!(is_persistent, 1); // Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01). diff --git a/src-tauri/src/downloaded_browsers_registry.rs b/src-tauri/src/downloaded_browsers_registry.rs index dc68012..d21d090 100644 --- a/src-tauri/src/downloaded_browsers_registry.rs +++ b/src-tauri/src/downloaded_browsers_registry.rs @@ -1057,15 +1057,15 @@ mod tests { fn test_add_and_get_browser() { let registry = DownloadedBrowsersRegistry::new(); let info = DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "139.0".to_string(), file_path: PathBuf::from("/test/path"), }; registry.add_browser(info.clone()); - assert!(registry.is_browser_registered("firefox", "139.0")); - assert!(!registry.is_browser_registered("firefox", "140.0")); + assert!(registry.is_browser_registered("testbrowser", "139.0")); + assert!(!registry.is_browser_registered("testbrowser", "140.0")); assert!(!registry.is_browser_registered("chrome", "139.0")); } @@ -1074,19 +1074,19 @@ mod tests { let registry = DownloadedBrowsersRegistry::new(); let info1 = DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "139.0".to_string(), file_path: PathBuf::from("/test/path1"), }; let info2 = DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "140.0".to_string(), file_path: PathBuf::from("/test/path2"), }; let info3 = DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "141.0".to_string(), file_path: PathBuf::from("/test/path3"), }; @@ -1095,7 +1095,7 @@ mod tests { registry.add_browser(info2); registry.add_browser(info3); - let versions = registry.get_downloaded_versions("firefox"); + let versions = registry.get_downloaded_versions("testbrowser"); assert_eq!(versions.len(), 3); assert!(versions.contains(&"139.0".to_string())); assert!(versions.contains(&"140.0".to_string())); @@ -1107,22 +1107,22 @@ mod tests { let registry = DownloadedBrowsersRegistry::new(); // Mark download started - registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path")); + registry.mark_download_started("testbrowser", "139.0", PathBuf::from("/test/path")); // Should NOT be registered until verification completes assert!( - !registry.is_browser_registered("firefox", "139.0"), + !registry.is_browser_registered("testbrowser", "139.0"), "Browser should NOT be registered after marking as started (only after verification)" ); // Mark as completed (after verification) registry - .mark_download_completed("firefox", "139.0", PathBuf::from("/test/path")) + .mark_download_completed("testbrowser", "139.0", PathBuf::from("/test/path")) .expect("Failed to mark download as completed"); // Should now be registered assert!( - registry.is_browser_registered("firefox", "139.0"), + registry.is_browser_registered("testbrowser", "139.0"), "Browser should be registered after verification completes" ); } @@ -1131,24 +1131,24 @@ mod tests { fn test_remove_browser() { let registry = DownloadedBrowsersRegistry::new(); let info = DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "139.0".to_string(), file_path: PathBuf::from("/test/path"), }; registry.add_browser(info); assert!( - registry.is_browser_registered("firefox", "139.0"), + registry.is_browser_registered("testbrowser", "139.0"), "Browser should be registered after adding" ); - let removed = registry.remove_browser("firefox", "139.0"); + let removed = registry.remove_browser("testbrowser", "139.0"); assert!( removed.is_some(), "Remove operation should return the removed browser info" ); assert!( - !registry.is_browser_registered("firefox", "139.0"), + !registry.is_browser_registered("testbrowser", "139.0"), "Browser should not be registered after removal" ); } @@ -1182,11 +1182,11 @@ mod tests { fn test_last_version_kept_during_cleanup() { let registry = DownloadedBrowsersRegistry::new(); - // Add a single version for "firefox" + // Add a single version for "testbrowser" registry.add_browser(DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "139.0".to_string(), - file_path: PathBuf::from("/test/firefox/139.0"), + file_path: PathBuf::from("/test/testbrowser/139.0"), }); // Add two versions for "chromium" @@ -1206,11 +1206,11 @@ mod tests { .cleanup_unused_binaries_internal(&[], &[]) .expect("cleanup should succeed"); - // firefox 139.0 should be kept (last version), chromium should lose one but keep one + // testbrowser 139.0 should be kept (last version), chromium should lose one but keep one // The exact one kept depends on iteration order, but at least one must remain assert!( - !result.contains(&"firefox 139.0".to_string()), - "Last version of firefox should not be cleaned up" + !result.contains(&"testbrowser 139.0".to_string()), + "Last version of testbrowser should not be cleaned up" ); // At most one chromium version should have been cleaned up let chromium_cleaned: Vec<_> = result @@ -1223,10 +1223,10 @@ mod tests { chromium_cleaned ); - // Verify firefox is still registered + // Verify testbrowser is still registered assert!( - registry.is_browser_registered("firefox", "139.0"), - "Last firefox version should still be registered" + registry.is_browser_registered("testbrowser", "139.0"), + "Last testbrowser version should still be registered" ); } @@ -1234,7 +1234,7 @@ mod tests { fn test_is_browser_registered_vs_downloaded() { let registry = DownloadedBrowsersRegistry::new(); let info = DownloadedBrowserInfo { - browser: "firefox".to_string(), + browser: "testbrowser".to_string(), version: "139.0".to_string(), file_path: PathBuf::from("/test/path"), }; @@ -1244,14 +1244,14 @@ mod tests { // Should be registered (in-memory check) assert!( - registry.is_browser_registered("firefox", "139.0"), + registry.is_browser_registered("testbrowser", "139.0"), "Browser should be registered after adding to registry" ); // is_browser_downloaded should return false in test environment because files don't exist // This tests the difference between registered (in registry) vs downloaded (files exist) assert!( - !registry.is_browser_downloaded("firefox", "139.0"), + !registry.is_browser_downloaded("testbrowser", "139.0"), "Browser should not be considered downloaded when files don't exist on disk" ); } @@ -1277,21 +1277,38 @@ pub async fn ensure_active_browsers_downloaded( } log::info!("ensure_active: No {browser} versions found, will download"); - // Get the latest release type for this browser - let release_types = match version_manager.get_browser_release_types(browser).await { - Ok(rt) => rt, - Err(e) => { - log::warn!("Failed to get release types for {browser}: {e}"); - continue; + // Resolve the version to download. For wayfern, only the currently + // published version is downloadable, so ask the API fresh — the release-type + // cache can be stale right after a new version is published, and the + // downloader rejects requests for versions that are no longer available. + let version = if *browser == "wayfern" { + match crate::api_client::ApiClient::instance() + .fetch_wayfern_version_with_caching(true) + .await + { + Ok(info) => info.version, + Err(e) => { + log::warn!("Failed to resolve current {browser} version: {e}"); + continue; + } } - }; + } else { + // Get the latest release type for this browser + let release_types = match version_manager.get_browser_release_types(browser).await { + Ok(rt) => rt, + Err(e) => { + log::warn!("Failed to get release types for {browser}: {e}"); + continue; + } + }; - // Use stable version (the only release type for these browsers) - let version = match release_types.stable { - Some(v) => v, - None => { - log::debug!("No stable version available for {browser} on this platform, skipping"); - continue; + // Use stable version (the only release type for these browsers) + match release_types.stable { + Some(v) => v, + None => { + log::debug!("No stable version available for {browser} on this platform, skipping"); + continue; + } } }; @@ -1330,8 +1347,8 @@ pub async fn ensure_active_browsers_downloaded( Err(_) => { // The download future itself hung past the overall timeout and was dropped, // so its own cleanup never ran. Clear any leftover in-progress bookkeeping - // (the future may have re-resolved to a different version, so clear by - // browser prefix) and emit a terminal error event so the UI stops spinning. + // (by browser prefix, as a catch-all for whatever key was in flight) and + // emit a terminal error event so the UI stops spinning. log::warn!( "Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})", ATTEMPT_TIMEOUT.as_secs() diff --git a/src-tauri/src/downloader.rs b/src-tauri/src/downloader.rs index 78f8f0e..6dcac42 100644 --- a/src-tauri/src/downloader.rs +++ b/src-tauri/src/downloader.rs @@ -23,6 +23,22 @@ lazy_static::lazy_static! { std::sync::Arc::new(Mutex::new(std::collections::HashMap::new())); } +/// Clears a browser-version pair from the in-flight download maps on every +/// exit path of `download_browser_full`. A leaked key would permanently report +/// "already being downloaded" for that version until app restart. +struct InFlightDownload(String); + +impl Drop for InFlightDownload { + fn drop(&mut self) { + if let Ok(mut downloading) = DOWNLOADING_BROWSERS.lock() { + downloading.remove(&self.0); + } + if let Ok(mut tokens) = DOWNLOAD_CANCELLATION_TOKENS.lock() { + tokens.remove(&self.0); + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct DownloadProgress { pub browser: String, @@ -97,7 +113,13 @@ impl Downloader { .await?; if !response.status().is_success() { - return Err(format!("Download failed with status: {}", response.status()).into()); + return Err( + format!( + "Download failed with HTTP status {}", + response.status().as_u16() + ) + .into(), + ); } let mut file = std::fs::OpenOptions::new() @@ -131,10 +153,16 @@ impl Downloader { .fetch_wayfern_version_with_caching(true) .await?; + // Never substitute: downloading the current build into the requested + // version's directory would register a mislabeled install. if version_info.version != version { - log::info!( - "Wayfern: requested version {version}, using available version {}", - version_info.version + return Err( + serde_json::json!({ + "code": "WAYFERN_VERSION_NOT_AVAILABLE", + "params": { "requested": version, "current": version_info.version } + }) + .to_string() + .into(), ); } @@ -301,7 +329,13 @@ impl Downloader { // Check if the response is successful (200 OK or 206 Partial Content) if !(response.status().is_success() || response.status().as_u16() == 206) { - return Err(format!("Download failed with status: {}", response.status()).into()); + return Err( + format!( + "Download failed with HTTP status {}", + response.status().as_u16() + ) + .into(), + ); } // Determine total size @@ -504,25 +538,36 @@ impl Downloader { return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into()); } - // For Wayfern, resolve the actual available version from the API - let version = if browser_str == "wayfern" { - match self + // Validate the browser type before touching the in-flight maps so a bad + // request can't leave state behind. + let browser_type = + BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?; + let browser = create_browser(browser_type.clone()); + + // For Wayfern, only the currently published version can be fetched. + // Requesting any other not-yet-downloaded version is an error — silently + // substituting the latest would install a version the caller never asked + // for while the response still echoes the requested one. The fetch must + // succeed too: proceeding unverified would let resolve_download_url fetch + // the current build into the requested version's directory (a mislabeled + // install), and that URL resolution needs the same endpoint anyway. + if browser_str == "wayfern" && !self.registry.is_browser_downloaded(&browser_str, &version) { + let info = self .api_client .fetch_wayfern_version_with_caching(true) .await - { - Ok(info) if info.version != version => { - log::info!( - "Wayfern: requested {version}, using available {}", - info.version - ); - info.version - } - _ => version, + .map_err(|e| format!("Failed to determine the current Wayfern version: {e}"))?; + if info.version != version { + return Err( + serde_json::json!({ + "code": "WAYFERN_VERSION_NOT_AVAILABLE", + "params": { "requested": version, "current": info.version } + }) + .to_string() + .into(), + ); } - } else { - version - }; + } // Check if this browser-version pair is already being downloaded let download_key = format!("{browser_str}-{version}"); @@ -539,10 +584,8 @@ impl Downloader { tokens.insert(download_key.clone(), token.clone()); token }; - - let browser_type = - BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?; - let browser = create_browser(browser_type.clone()); + // Cleared on drop, whatever exit path this function takes. + let _in_flight = InFlightDownload(download_key.clone()); let binaries_dir = crate::app_dirs::binaries_dir(); @@ -551,12 +594,6 @@ impl Downloader { let actually_exists = browser.is_version_downloaded(&version, &binaries_dir); if actually_exists { - // Remove from downloading set since it's already downloaded - let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); - downloading.remove(&download_key); - drop(downloading); - let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); - tokens.remove(&download_key); return Ok(version); } else { // Registry says it's downloaded but files don't exist - clean up registry @@ -575,12 +612,6 @@ impl Downloader { .is_browser_supported(&browser_str) .unwrap_or(false) { - // Remove from downloading set on error - let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); - downloading.remove(&download_key); - drop(downloading); - let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); - tokens.remove(&download_key); return Err( format!( "Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}", @@ -630,11 +661,6 @@ impl Downloader { // Clean registry entry and stop here so the UI can show a single, clear error. let _ = self.registry.remove_browser(&browser_str, &version); let _ = self.registry.save(); - let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); - downloading.remove(&download_key); - drop(downloading); - let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); - tokens.remove(&download_key); // Emit a terminal stage so the UI stops spinning. A user cancellation maps to // "cancelled"; any other failure (network error, stall timeout, bad status) @@ -687,14 +713,6 @@ impl Downloader { let _ = self.registry.remove_browser(&browser_str, &version); let _ = self.registry.save(); - { - let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); - downloading.remove(&download_key); - } - { - let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); - tokens.remove(&download_key); - } // Emit error stage so the UI shows a toast let progress = DownloadProgress { @@ -777,15 +795,6 @@ impl Downloader { }; let _ = events::emit("download-progress", &progress); - // Remove browser-version pair from downloading set on verification failure - { - let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); - downloading.remove(&download_key); - } - { - let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); - tokens.remove(&download_key); - } return Err(error_details.into()); } @@ -825,16 +834,6 @@ impl Downloader { }; let _ = events::emit("download-progress", &progress); - // Remove browser-version pair from downloading set and cancel token - { - let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap(); - downloading.remove(&download_key); - } - { - let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap(); - tokens.remove(&download_key); - } - // Auto-update non-running profiles to the latest installed version and cleanup unused binaries { let app_handle_for_update = app_handle.clone(); @@ -883,10 +882,9 @@ pub fn is_downloading(browser: &str, version: &str) -> bool { /// Clear all in-progress download bookkeeping for a browser. /// /// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped -/// by an outer timeout) before its own error path could run. Because -/// `download_browser_full` may re-resolve to a different version than requested, this -/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck -/// key is left behind regardless of which version was actually in flight. +/// by an outer timeout) before its own error path could run. Matches by the +/// `"{browser}-"` key prefix rather than an exact version so no stuck key is left +/// behind even when the caller doesn't know which version was actually in flight. pub fn clear_download_state_for_browser(browser: &str) { let prefix = format!("{browser}-"); { @@ -909,7 +907,7 @@ pub async fn download_browser( downloader .download_browser_full(&app_handle, browser_str, version) .await - .map_err(|e| format!("Failed to download browser: {e}")) + .map_err(|e| crate::wrap_backend_error(e, "Failed to download browser")) } #[tauri::command] diff --git a/src-tauri/src/extension_manager.rs b/src-tauri/src/extension_manager.rs index 72166cb..00d33e2 100644 --- a/src-tauri/src/extension_manager.rs +++ b/src-tauri/src/extension_manager.rs @@ -280,12 +280,21 @@ impl ExtensionManager { let (manifest_name, version, description, author, homepage_url) = extract_manifest_metadata(&file_data, &file_type); - let final_name = if manifest_name.is_some() { - manifest_name.clone().unwrap_or(name) - } else { - name + // An empty/whitespace-only manifest name counts as absent so the + // user-provided name still applies. + let final_name = match manifest_name.clone() { + Some(n) if !n.trim().is_empty() => n, + _ => name, }; + if final_name.trim().is_empty() { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + let ext = Extension { id: uuid::Uuid::new_v4().to_string(), name: final_name, @@ -510,6 +519,14 @@ impl ExtensionManager { } pub fn create_group(&self, name: String) -> Result> { + if name.trim().is_empty() { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + let mut data = self.load_groups_data()?; if data.groups.iter().any(|g| g.name == name) { @@ -566,6 +583,14 @@ impl ExtensionManager { name: Option, extension_ids: Option>, ) -> Result> { + if name.as_deref().is_some_and(|n| n.trim().is_empty()) { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + let mut data = self.load_groups_data()?; if let Some(ref new_name) = name { @@ -1080,7 +1105,7 @@ pub async fn add_extension( let mgr = EXTENSION_MANAGER.lock().unwrap(); mgr .add_extension(name, file_name, file_data) - .map_err(|e| format!("Failed to add extension: {e}")) + .map_err(|e| crate::wrap_backend_error(e, "Failed to add extension")) } #[tauri::command] @@ -1120,7 +1145,7 @@ pub async fn create_extension_group(name: String) -> Result Result> { + if name.trim().is_empty() { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + let mut groups_data = self.load_groups_data()?; // Check if group with this name already exists @@ -127,6 +135,14 @@ impl GroupManager { id: String, name: String, ) -> Result> { + if name.trim().is_empty() { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + let mut groups_data = self.load_groups_data()?; // Check if another group with this name already exists diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e1e4d6..db7f185 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -245,6 +245,18 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin Ok(()) } +/// Prefix a command error with context, but pass structured `{"code": ...}` +/// backend errors through untouched — the frontend can only translate a code +/// when the JSON is the entire message (see src/lib/backend-errors.ts). +pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String { + let msg = e.to_string(); + if msg.starts_with('{') { + msg + } else { + format!("{context}: {msg}") + } +} + #[tauri::command] async fn create_stored_proxy( app_handle: tauri::AppHandle, @@ -254,7 +266,7 @@ async fn create_stored_proxy( if let Some(settings) = proxy_settings { crate::proxy_manager::PROXY_MANAGER .create_stored_proxy(&app_handle, name, settings) - .map_err(|e| format!("Failed to create stored proxy: {e}")) + .map_err(|e| wrap_backend_error(e, "Failed to create stored proxy")) } else { Err("proxy_settings is required".to_string()) } @@ -274,7 +286,7 @@ async fn update_stored_proxy( ) -> Result { crate::proxy_manager::PROXY_MANAGER .update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) - .map_err(|e| format!("Failed to update stored proxy: {e}")) + .map_err(|e| wrap_backend_error(e, "Failed to update stored proxy")) } #[tauri::command] diff --git a/src-tauri/src/platform_browser.rs b/src-tauri/src/platform_browser.rs index d20dbfa..3ee57bb 100644 --- a/src-tauri/src/platform_browser.rs +++ b/src-tauri/src/platform_browser.rs @@ -15,8 +15,7 @@ use std::process::Command; fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool { let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect(); for (i, arg) in args.iter().enumerate() { - // Exact argument equality (Firefox: `-profile `; some launchers - // pass the path as its own arg). + // Exact argument equality (some launchers pass the path as its own arg). if *arg == profile_path { return true; } @@ -85,59 +84,6 @@ pub mod macos { } } - pub async fn open_url_in_existing_browser_firefox_like( - profile: &BrowserProfile, - url: &str, - browser_type: BrowserType, - browser_dir: &Path, - profiles_dir: &Path, - ) -> Result<(), Box> { - let pid = profile.process_id.unwrap(); - let profile_data_path = profile.get_profile_data_path(profiles_dir); - - // First try: Use Firefox remote command - log::info!("Trying Firefox remote command for PID: {pid}"); - let browser = create_browser(browser_type); - if let Ok(executable_path) = browser.get_executable_path(browser_dir) { - let remote_args = vec![ - "-profile".to_string(), - profile_data_path.to_string_lossy().to_string(), - "-new-tab".to_string(), - url.to_string(), - ]; - - let remote_output = Command::new(executable_path).args(&remote_args).output(); - - match remote_output { - Ok(output) if output.status.success() => { - log::info!("Firefox remote command succeeded"); - return Ok(()); - } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - log::info!( - "Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback" - ); - } - Err(e) => { - log::info!("Firefox remote command error: {e}, trying AppleScript fallback"); - } - } - } - - // The Firefox `-new-tab` remote command failed. We intentionally do NOT - // fall back to an AppleScript `System Events` keystroke path: that would - // send Apple Events to another application and trigger the macOS TCC - // " wants control of " / "prevented from modifying other - // apps" prompts. Donut must never touch other apps on the user's Mac. - Err( - format!( - "Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps" - ) - .into(), - ) - } - pub async fn kill_browser_process_impl( pid: u32, profile_data_path: Option<&str>, @@ -399,77 +345,6 @@ pub mod windows { Ok(child) } - pub async fn open_url_in_existing_browser_firefox_like( - profile: &BrowserProfile, - url: &str, - browser_type: BrowserType, - browser_dir: &Path, - profiles_dir: &Path, - ) -> Result<(), Box> { - let browser = create_browser(browser_type); - let executable_path = browser - .get_executable_path(browser_dir) - .map_err(|e| format!("Failed to get executable path: {}", e))?; - - let profile_data_path = profile.get_profile_data_path(profiles_dir); - - // For Windows, try using the -requestPending approach for Firefox - let mut cmd = Command::new(executable_path); - cmd.args([ - "-profile", - &profile_data_path.to_string_lossy(), - "-requestPending", - "-new-tab", - url, - ]); - - // Set working directory - if let Some(parent_dir) = browser_dir - .parent() - .or_else(|| browser_dir.ancestors().nth(1)) - { - cmd.current_dir(parent_dir); - } - - let output = cmd.output()?; - - if !output.status.success() { - // Fallback: try without -requestPending - let executable_path = browser - .get_executable_path(browser_dir) - .map_err(|e| format!("Failed to get executable path: {}", e))?; - let mut fallback_cmd = Command::new(executable_path); - let profile_data_path = profile.get_profile_data_path(profiles_dir); - fallback_cmd.args([ - "-profile", - &profile_data_path.to_string_lossy(), - "-new-tab", - url, - ]); - - if let Some(parent_dir) = browser_dir - .parent() - .or_else(|| browser_dir.ancestors().nth(1)) - { - fallback_cmd.current_dir(parent_dir); - } - - let fallback_output = fallback_cmd.output()?; - - if !fallback_output.status.success() { - return Err( - format!( - "Failed to open URL in existing browser: {}", - String::from_utf8_lossy(&fallback_output.stderr) - ) - .into(), - ); - } - } - - Ok(()) - } - pub async fn open_url_in_existing_browser_chromium( profile: &BrowserProfile, url: &str, @@ -588,7 +463,7 @@ pub mod linux { let mut cmd = Command::new(executable_path); cmd.args(args); - // For Firefox-based browsers, ensure library path includes the installation directory + // Ensure the library path includes the installation directory if let Some(install_dir) = executable_path.parent() { let mut ld_library_path = Vec::new(); @@ -606,16 +481,15 @@ pub mod linux { } } - // For Firefox specifically, add common system library paths that might be needed - let firefox_lib_paths = [ - "/usr/lib/firefox", + // Add common system library paths that might be needed + let system_lib_paths = [ "/usr/lib/x86_64-linux-gnu", "/usr/lib/aarch64-linux-gnu", "/lib/x86_64-linux-gnu", "/lib/aarch64-linux-gnu", ]; - for lib_path in &firefox_lib_paths { + for lib_path in &system_lib_paths { let path = std::path::Path::new(lib_path); if path.exists() { ld_library_path.push(lib_path.to_string()); @@ -686,41 +560,6 @@ pub mod linux { } } - pub async fn open_url_in_existing_browser_firefox_like( - profile: &BrowserProfile, - url: &str, - browser_type: BrowserType, - browser_dir: &Path, - profiles_dir: &Path, - ) -> Result<(), Box> { - let browser = create_browser(browser_type); - let executable_path = browser - .get_executable_path(browser_dir) - .map_err(|e| format!("Failed to get executable path: {}", e))?; - - let profile_data_path = profile.get_profile_data_path(profiles_dir); - let output = Command::new(executable_path) - .args([ - "-profile", - &profile_data_path.to_string_lossy(), - "-new-tab", - url, - ]) - .output()?; - - if !output.status.success() { - return Err( - format!( - "Failed to open URL in existing browser: {}", - String::from_utf8_lossy(&output.stderr) - ) - .into(), - ); - } - - Ok(()) - } - pub async fn open_url_in_existing_browser_chromium( profile: &BrowserProfile, url: &str, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 6287def..935dab7 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -82,6 +82,14 @@ impl ProfileManager { dns_blocklist: Option, launch_hook: Option, ) -> Result> { + if name.trim().is_empty() { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + if proxy_id.is_some() && vpn_id.is_some() { return Err("Cannot set both proxy_id and vpn_id".into()); } @@ -399,6 +407,14 @@ impl ProfileManager { profile_id: &str, new_name: &str, ) -> Result> { + if new_name.trim().is_empty() { + return Err( + serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }) + .to_string() + .into(), + ); + } + // Check if new name already exists (case insensitive) let existing_profiles = self.list_profiles()?; if existing_profiles @@ -1285,7 +1301,7 @@ impl ProfileManager { let profile_data_path_str = profile_data_path.to_string_lossy(); let profile_path_match = cmd.iter().any(|s| { let arg = s.to_str().unwrap_or(""); - // For Firefox-based browsers, check for exact profile path match + // Match the Chromium --user-data-dir flag or an exact profile path argument arg.contains(&format!("--user-data-dir={profile_data_path_str}")) || arg == profile_data_path_str }); @@ -1323,7 +1339,7 @@ impl ProfileManager { let profile_data_path_str = profile_data_path.to_string_lossy(); let profile_path_match = cmd.iter().any(|s| { let arg = s.to_str().unwrap_or(""); - // For Firefox-based browsers, check for exact profile path match + // Match the Chromium --user-data-dir flag or an exact profile path argument arg.contains(&format!("--user-data-dir={profile_data_path_str}")) || arg == profile_data_path_str }); @@ -1649,7 +1665,7 @@ pub async fn create_browser_profile_with_group( launch_hook, ) .await - .map_err(|e| format!("Failed to create profile: {e}")) + .map_err(|e| crate::wrap_backend_error(e, "Failed to create profile")) } #[tauri::command] @@ -1799,7 +1815,7 @@ pub fn rename_profile( let profile_manager = ProfileManager::instance(); profile_manager .rename_profile(&app_handle, &profile_id, &new_name) - .map_err(|e| format!("Failed to rename profile: {e}")) + .map_err(|e| crate::wrap_backend_error(e, "Failed to rename profile")) } #[allow(clippy::too_many_arguments)] diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index c75f3ac..4575346 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -19,12 +19,9 @@ pub struct DetectedProfile { pub description: String, } -fn map_browser_type(browser: &str) -> &str { - // Legacy Firefox-family sources map to Wayfern at import time. - match browser { - "firefox" | "firefox-developer" | "zen" | "camoufox" => "wayfern", - _ => "wayfern", - } +fn map_browser_type(_browser: &str) -> &str { + // Every import source maps to Wayfern — the only launchable engine. + "wayfern" } pub struct ProfileImporter { @@ -53,9 +50,9 @@ impl ProfileImporter { ) -> Result, Box> { let mut detected_profiles = Vec::new(); - // Firefox-based browsers (Firefox, Firefox Developer, Zen) map to Camoufox, - // which is deprecated — they can no longer be imported. Only Chromium-based - // sources (mapping to Wayfern) are detected. + // Only Chromium-based sources (mapping to Wayfern) are detected. Gecko-family + // sources mapped to Camoufox, which was removed, so they can no longer be + // imported. detected_profiles.extend(self.detect_chrome_profiles()?); detected_profiles.extend(self.detect_brave_profiles()?); detected_profiles.extend(self.detect_chromium_profiles()?); @@ -210,8 +207,6 @@ impl ProfileImporter { fn get_browser_display_name(&self, browser_type: &str) -> &str { match browser_type { - "firefox" => "Firefox", - "firefox-developer" => "Firefox Developer", "chromium" => "Chrome/Chromium", "brave" => "Brave", "zen" => "Zen Browser", @@ -508,11 +503,6 @@ mod tests { fn test_get_browser_display_name() { let (importer, _temp_dir) = create_test_profile_importer(); - assert_eq!(importer.get_browser_display_name("firefox"), "Firefox"); - assert_eq!( - importer.get_browser_display_name("firefox-developer"), - "Firefox Developer" - ); assert_eq!( importer.get_browser_display_name("chromium"), "Chrome/Chromium" @@ -527,9 +517,6 @@ mod tests { #[test] fn test_map_browser_type() { - assert_eq!(map_browser_type("firefox"), "wayfern"); - assert_eq!(map_browser_type("firefox-developer"), "wayfern"); - assert_eq!(map_browser_type("zen"), "wayfern"); assert_eq!(map_browser_type("chromium"), "wayfern"); assert_eq!(map_browser_type("brave"), "wayfern"); assert_eq!(map_browser_type("camoufox"), "wayfern"); diff --git a/src-tauri/src/proxy_manager.rs b/src-tauri/src/proxy_manager.rs index 2e831d4..be15d5c 100644 --- a/src-tauri/src/proxy_manager.rs +++ b/src-tauri/src/proxy_manager.rs @@ -406,6 +406,10 @@ impl ProxyManager { name: String, proxy_settings: ProxySettings, ) -> Result { + if name.trim().is_empty() { + return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()); + } + // Check if name already exists { let stored_proxies = self.stored_proxies.lock().unwrap(); @@ -795,6 +799,10 @@ impl ProxyManager { name: Option, proxy_settings: Option, ) -> Result { + if name.as_deref().is_some_and(|n| n.trim().is_empty()) { + return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()); + } + // First, check for conflicts without holding a mutable reference { let stored_proxies = self.stored_proxies.lock().unwrap(); diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index 97702ac..7f9337c 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -26,14 +26,14 @@ * * Auto-update toast: * ``` - * showAutoUpdateToast("Firefox", "125.0.1"); + * showAutoUpdateToast("Wayfern", "149.0.7827.116"); * ``` * * Download progress toast: * ``` * showToast({ * type: "download", - * title: "Downloading Firefox 123.0", + * title: "Downloading Wayfern 149.0.7827.116", * progress: { percentage: 45, speed: "2.5", eta: "30s" } * }); * ``` diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx index b87639f..55d22d7 100644 --- a/src/components/import-profile-dialog.tsx +++ b/src/components/import-profile-dialog.tsx @@ -517,11 +517,11 @@ export function ImportProfileDialog({ {t("importProfile.examplePaths")}
macOS: ~/Library/Application - Support/Firefox/Profiles/xxx.default + Support/Google/Chrome/Default
- Windows: %APPDATA%\Mozilla\Firefox\Profiles\xxx.default + Windows: %LOCALAPPDATA%\Google\Chrome\User Data\Default
- Linux: ~/.mozilla/firefox/xxx.default + Linux: ~/.config/google-chrome/Default

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 73efc56..f48d6b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "The selected proxy isn't working, so the profile wasn't created.", "proxyPaymentRequired": "The selected proxy requires payment (402) — its subscription may have expired — so the profile wasn't created.", "vpnNotWorking": "The selected VPN isn't working, so the profile wasn't created.", - "camoufoxImportDeprecated": "Importing Firefox-based profiles is no longer supported. Please use Wayfern instead.", + "camoufoxImportDeprecated": "Importing this profile type is no longer supported. Please use Wayfern instead.", "updateChecksumsUnavailable": "The update {{version}} could not be verified because its checksum file could not be retrieved. The update was not installed; it will be retried later.", - "updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again." + "updateChecksumMismatch": "The downloaded update file {{file}} failed checksum verification and was discarded. Please try again.", + "nameCannotBeEmpty": "Name cannot be empty", + "wayfernVersionNotAvailable": "Wayfern version {{requested}} is not available for download. The current version is {{current}}." }, "rail": { "profiles": "Profiles", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f3a82cd..a66c14c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "El proxy seleccionado no funciona, por lo que no se creó el perfil.", "proxyPaymentRequired": "El proxy seleccionado requiere pago (402) —su suscripción puede haber vencido— por lo que no se creó el perfil.", "vpnNotWorking": "La VPN seleccionada no funciona, por lo que no se creó el perfil.", - "camoufoxImportDeprecated": "La importación de perfiles basados en Firefox ya no está soportada. Utilice Wayfern en su lugar.", + "camoufoxImportDeprecated": "La importación de este tipo de perfil ya no es compatible. Utiliza Wayfern en su lugar.", "updateChecksumsUnavailable": "No se pudo verificar la actualización {{version}} porque no se pudo obtener su archivo de sumas de comprobación. La actualización no se instaló; se reintentará más tarde.", - "updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo." + "updateChecksumMismatch": "El archivo de actualización descargado {{file}} no superó la verificación de suma de comprobación y fue descartado. Inténtalo de nuevo.", + "nameCannotBeEmpty": "El nombre no puede estar vacío", + "wayfernVersionNotAvailable": "La versión {{requested}} de Wayfern no está disponible para descargar. La versión actual es {{current}}." }, "rail": { "profiles": "Perfiles", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d231626..3326925 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "Le proxy sélectionné ne fonctionne pas, le profil n'a donc pas été créé.", "proxyPaymentRequired": "Le proxy sélectionné requiert un paiement (402) — son abonnement a peut-être expiré — le profil n'a donc pas été créé.", "vpnNotWorking": "Le VPN sélectionné ne fonctionne pas, le profil n'a donc pas été créé.", - "camoufoxImportDeprecated": "L'importation de profils basés sur Firefox n'est plus prise en charge. Utilisez Wayfern à la place.", + "camoufoxImportDeprecated": "L'importation de ce type de profil n'est plus prise en charge. Veuillez utiliser Wayfern à la place.", "updateChecksumsUnavailable": "La mise à jour {{version}} n'a pas pu être vérifiée car son fichier de sommes de contrôle n'a pas pu être récupéré. La mise à jour n'a pas été installée ; une nouvelle tentative aura lieu plus tard.", - "updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer." + "updateChecksumMismatch": "Le fichier de mise à jour téléchargé {{file}} a échoué à la vérification de la somme de contrôle et a été supprimé. Veuillez réessayer.", + "nameCannotBeEmpty": "Le nom ne peut pas être vide", + "wayfernVersionNotAvailable": "La version {{requested}} de Wayfern n'est pas disponible au téléchargement. La version actuelle est {{current}}." }, "rail": { "profiles": "Profils", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index e95fdeb..33fd1b7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "選択したプロキシが機能していないため、プロファイルは作成されませんでした。", "proxyPaymentRequired": "選択したプロキシは支払いが必要です(402)。サブスクリプションが期限切れの可能性があります。そのため、プロファイルは作成されませんでした。", "vpnNotWorking": "選択したVPNが機能していないため、プロファイルは作成されませんでした。", - "camoufoxImportDeprecated": "Firefox ベースのプロファイルのインポートはサポートされなくなりました。Wayfern をご利用ください。", + "camoufoxImportDeprecated": "このタイプのプロファイルのインポートはサポートされなくなりました。代わりにWayfernを使用してください。", "updateChecksumsUnavailable": "アップデート {{version}} のチェックサムファイルを取得できなかったため、検証できませんでした。アップデートはインストールされませんでした。後で再試行されます。", - "updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。" + "updateChecksumMismatch": "ダウンロードしたアップデートファイル {{file}} はチェックサム検証に失敗したため破棄されました。もう一度お試しください。", + "nameCannotBeEmpty": "名前を空にすることはできません", + "wayfernVersionNotAvailable": "Wayfernのバージョン{{requested}}はダウンロードできません。現在のバージョンは{{current}}です。" }, "rail": { "profiles": "プロファイル", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index bc455d6..70d16ea 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "선택한 프록시가 작동하지 않아 프로필이 생성되지 않았습니다.", "proxyPaymentRequired": "선택한 프록시는 결제가 필요합니다(402). 구독이 만료되었을 수 있어 프로필이 생성되지 않았습니다.", "vpnNotWorking": "선택한 VPN이 작동하지 않아 프로필이 생성되지 않았습니다.", - "camoufoxImportDeprecated": "Firefox 기반 프로필 가져오기는 더 이상 지원되지 않습니다. Wayfern을 사용하세요.", + "camoufoxImportDeprecated": "이 유형의 프로필 가져오기는 더 이상 지원되지 않습니다. 대신 Wayfern을 사용하세요.", "updateChecksumsUnavailable": "업데이트 {{version}}의 체크섬 파일을 가져올 수 없어 검증하지 못했습니다. 업데이트가 설치되지 않았으며 나중에 다시 시도됩니다.", - "updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요." + "updateChecksumMismatch": "다운로드한 업데이트 파일 {{file}}이(가) 체크섬 검증에 실패하여 삭제되었습니다. 다시 시도해 주세요.", + "nameCannotBeEmpty": "이름은 비워둘 수 없습니다", + "wayfernVersionNotAvailable": "Wayfern 버전 {{requested}}은(는) 다운로드할 수 없습니다. 현재 버전은 {{current}}입니다." }, "rail": { "profiles": "프로필", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 7fc193a..79dfc29 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "O proxy selecionado não está funcionando, então o perfil não foi criado.", "proxyPaymentRequired": "O proxy selecionado exige pagamento (402) — sua assinatura pode ter expirado — então o perfil não foi criado.", "vpnNotWorking": "A VPN selecionada não está funcionando, então o perfil não foi criado.", - "camoufoxImportDeprecated": "A importação de perfis baseados em Firefox não é mais suportada. Use o Wayfern.", + "camoufoxImportDeprecated": "A importação deste tipo de perfil não é mais suportada. Use o Wayfern em vez disso.", "updateChecksumsUnavailable": "Não foi possível verificar a atualização {{version}} porque o arquivo de somas de verificação não pôde ser obtido. A atualização não foi instalada; será tentada novamente mais tarde.", - "updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente." + "updateChecksumMismatch": "O arquivo de atualização baixado {{file}} falhou na verificação de soma de verificação e foi descartado. Tente novamente.", + "nameCannotBeEmpty": "O nome não pode estar vazio", + "wayfernVersionNotAvailable": "A versão {{requested}} do Wayfern não está disponível para download. A versão atual é {{current}}." }, "rail": { "profiles": "Perfis", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 1994752..04039ab 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "Выбранный прокси не работает, поэтому профиль не создан.", "proxyPaymentRequired": "Выбранный прокси требует оплаты (402) — возможно, его подписка истекла — поэтому профиль не создан.", "vpnNotWorking": "Выбранный VPN не работает, поэтому профиль не создан.", - "camoufoxImportDeprecated": "Импорт профилей на основе Firefox больше не поддерживается. Используйте Wayfern.", + "camoufoxImportDeprecated": "Импорт профилей этого типа больше не поддерживается. Используйте Wayfern.", "updateChecksumsUnavailable": "Не удалось проверить обновление {{version}}: файл контрольных сумм не удалось получить. Обновление не было установлено; попытка будет повторена позже.", - "updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз." + "updateChecksumMismatch": "Загруженный файл обновления {{file}} не прошёл проверку контрольной суммы и был удалён. Попробуйте ещё раз.", + "nameCannotBeEmpty": "Имя не может быть пустым", + "wayfernVersionNotAvailable": "Версия Wayfern {{requested}} недоступна для загрузки. Текущая версия — {{current}}." }, "rail": { "profiles": "Профили", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 69e13e3..d8f7b30 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "Proxy đã chọn không hoạt động, nên profile chưa được tạo.", "proxyPaymentRequired": "Proxy đã chọn yêu cầu thanh toán (402) — gói đăng ký của nó có thể đã hết hạn — nên profile chưa được tạo.", "vpnNotWorking": "VPN đã chọn không hoạt động, nên profile chưa được tạo.", - "camoufoxImportDeprecated": "Không còn hỗ trợ nhập profile dựa trên Firefox. Vui lòng dùng Wayfern.", + "camoufoxImportDeprecated": "Việc nhập loại hồ sơ này không còn được hỗ trợ. Vui lòng sử dụng Wayfern thay thế.", "updateChecksumsUnavailable": "Không thể xác minh bản cập nhật {{version}} vì không thể tải tệp checksum. Bản cập nhật chưa được cài đặt; sẽ thử lại sau.", - "updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại." + "updateChecksumMismatch": "Tệp cập nhật đã tải xuống {{file}} không vượt qua kiểm tra checksum và đã bị loại bỏ. Vui lòng thử lại.", + "nameCannotBeEmpty": "Tên không được để trống", + "wayfernVersionNotAvailable": "Phiên bản Wayfern {{requested}} không có sẵn để tải xuống. Phiên bản hiện tại là {{current}}." }, "rail": { "profiles": "Profile", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index aee84be..ced6c9b 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1798,9 +1798,11 @@ "proxyNotWorking": "所选代理无法使用,因此未创建配置文件。", "proxyPaymentRequired": "所选代理需要付费(402),其订阅可能已过期,因此未创建配置文件。", "vpnNotWorking": "所选 VPN 无法使用,因此未创建配置文件。", - "camoufoxImportDeprecated": "不再支持导入基于 Firefox 的配置文件。请改用 Wayfern。", + "camoufoxImportDeprecated": "不再支持导入此类型的配置文件。请改用 Wayfern。", "updateChecksumsUnavailable": "无法验证更新 {{version}}:无法获取其校验和文件。更新未安装,稍后将重试。", - "updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。" + "updateChecksumMismatch": "下载的更新文件 {{file}} 未通过校验和验证,已被丢弃。请重试。", + "nameCannotBeEmpty": "名称不能为空", + "wayfernVersionNotAvailable": "Wayfern 版本 {{requested}} 无法下载。当前版本为 {{current}}。" }, "rail": { "profiles": "配置文件", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index ed70186..2dcf78d 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -23,6 +23,8 @@ export type BackendErrorCode = | "PROXY_NOT_FOUND" | "GROUP_NOT_FOUND" | "GROUP_ALREADY_EXISTS" + | "NAME_CANNOT_BE_EMPTY" + | "WAYFERN_VERSION_NOT_AVAILABLE" | "VPN_NOT_FOUND" | "EXTENSION_NOT_FOUND" | "EXTENSION_GROUP_NOT_FOUND" @@ -118,6 +120,13 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.groupNotFound"); case "GROUP_ALREADY_EXISTS": return t("backendErrors.groupAlreadyExists"); + case "NAME_CANNOT_BE_EMPTY": + return t("backendErrors.nameCannotBeEmpty"); + case "WAYFERN_VERSION_NOT_AVAILABLE": + return t("backendErrors.wayfernVersionNotAvailable", { + requested: parsed.params?.requested ?? "", + current: parsed.params?.current ?? "", + }); case "VPN_NOT_FOUND": return t("backendErrors.vpnNotFound"); case "EXTENSION_NOT_FOUND":