refactor: api cleanup

This commit is contained in:
zhom
2026-07-11 21:37:41 +04:00
parent 53db00a85a
commit ae8afbb158
27 changed files with 604 additions and 531 deletions
+1 -1
View File
@@ -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::<WayfernVersionInfo>().await {
Ok(info) => {
+247 -162
View File
@@ -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<Object>)]
pub wayfern_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
@@ -94,7 +88,6 @@ pub struct UpdateProfileRequest {
pub vpn_id: Option<String>,
pub launch_hook: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
pub extension_group_id: Option<String>,
@@ -143,7 +136,7 @@ struct CreateProxyRequest {
#[derive(Debug, Deserialize, ToSchema)]
struct UpdateProxyRequest {
name: Option<String>,
#[schema(value_type = Object)]
#[schema(value_type = Option<Object>)]
proxy_settings: Option<ProxySettings>,
}
@@ -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<Option<u16>, 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::<serde_json::Value>(&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<String, usize> {
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<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateProfileRequest>,
) -> Result<Json<ApiProfileResponse>, StatusCode> {
) -> Result<Json<ApiProfileResponse>, (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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
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<ApiServerState>,
) -> Result<Json<Vec<ApiGroupResponse>>, 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<ApiServerState>,
Json(request): Json<CreateGroupRequest>,
) -> Result<Json<ApiGroupResponse>, StatusCode> {
) -> Result<Json<ApiGroupResponse>, (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<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateGroupRequest>,
) -> Result<Json<ApiGroupResponse>, StatusCode> {
) -> Result<Json<ApiGroupResponse>, (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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
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<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
) -> Result<Json<ApiProxyResponse>, (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<String>,
State(state): State<ApiServerState>,
Json(request): Json<UpdateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
) -> Result<Json<ApiProxyResponse>, (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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
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<String>,
State(state): State<ApiServerState>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
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<String>,
State(state): State<ApiServerState>,
Json(request): Json<OpenUrlRequest>,
) -> Result<StatusCode, StatusCode> {
) -> Result<StatusCode, (StatusCode, String)> {
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<ApiServerState>,
Json(request): Json<DownloadBrowserRequest>,
) -> Result<Json<DownloadBrowserResponse>, StatusCode> {
) -> Result<Json<DownloadBrowserResponse>, (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<String>),
(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<String>,
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<String>>, StatusCode> {
) -> Result<Json<Vec<String>>, (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<String>,
}
#[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<ApiServerState>,
) -> Result<Json<WayfernTokenResponse>, 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<ApiServerState>,
) -> Result<Json<WayfernTokenResponse>, (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<String> {
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<T>` 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"
);
}
}
+26 -20
View File
@@ -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()],
+24 -11
View File
@@ -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());
}
}
+1 -1
View File
@@ -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).
+59 -42
View File
@@ -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()
+71 -73
View File
@@ -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]
+32 -7
View File
@@ -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<ExtensionGroup, Box<dyn std::error::Error>> {
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<String>,
extension_ids: Option<Vec<String>>,
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
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<ExtensionGroup, Stri
let mgr = EXTENSION_MANAGER.lock().unwrap();
mgr
.create_group(name)
.map_err(|e| format!("Failed to create extension group: {e}"))
.map_err(|e| crate::wrap_backend_error(e, "Failed to create extension group"))
}
#[tauri::command]
@@ -1132,7 +1157,7 @@ pub async fn update_extension_group(
let mgr = EXTENSION_MANAGER.lock().unwrap();
mgr
.update_group(&group_id, name, extension_ids)
.map_err(|e| format!("Failed to update extension group: {e}"))
.map_err(|e| crate::wrap_backend_error(e, "Failed to update extension group"))
}
#[tauri::command]
+16
View File
@@ -81,6 +81,14 @@ impl GroupManager {
_app_handle: &tauri::AppHandle,
name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
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<ProfileGroup, Box<dyn std::error::Error>> {
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
+14 -2
View File
@@ -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::StoredProxy, String> {
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]
+5 -166
View File
@@ -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 <path>`; 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<dyn std::error::Error + Send + Sync>> {
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
// "<Donut> wants control of <Browser>" / "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<dyn std::error::Error + Send + Sync>> {
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<dyn std::error::Error + Send + Sync>> {
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,
+20 -4
View File
@@ -82,6 +82,14 @@ impl ProfileManager {
dns_blocklist: Option<String>,
launch_hook: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
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<BrowserProfile, Box<dyn std::error::Error>> {
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)]
+6 -19
View File
@@ -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<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
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");
+8
View File
@@ -406,6 +406,10 @@ impl ProxyManager {
name: String,
proxy_settings: ProxySettings,
) -> Result<StoredProxy, String> {
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<String>,
proxy_settings: Option<ProxySettings>,
) -> Result<StoredProxy, String> {
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();