diff --git a/e2e/tests/motion.test.mjs b/e2e/tests/motion.test.mjs index d8325edf..0cb39ab8 100644 --- a/e2e/tests/motion.test.mjs +++ b/e2e/tests/motion.test.mjs @@ -1554,10 +1554,11 @@ test("profile notes save through the inspector and real launch failures retain a )?.note === "Remember the launch context", { description: "note saved" }, ); - // A cross-platform launch is rejected before starting any process. + // A profile bound to another OS is rejected before starting any process. + // Wayfern profiles run on every OS, so the bound one is another browser's. const otherOs = process.platform === "darwin" ? "linux" : "macos"; await app.invokeError("launch_browser_profile", { - profile: { ...profile, host_os: otherOs }, + profile: { ...profile, browser: "camoufox", host_os: otherOs }, url: null, }); await waitForSelector( diff --git a/sdk/node/src/types.mts b/sdk/node/src/types.mts index 8463fdb0..3e605438 100644 --- a/sdk/node/src/types.mts +++ b/sdk/node/src/types.mts @@ -46,7 +46,11 @@ export interface ApiProfile { sync_mode: string; cloud_sync_enabled: boolean; host_os?: string | null; - /** A profile from another OS can only ever run on a remote host of that OS. */ + /** + * True when a browser bound to its OS created the profile on another OS, so + * it can only run on a remote host of that OS. Always false for Wayfern + * profiles, which run on every OS. + */ is_cross_os: boolean; fingerprint_os?: string | null; } diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index dbd864fe..947ca869 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -65,9 +65,10 @@ pub struct ApiProfile { /// OS the profile was created on (`"macos"`, `"windows"`, `"linux"`). /// `null` when neither `host_os` nor the browser config records one. pub host_os: Option, - /// True when the profile belongs to a different OS than this machine. - /// Such a profile cannot be launched locally, and must only ever run on a - /// remote host of its own OS — Chromium profile state is OS-specific. + /// True when the profile cannot be launched on this machine because a + /// browser bound to its OS created it on another OS; such a profile can only + /// run on a remote host of its own OS. Always false for Wayfern profiles, + /// which sync their data and launch on every OS. pub is_cross_os: bool, /// The fingerprint operating system set at creation via `wayfern_config.os` /// (`"windows"`, `"macos"`, `"linux"`, `"android"` or `"ios"`), or `null` @@ -1292,9 +1293,10 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) { StatusCode::CONFLICT } else if code == "INTERNAL_ERROR" { StatusCode::INTERNAL_SERVER_ERROR - } else if code == "PROFILE_LOCK_UNAVAILABLE" { - // The lock service could not be reached. The launch is refused because - // it cannot be proven safe, which is an upstream failure, not the + } else if code == "PROFILE_LOCK_UNAVAILABLE" || code == "PROFILE_DATA_NOT_DOWNLOADED" { + // The lock service, or the sync storage holding a profile's first + // download, could not be reached. The launch is refused because it + // cannot be proven safe, which is an upstream failure, not the // caller's fault. StatusCode::SERVICE_UNAVAILABLE } else if code.ends_with("_REQUIRES_PRO") @@ -2905,7 +2907,7 @@ async fn remove_extension_from_group_api( (status = 404, description = "Profile not found"), (status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"), (status = 429, description = "Automation request rate limit exceeded"), - (status = 503, description = "The profile lock service could not be reached"), + (status = 503, description = "The profile lock service could not be reached, or the profile's files could not be downloaded from sync before its first launch here"), (status = 500, description = "Internal server error") ), security( @@ -3058,9 +3060,9 @@ async fn run_profile_remote( } } - // Deliberately NO is_cross_os() guard here. Local /run refuses a foreign - // profile because this machine is the wrong OS; running it remotely on a host - // of its OWN OS is precisely what this endpoint exists for. + // Deliberately NO is_cross_os() guard here. Local /run refuses a profile + // bound to another OS because this machine is the wrong OS; running it + // remotely on a host of its OWN OS is precisely what this endpoint exists for. let outcome = crate::remote_session::start_remote_session(state.app_handle.clone(), profile, request.url) .await @@ -4044,7 +4046,7 @@ async fn get_cookie_bot_usage( (status = 404, description = "Profile not found"), (status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"), (status = 429, description = "Automation request rate limit exceeded"), - (status = 503, description = "The profile lock service could not be reached"), + (status = 503, description = "The profile lock service could not be reached, or the profile's files could not be downloaded from sync before its first launch here"), (status = 500, description = "Internal server error") ), security( @@ -5062,14 +5064,16 @@ mod tests { "windows" }; let foreign = profile_with(SyncMode::Regular, Some(other)); + // A Wayfern profile from another OS runs locally too, and remotely. + assert!(!foreign.is_cross_os()); + assert!(remote_launch_profile_rules(&foreign).is_ok()); - assert!( - foreign.is_cross_os(), - "test setup: profile should be foreign" - ); + let mut bound = foreign.clone(); + bound.browser = "camoufox".to_string(); + assert!(bound.is_cross_os(), "test setup: profile should be foreign"); // Local /run refuses this; running it remotely on a host of its own OS is // exactly what /run-remote is for. - assert!(remote_launch_profile_rules(&foreign).is_ok()); + assert!(remote_launch_profile_rules(&bound).is_ok()); } #[tokio::test] @@ -5113,9 +5117,9 @@ mod tests { assert!(encrypted.cloud_sync_enabled); } - // A profile must only ever run on its own operating system: Chromium's - // on-disk state is OS-specific, so replaying a macOS profile on Windows is a - // mismatch no amount of user-agent spoofing repairs. + // `host_os` reports where the profile was created; `is_cross_os` reports + // whether that stops it from launching here, which it never does for a + // Wayfern profile. #[test] fn api_profile_reports_its_operating_system() { let host = crate::profile::types::get_host_os(); @@ -5130,7 +5134,13 @@ mod tests { }; let foreign = ApiProfile::from(&profile_with(SyncMode::Regular, Some(other))); assert_eq!(foreign.host_os.as_deref(), Some(other)); - assert!(foreign.is_cross_os); + assert!(!foreign.is_cross_os); + + let mut bound = profile_with(SyncMode::Regular, Some(other)); + bound.browser = "camoufox".to_string(); + let bound = ApiProfile::from(&bound); + assert_eq!(bound.host_os.as_deref(), Some(other)); + assert!(bound.is_cross_os); } #[test] @@ -6072,6 +6082,16 @@ mod tests { assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); } + #[test] + fn a_first_download_that_cannot_complete_is_not_the_callers_fault() { + let (status, body) = manager_error_response(crate::backend_error_with_detail( + "PROFILE_DATA_NOT_DOWNLOADED", + "offline", + )); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert!(body.contains("PROFILE_DATA_NOT_DOWNLOADED")); + } + #[test] fn a_rejected_extension_upload_is_the_callers_problem_not_a_server_fault() { // Every one of these is something about the request: the wrong file type, a diff --git a/src-tauri/src/browser_runner.rs b/src-tauri/src/browser_runner.rs index 8d90808d..dea765db 100644 --- a/src-tauri/src/browser_runner.rs +++ b/src-tauri/src/browser_runner.rs @@ -66,12 +66,13 @@ fn log_err(err: &str, profile: &BrowserProfile) -> String { } /// Outcomes the person meets as a dialog or a notice, not a fault. -const LAUNCH_BLOCK_CODES: [&str; 10] = [ +const LAUNCH_BLOCK_CODES: [&str; 11] = [ "FINGERPRINT_EXIT_MISMATCH", "LAUNCH_CONSENT_EXPIRED", "PROFILE_RUNNING", "PROFILE_RUNNING_REMOTELY", "PROFILE_REMOTE_SYNC_PENDING", + "PROFILE_DATA_NOT_DOWNLOADED", "PROFILE_LOCKED_BY_MEMBER", "PROFILE_LOCKED_ELSEWHERE", "PROFILE_ROUTE_REQUIRED", @@ -122,6 +123,17 @@ fn log_launch_failure(profile: &BrowserProfile, err: &str, started: std::time::I ); } +/// Why this machine refuses to launch the profile locally, or `None` when it +/// may. A Wayfern profile is never refused for the OS that created it. +fn local_os_refusal(profile: &BrowserProfile) -> Option { + profile.is_cross_os().then(|| { + format!( + "this profile was created on {} and cannot be launched on a different operating system", + profile.host_os.as_deref().unwrap_or("another OS"), + ) + }) +} + pub struct BrowserRunner { pub profile_manager: &'static ProfileManager, pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry, @@ -1453,10 +1465,10 @@ impl BrowserRunner { let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await; // A profile already open on the leased fleet is driven, not launched. This - // sits above the cross-OS guard on purpose: a Windows profile cannot run on - // this Mac, which is the whole reason it is running remotely, and refusing - // to point it at a URL for that reason would make the remote session - // unusable from the one endpoint that exists to use it. + // sits above the cross-OS guard on purpose: a profile bound to another OS + // cannot run here, and refusing to point its remote session at a URL for + // that reason would make the session unusable from the one endpoint that + // exists to use it. // // Guarded on there being no local process, so a profile running here never // pays for the lookup: a local launch records a pid, and the profile lock @@ -1483,11 +1495,10 @@ impl BrowserRunner { } } - if profile.is_cross_os() { + if let Some(reason) = local_os_refusal(&profile) { return Err(format!( - "Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system", - profile.name, - profile.host_os.as_deref().unwrap_or("another OS"), + "Cannot open URL with profile '{}': {reason}", + profile.name )); } @@ -1498,6 +1509,10 @@ impl BrowserRunner { // to a local launch on a profile a host was writing to. crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?; let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?; + if let Err(e) = crate::sync::download_before_first_launch(&app_handle, &profile).await { + unwind_launch(&profile, acquired_team_lock).await; + return Err(e); + } let started = std::time::Instant::now(); // Use launch_or_open_url which handles both launching new instances and opening in existing ones @@ -1662,11 +1677,10 @@ async fn launch_browser_profile_tracked( } = options; emit_launch_stage(&profile, "preparing", None); - if profile.is_cross_os() { + if let Some(reason) = local_os_refusal(&profile) { return Err(format!( - "Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system", - profile.name, - profile.host_os.as_deref().unwrap_or("another OS"), + "Cannot launch profile '{}': {reason}", + profile.name )); } @@ -1679,6 +1693,13 @@ async fn launch_browser_profile_tracked( // Team lock check: if profile is sync-enabled and user is on a team, acquire lock let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?; + // A profile that reached this machine as metadata alone downloads its files + // first; launched empty, its next sync would erase the real profile. + if let Err(e) = crate::sync::download_before_first_launch(&app_handle, &profile).await { + unwind_launch(&profile, acquired_team_lock).await; + return Err(e); + } + // Notify sync scheduler that profile is now running and queue sync for when it stops if let Some(scheduler) = crate::sync::get_global_scheduler() { let pid = profile.id.to_string(); @@ -1940,4 +1961,44 @@ mod tests { .is_ok() ); } + + fn profile_created_on(browser: &str, os: &str) -> BrowserProfile { + BrowserProfile { + name: "p".to_string(), + browser: browser.to_string(), + host_os: Some(os.to_string()), + ..BrowserProfile::default() + } + } + + #[test] + fn a_wayfern_profile_from_any_os_launches_locally() { + for os in ["macos", "windows", "linux"] { + assert_eq!( + local_os_refusal(&profile_created_on("wayfern", os)), + None, + "a Wayfern profile created on {os} must launch here" + ); + } + } + + #[test] + fn a_profile_bound_to_another_os_is_refused_locally() { + let host = crate::profile::types::get_host_os(); + let other = if host == "windows" { + "macos" + } else { + "windows" + }; + let reason = local_os_refusal(&profile_created_on("camoufox", other)) + .expect("a profile bound to another OS must be refused"); + assert!( + reason.contains(other), + "the reason must name the OS: {reason}" + ); + assert_eq!( + local_os_refusal(&profile_created_on("camoufox", &host)), + None + ); + } } diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 76d698b5..e549d34c 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -9469,9 +9469,9 @@ impl McpServer { /// Start this profile on a host of its own operating system. /// - /// Deliberately no `is_cross_os` guard: local `run_profile` refuses a foreign - /// profile because THIS machine is the wrong OS, and running it on a host of - /// its own OS is precisely what this exists for. + /// Deliberately no `is_cross_os` guard: local `run_profile` refuses a profile + /// bound to another OS because THIS machine is the wrong OS, and running it + /// on a host of its own OS is precisely what this exists for. async fn handle_run_profile_remote( &self, arguments: &serde_json::Value, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index c90774ab..43b8ce18 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -601,10 +601,10 @@ impl ProfileManager { // // Only a real host OS may be stored here. The fallback in // `resolved_os` reads `wayfern_config.os`, which is a fingerprint OS - // and may be "android"/"ios". Persisting that made `is_cross_os` - // permanently true and locked the profile out of every local launch, - // with no way to undo it from the UI. Leaving `host_os` as None keeps - // the profile launchable, which is what it was before the field. + // and may be "android"/"ios". Persisting that records a creating + // machine that cannot exist, with no way to undo it from the UI. + // Leaving `host_os` as None means "unknown", which is what it was + // before the field. let needs_repair = profile.host_os.as_deref().is_some_and(|os| !is_host_os(os)); if profile.host_os.is_none() || needs_repair { let inferred_os = profile @@ -1617,9 +1617,13 @@ impl ProfileManager { { return Err(crate::backend_error("FINGERPRINT_REQUIRES_PRO").into()); } - if !CLOUD_AUTH - .is_fingerprint_os_allowed(config.os.as_deref()) - .await + // Only a new claim is checked: a profile created on another OS keeps the + // claim it arrived with, and editing anything else must not refuse it. + let os_changed = config.os != stored.and_then(|c| c.os.clone()); + if os_changed + && !CLOUD_AUTH + .is_fingerprint_os_allowed(config.os.as_deref()) + .await { return Err(crate::backend_error("FINGERPRINT_REQUIRES_PRO").into()); } diff --git a/src-tauri/src/profile/trash.rs b/src-tauri/src/profile/trash.rs index 73c59104..e1f6ce9a 100644 --- a/src-tauri/src/profile/trash.rs +++ b/src-tauri/src/profile/trash.rs @@ -446,7 +446,8 @@ pub fn purge_expired(trash_root: &Path, now: u64, retention_days: u32) -> Vec bool { profile .process_id @@ -1043,12 +1044,15 @@ mod tests { let mut profile = sample_profile("Running"); profile.process_id = Some(std::process::id()); assert!(is_running_locally(&profile)); - // A cross-OS profile can never be running on this machine. + // A Wayfern profile created on another OS runs here like any other. profile.host_os = Some(if cfg!(target_os = "macos") { "linux".to_string() } else { "macos".to_string() }); + assert!(is_running_locally(&profile)); + // A profile bound to another OS can never be running on this machine. + profile.browser = "camoufox".to_string(); assert!(!is_running_locally(&profile)); let mut idle = sample_profile("Idle"); idle.process_id = None; diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index e0172842..ec037e14 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -114,8 +114,8 @@ pub fn get_host_os() -> String { /// /// A fingerprint OS is a wider set than a host OS: `"android"` and `"ios"` are /// valid fingerprints but no machine ever reports them as its host. Storing one -/// in `host_os` makes `is_cross_os` permanently true, which bars the profile -/// from every local launch path on the very machine that created it. +/// in `host_os` records a creating machine that cannot exist, which every +/// reader of `host_os` then treats as foreign. pub fn is_host_os(value: &str) -> bool { matches!(value, "macos" | "windows" | "linux") } @@ -136,15 +136,39 @@ impl BrowserProfile { .or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref())) } - /// Returns true when the profile was created on a different OS than the current host. - /// Checks `host_os` first, then falls back to the browser config's `os` field. + /// Whether this profile's directory runs on every supported OS, whichever OS + /// wrote it. True for Wayfern; other browsers stay on the OS that created + /// their profile. + pub fn runs_on_any_os(&self) -> bool { + self.browser == "wayfern" + } + + /// Returns true when the profile cannot run here because a browser bound to + /// its OS created it on a different OS. A Wayfern profile is never cross-OS: + /// it syncs its data and launches on every OS. pub fn is_cross_os(&self) -> bool { + if self.runs_on_any_os() { + return false; + } match self.resolved_os() { Some(os) => os != get_host_os(), None => false, } } + /// The OS a fingerprint without an explicit `os` describes: the machine that + /// created the profile, or this machine when that is not recorded. Such a + /// fingerprint was generated for its creating machine, so it keeps that OS + /// when the profile moves to another one. + pub fn native_os(&self) -> String { + self + .host_os + .as_deref() + .filter(|os| is_host_os(os)) + .map(str::to_string) + .unwrap_or_else(get_host_os) + } + /// Returns true if sync is enabled (either Regular or Encrypted mode). pub fn is_sync_enabled(&self) -> bool { self.sync_mode != SyncMode::Disabled @@ -179,4 +203,60 @@ mod tests { assert!(!is_host_os(os), "{os} must not be stored as a host OS"); } } + + fn other_host_os() -> &'static str { + if get_host_os() == "windows" { + "macos" + } else { + "windows" + } + } + + fn profile_from(browser: &str, host_os: Option<&str>) -> BrowserProfile { + BrowserProfile { + browser: browser.to_string(), + host_os: host_os.map(str::to_string), + ..BrowserProfile::default() + } + } + + #[test] + fn a_wayfern_profile_from_another_os_is_not_cross_os() { + for os in ["macos", "windows", "linux"] { + let profile = profile_from("wayfern", Some(os)); + assert!(profile.runs_on_any_os()); + assert!( + !profile.is_cross_os(), + "a Wayfern profile created on {os} must run here" + ); + } + // A claimed OS with no recorded creating machine is not a reason either. + let mut claimed = profile_from("wayfern", None); + claimed.wayfern_config = Some(WayfernConfig { + os: Some(other_host_os().to_string()), + ..WayfernConfig::default() + }); + assert!(!claimed.is_cross_os()); + } + + #[test] + fn a_profile_of_another_browser_stays_on_its_own_os() { + let foreign = profile_from("camoufox", Some(other_host_os())); + assert!(!foreign.runs_on_any_os()); + assert!(foreign.is_cross_os()); + assert!(!profile_from("camoufox", Some(&get_host_os())).is_cross_os()); + assert!(!profile_from("camoufox", None).is_cross_os()); + } + + #[test] + fn native_os_is_the_creating_machine_not_this_one() { + let other = other_host_os(); + assert_eq!(profile_from("wayfern", Some(other)).native_os(), other); + assert_eq!(profile_from("wayfern", None).native_os(), get_host_os()); + // A fingerprint OS stored by mistake is not a machine. + assert_eq!( + profile_from("wayfern", Some("android")).native_os(), + get_host_os() + ); + } } diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index ac62ee80..98c55ea4 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -210,6 +210,76 @@ fn is_safe_manifest_path(path: &str) -> bool { .all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) } +/// Whether this machine syncs the profile's browser files, or only its +/// metadata. Every Wayfern profile syncs its files, whichever OS created it; +/// only a profile of a browser bound to another OS keeps to its metadata. +fn syncs_browser_files(profile: &BrowserProfile) -> bool { + !profile.is_cross_os() +} + +/// True when `dir` holds no file at any depth, or does not exist. +fn holds_no_files(dir: &Path) -> bool { + let Ok(entries) = fs::read_dir(dir) else { + return true; + }; + entries.flatten().all(|entry| { + entry + .file_type() + .is_ok_and(|kind| kind.is_dir() && holds_no_files(&entry.path())) + }) +} + +/// Whether a synced profile created on another OS is on this machine as +/// metadata alone, with no browser files yet. Earlier versions synced a +/// profile from another OS that way. +pub fn awaits_first_download(profile: &BrowserProfile, profiles_dir: &Path) -> bool { + profile.is_sync_enabled() + && syncs_browser_files(profile) + && profile + .resolved_os() + .is_some_and(|os| os != crate::profile::types::get_host_os()) + && holds_no_files(&profile.get_profile_data_path(profiles_dir)) +} + +/// Download the browser files of a profile that `awaits_first_download` +/// before it launches. Launched empty, the browser would write a fresh +/// profile, and the sync after it closes would publish that as the newer copy +/// and delete the real files everywhere, so a download that does not complete +/// refuses the launch. +pub async fn download_before_first_launch( + app_handle: &tauri::AppHandle, + profile: &BrowserProfile, +) -> Result<(), String> { + let profiles_dir = ProfileManager::instance().get_profiles_dir(); + if !awaits_first_download(profile, &profiles_dir) { + return Ok(()); + } + let refused = |detail: String| { + log::warn!( + "Launch refused, profile data not downloaded profile={} err=\"{detail}\"", + profile.id + ); + crate::backend_error_with_detail("PROFILE_DATA_NOT_DOWNLOADED", detail) + }; + let engine = SyncEngine::create_from_settings(app_handle) + .await + .map_err(|e| refused(e.to_string()))?; + match engine + .sync_profile_with_bias(app_handle, profile, DiffBias::Auto) + .await + { + Ok(ProfileSyncOutcome::Completed) => { + log::info!( + "Profile data downloaded before launch profile={}", + profile.id + ); + Ok(()) + } + Ok(ProfileSyncOutcome::Skipped(reason)) => Err(refused(reason.to_string())), + Err(e) => Err(refused(e.to_string())), + } +} + /// Parse an S3 `lastModified` (RFC3339) into unix seconds. fn rfc3339_secs(value: &str) -> Option { DateTime::parse_from_rfc3339(value) @@ -678,7 +748,7 @@ impl SyncEngine { .ok_or_else(|| SyncError::InvalidData(format!("Profile {profile_id} not found")))?; let profile = ¤t_profile; - if profile.is_cross_os() { + if !syncs_browser_files(profile) { log::debug!( "Profile sync: cross-OS, metadata only profile={}", profile.id @@ -1176,8 +1246,8 @@ impl SyncEngine { Ok(remote) } - /// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups). - /// No browser files are synced. + /// Sync only metadata for profiles bound to another OS (tags, notes, + /// proxies, groups). No browser files are synced. async fn sync_cross_os_metadata( &self, app_handle: &tauri::AppHandle, @@ -2737,8 +2807,9 @@ impl SyncEngine { let mut profile: BrowserProfile = serde_json::from_slice(&metadata_data) .map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?; - // Cross-OS profile: save metadata only, skip manifest + file downloads - if profile.is_cross_os() { + // A profile bound to another OS: save metadata only, skip manifest + file + // downloads. A Wayfern profile from any OS downloads in full below. + if !syncs_browser_files(&profile) { fs::create_dir_all(&profile_dir).map_err(|e| { SyncError::IoError(format!( "Failed to create profile directory {}: {e}", @@ -2837,18 +2908,8 @@ impl SyncEngine { // Verify critical files after download let os_crypt_key_path = profile_dir.join("profile").join("os_crypt_key"); - let cookies_path = { - let network = profile_dir - .join("profile") - .join("Default") - .join("Network") - .join("Cookies"); - if network.exists() { - network - } else { - profile_dir.join("profile").join("Default").join("Cookies") - } - }; + let cookies_path = + crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_dir.join("profile")); let has_os_crypt_key = os_crypt_key_path.exists(); let cookies_bytes = fs::metadata(&cookies_path).ok().map(|meta| meta.len()); if !has_os_crypt_key || cookies_bytes.is_none() { @@ -3081,7 +3142,7 @@ impl SyncEngine { .list_profiles() .unwrap_or_default() .iter() - .filter(|p| p.is_cross_os() && p.is_sync_enabled()) + .filter(|p| !syncs_browser_files(p) && p.is_sync_enabled()) .map(|p| (p.id.to_string(), p.sync_mode, p.created_by_id.clone())) .collect(); @@ -3530,7 +3591,7 @@ async fn apply_profile_sync_mode( .find(|p| p.id == profile_uuid) .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; - if profile.is_cross_os() { + if !syncs_browser_files(&profile) { return Err("Cannot modify sync settings for a cross-OS profile".to_string()); } @@ -4742,6 +4803,127 @@ mod tests { assert!(tombstone_outranks_local(true, None, 501)); } + fn other_host_os() -> &'static str { + if crate::profile::types::get_host_os() == "windows" { + "macos" + } else { + "windows" + } + } + + fn synced_profile(browser: &str, host_os: &str) -> BrowserProfile { + BrowserProfile { + id: uuid::Uuid::new_v4(), + browser: browser.to_string(), + host_os: Some(host_os.to_string()), + sync_mode: SyncMode::Regular, + ..BrowserProfile::default() + } + } + + #[test] + fn a_wayfern_profile_from_another_os_syncs_its_browser_files() { + for os in ["macos", "windows", "linux"] { + assert!( + syncs_browser_files(&synced_profile("wayfern", os)), + "a Wayfern profile created on {os} must sync its data here" + ); + } + } + + #[test] + fn a_profile_bound_to_another_os_syncs_metadata_only() { + assert!(!syncs_browser_files(&synced_profile( + "camoufox", + other_host_os() + ))); + assert!(syncs_browser_files(&synced_profile( + "camoufox", + &crate::profile::types::get_host_os() + ))); + } + + #[test] + fn a_profile_from_another_os_transfers_its_whole_directory() { + // A profile directory as Chromium on Windows leaves it: a raw key file, + // migrated network data, and the per-machine files sync never carries. + let source = tempfile::TempDir::new().unwrap(); + let uuid_dir = source.path(); + let data_dir = uuid_dir.join("profile"); + let network_dir = data_dir.join("Default").join("Network"); + fs::create_dir_all(&network_dir).unwrap(); + fs::create_dir_all(data_dir.join("Default").join("Cache")).unwrap(); + fs::write(data_dir.join("os_crypt_key"), [0x42u8; 32]).unwrap(); + fs::write(data_dir.join("Local State"), b"{}").unwrap(); + fs::write(data_dir.join("Default").join("Preferences"), b"{}").unwrap(); + fs::write(network_dir.join("Cookies"), b"cookies").unwrap(); + fs::write(network_dir.join("NetworkDataMigrated"), b"").unwrap(); + fs::write(data_dir.join("SingletonLock"), b"host-1234").unwrap(); + fs::write(data_dir.join("Default").join("LOCK"), b"").unwrap(); + fs::write(data_dir.join("Default").join("Cache").join("data_0"), b"").unwrap(); + fs::write(uuid_dir.join("metadata.json"), b"{}").unwrap(); + + let mut cache = HashCache::default(); + let remote = generate_manifest("p", uuid_dir, &mut cache).unwrap(); + let mut uploaded: Vec<&str> = remote.files.iter().map(|f| f.path.as_str()).collect(); + uploaded.sort_unstable(); + assert_eq!( + uploaded, + vec![ + "profile/Default/Network/Cookies", + "profile/Default/Network/NetworkDataMigrated", + "profile/Default/Preferences", + "profile/Local State", + "profile/os_crypt_key", + ], + "every data file syncs and only the per-machine files stay behind" + ); + + // The machine on the other OS holds metadata alone; it pulls everything. + let target = tempfile::TempDir::new().unwrap(); + fs::write(target.path().join("metadata.json"), b"{}").unwrap(); + let mut target_cache = HashCache::default(); + let local = generate_manifest("p", target.path(), &mut target_cache).unwrap(); + let diff = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto); + assert_eq!(diff.files_to_download.len(), remote.files.len()); + assert!(diff.files_to_upload.is_empty()); + assert!(diff.files_to_delete_remote.is_empty()); + } + + #[test] + fn a_metadata_only_copy_from_another_os_awaits_its_first_download() { + let profiles_dir = tempfile::TempDir::new().unwrap(); + let profile = synced_profile("wayfern", other_host_os()); + let uuid_dir = profiles_dir.path().join(profile.id.to_string()); + fs::create_dir_all(&uuid_dir).unwrap(); + fs::write(uuid_dir.join("metadata.json"), b"{}").unwrap(); + assert!(awaits_first_download(&profile, profiles_dir.path())); + + // An empty directory tree is still nothing to launch on. + let data_dir = profile.get_profile_data_path(profiles_dir.path()); + fs::create_dir_all(data_dir.join("Default")).unwrap(); + assert!(awaits_first_download(&profile, profiles_dir.path())); + + // Once any file is there, the launch goes ahead as for any profile. + fs::write(data_dir.join("Local State"), b"{}").unwrap(); + assert!(!awaits_first_download(&profile, profiles_dir.path())); + } + + #[test] + fn only_a_synced_profile_from_another_os_waits_for_a_download() { + let profiles_dir = tempfile::TempDir::new().unwrap(); + // A new profile created here launches on an empty directory. + let local = synced_profile("wayfern", &crate::profile::types::get_host_os()); + assert!(!awaits_first_download(&local, profiles_dir.path())); + // Without sync there is nothing to download. + let mut unsynced = synced_profile("wayfern", other_host_os()); + unsynced.sync_mode = SyncMode::Disabled; + assert!(!awaits_first_download(&unsynced, profiles_dir.path())); + // A profile bound to another OS never launches here at all. + let bound = synced_profile("camoufox", other_host_os()); + assert!(!awaits_first_download(&bound, profiles_dir.path())); + } + #[test] fn test_rfc3339_secs() { assert_eq!(rfc3339_secs("1970-01-01T00:00:00Z"), Some(0)); diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 329d3c59..42f9e023 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -13,9 +13,10 @@ pub use encryption::{ check_has_e2e_password, delete_e2e_password, set_e2e_password, verify_e2e_password, }; pub use engine::{ - cancel_profile_sync, enable_extension_group_sync_if_needed, enable_group_sync_if_needed, - enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed, - get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, + awaits_first_download, cancel_profile_sync, download_before_first_launch, + enable_extension_group_sync_if_needed, enable_group_sync_if_needed, enable_proxy_sync_if_needed, + enable_sync_for_all_entities, enable_vpn_sync_if_needed, get_unsynced_entity_counts, + is_group_in_use_by_synced_profile, is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, pull_profile_after_remote_session, request_profile_sync, resume_profile_sync, diff --git a/src-tauri/src/wayfern_identity_storage.rs b/src-tauri/src/wayfern_identity_storage.rs index 1dc39cbf..8aac8f4c 100644 --- a/src-tauri/src/wayfern_identity_storage.rs +++ b/src-tauri/src/wayfern_identity_storage.rs @@ -99,7 +99,6 @@ pub fn stored_shape(config: &WayfernConfig) -> StoredShape { pub fn needs_conversion(profile: &BrowserProfile) -> bool { profile.browser == "wayfern" && supports_wayfern_152(&profile.version) - && !profile.is_cross_os() && profile.wayfern_config.as_ref().is_some_and(|config| { config.randomize_fingerprint_on_launch != Some(true) && matches!( @@ -698,6 +697,7 @@ mod tests { camoufox.browser = "camoufox".to_string(); assert!(!needs_conversion(&camoufox)); + // A profile created on another OS launches here, so it converts here too. let mut foreign = profile_with("152.0.7977.64", payload_only); foreign.host_os = Some( if get_host_os() == "windows" { @@ -707,7 +707,7 @@ mod tests { } .to_string(), ); - assert!(!needs_conversion(&foreign)); + assert!(needs_conversion(&foreign)); } #[test] diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 657b218f..5d6b5bc5 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -1718,7 +1718,13 @@ impl WayfernManager { /// operating system, or no timezone. The browser requires the timezone /// because it does not resolve the exit itself; donut holds the proxy and /// resolved it when the location was written. - pub fn launch_identity_document(config: &WayfernConfig) -> Option { + /// + /// `native_os` is `BrowserProfile::native_os`: the OS a profile without an + /// explicit claim was generated for, which stays its claim on every machine. + pub fn launch_identity_document( + config: &WayfernConfig, + native_os: &str, + ) -> Option { if config.fingerprint.is_some() { return None; } @@ -1727,11 +1733,10 @@ impl WayfernManager { .as_deref() .map(str::trim) .filter(|id| !id.is_empty())?; - // The browser requires the operating system in the document. Over CDP an - // omitted `operatingSystem` means the host, so the document says so - // explicitly, and the profile's own claim wins when it has one. - let host_os = crate::profile::types::get_host_os(); - let os = Self::claimed_operating_system(config, None).unwrap_or(host_os.as_str()); + // The browser requires the operating system in the document. A profile + // with no explicit claim was generated for the machine that created it, + // so the document names that OS rather than the machine launching it. + let os = Self::claimed_operating_system(config, None).unwrap_or(native_os); let location = Self::stored_object(config.location.as_deref()); let geo = Self::geo_params(&location); let timezone = geo @@ -2390,8 +2395,10 @@ impl WayfernManager { .await?; let ws_url = session.page_ws_url().to_string(); - let host_os = crate::profile::types::get_host_os(); - let os = config.os.as_deref().unwrap_or(&host_os); + // A profile without an explicit claim keeps the OS of the machine that + // created it, wherever it is regenerated. + let native_os = profile.native_os(); + let os = config.os.as_deref().unwrap_or(&native_os); // Include wayfern token if available (enables cross-OS fingerprinting for paid users) let mut generate_params = json!({ "operatingSystem": os }); @@ -2606,17 +2613,8 @@ impl WayfernManager { { let profile_path_buf = std::path::PathBuf::from(profile_path); let key_path = profile_path_buf.join("os_crypt_key"); - let cookies_path = { - let network = profile_path_buf - .join("Default") - .join("Network") - .join("Cookies"); - if network.exists() { - network - } else { - profile_path_buf.join("Default").join("Cookies") - } - }; + let cookies_path = + crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_path_buf); // Length only. The contents are the profile's encryption key, and this // log is the first thing a user attaches to a bug report. @@ -2880,7 +2878,7 @@ impl WayfernManager { // device payloads and a profile whose location carries no timezone keep // the post-launch CDP apply below. let launch_identity = if supports_wayfern_152(&profile.version) { - Self::launch_identity_document(config) + Self::launch_identity_document(config, &profile.native_os()) } else { None }; @@ -4405,7 +4403,7 @@ mod tests { #[test] fn the_launch_identity_document_carries_exactly_what_the_browser_reads() { let document = - WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin"))) + WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin")), "macos") .expect("a complete identity profile yields a document"); assert_eq!( document, @@ -4425,22 +4423,40 @@ mod tests { fn the_launch_identity_document_needs_a_timezone_and_an_identity() { // The browser refuses a document without a timezone, and does not resolve // one itself, so no document is written at all. - assert!(WayfernManager::launch_identity_document(&identity_config(None)).is_none()); - // No claim means the host, which is what an omitted `operatingSystem` - // means over CDP as well. - let mut no_os = identity_config(Some("Europe/Berlin")); - no_os.os = None; - assert_eq!( - WayfernManager::launch_identity_document(&no_os).unwrap()["operatingSystem"], - json!(crate::profile::types::get_host_os()) - ); + assert!(WayfernManager::launch_identity_document(&identity_config(None), "windows").is_none()); let mut no_identity = identity_config(Some("Europe/Berlin")); no_identity.identity_id = Some(" ".into()); - assert!(WayfernManager::launch_identity_document(&no_identity).is_none()); + assert!(WayfernManager::launch_identity_document(&no_identity, "windows").is_none()); // A legacy device payload is only reproducible through setFingerprint. let mut legacy = identity_config(Some("Europe/Berlin")); legacy.fingerprint = Some("{}".into()); - assert!(WayfernManager::launch_identity_document(&legacy).is_none()); + assert!(WayfernManager::launch_identity_document(&legacy, "windows").is_none()); + } + + #[test] + fn an_unclaimed_identity_keeps_the_os_of_the_machine_that_created_it() { + // No explicit claim means the fingerprint was generated for the creating + // machine, so a profile synced to another OS still names that OS. + let mut no_os = identity_config(Some("Europe/Berlin")); + no_os.os = None; + for created_on in ["macos", "windows", "linux"] { + let profile = BrowserProfile { + browser: "wayfern".into(), + host_os: Some(created_on.into()), + ..Default::default() + }; + assert_eq!( + WayfernManager::launch_identity_document(&no_os, &profile.native_os()).unwrap() + ["operatingSystem"], + json!(created_on) + ); + } + // An explicit claim wins over the creating machine. + assert_eq!( + WayfernManager::launch_identity_document(&identity_config(Some("Europe/Berlin")), "linux") + .unwrap()["operatingSystem"], + json!("windows") + ); } #[test] @@ -4448,7 +4464,7 @@ mod tests { let mut config = identity_config(Some("Asia/Tokyo")); config.location = Some(r#"{"timezone":"Asia/Tokyo","latitude":35.6}"#.into()); config.identity_overrides = None; - let document = WayfernManager::launch_identity_document(&config).unwrap(); + let document = WayfernManager::launch_identity_document(&config, "macos").unwrap(); assert_eq!( document, json!({ diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 5fce4ca3..5af3836c 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -86,7 +86,7 @@ import { LaunchArgumentsEditor } from "@/components/wayfern-config-form"; import { WindowDragArea } from "@/components/window-drag-area"; import { useInputModality } from "@/hooks/use-input-modality"; import { translateBackendError } from "@/lib/backend-errors"; -import { getProfileIcon } from "@/lib/browser-utils"; +import { getProfileIcon, getProfileNativeOs } from "@/lib/browser-utils"; import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels"; import { formatRelativeTime } from "@/lib/flag-utils"; import { MOTION_EASE_OUT } from "@/lib/motion"; @@ -2297,6 +2297,7 @@ function FingerprintSectionInline({ profileVersion={profile.version} profileBrowser={profile.browser} profileId={profile.id} + nativeOS={getProfileNativeOs(profile)} /> ) : ( <> diff --git a/src/components/wayfern-config-dialog.tsx b/src/components/wayfern-config-dialog.tsx index 73bb403a..8f11fb19 100644 --- a/src/components/wayfern-config-dialog.tsx +++ b/src/components/wayfern-config-dialog.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { translateBackendError } from "@/lib/backend-errors"; +import { getProfileNativeOs } from "@/lib/browser-utils"; import type { BrowserProfile, WayfernConfig, WayfernOS } from "@/types"; import { LoadingButton } from "./loading-button"; import { RippleButton } from "./ui/ripple"; @@ -51,10 +52,8 @@ export function WayfernConfigDialog({ useEffect(() => { if (profile?.browser === "wayfern") { setConfig( - profile.wayfern_config || { - geoip: true, - os: getCurrentOS(), - }, + // No `os` means the profile's native OS, which the form shows. + profile.wayfern_config || { geoip: true }, ); } }, [profile]); @@ -96,12 +95,7 @@ export function WayfernConfigDialog({ const handleClose = () => { if (profile?.browser === "wayfern") { - setConfig( - profile.wayfern_config || { - geoip: true, - os: getCurrentOS(), - }, - ); + setConfig(profile.wayfern_config || { geoip: true }); } onClose(); }; @@ -139,6 +133,7 @@ export function WayfernConfigDialog({ profileVersion={profile.version} profileBrowser="wayfern" profileId={profile.id} + nativeOS={getProfileNativeOs(profile)} /> diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index e0638322..95e79e11 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -183,6 +183,10 @@ interface WayfernConfigFormProps { /// The saved profile whose persona is edited here. Absent while a profile is /// being created: there is no seed yet, so there is no person to show. profileId?: string; + /// The OS a fingerprint without an explicit `os` describes: the machine that + /// created the saved profile. It stays the profile's claim on every + /// computer, so it is shown and regenerated instead of this computer's OS. + nativeOS?: WayfernOS; } export function LaunchArgumentsEditor({ @@ -281,6 +285,7 @@ export function WayfernConfigForm({ profileVersion, profileBrowser, profileId, + nativeOS, }: WayfernConfigFormProps) { const { t } = useTranslation(); const [activeTab, setActiveTab] = useState( @@ -296,7 +301,10 @@ export function WayfernConfigForm({ if (!profileVersion) return; setIsGeneratingFingerprint(true); try { - const configJson = JSON.stringify(config); + const configJson = JSON.stringify({ + ...config, + os: config.os ?? nativeOS, + }); const result = await invoke( "generate_sample_fingerprint", { @@ -341,7 +349,7 @@ export function WayfernConfigForm({ void handleGenerateFingerprint(); }; - const selectedOS = config.os || currentOS; + const selectedOS = config.os || nativeOS || currentOS; useEffect(() => { if (isCreating && typeof window !== "undefined") { diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 11c51e76..c4994712 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "Dieses Profil gibt sich als Touch-Gerät aus, das der Bot nicht steuern kann. Verwenden Sie einen Desktop-Fingerprint.", "profileRunningRemotely": "Dieses Profil läuft auf einem entfernten Rechner. Stoppen Sie zuerst die Remote-Sitzung.", "profileRemoteSyncPending": "Eine Remote-Sitzung wurde gerade beendet. Ihre Änderungen werden heruntergeladen, bevor dieses Profil hier geöffnet werden kann.", + "profileDataNotDownloaded": "Die Dateien dieses Profils wurden noch nicht auf diesen Computer heruntergeladen. Prüfen Sie, ob die Synchronisierung verbunden ist, und versuchen Sie es dann erneut.", "profileLockedByMember": "Dieses Profil wird von {{email}} verwendet.", "profileLockedElsewhere": "Dieses Profil wird auf einem anderen Gerät verwendet.", "profileLockUnavailable": "Es konnte nicht geprüft werden, ob dieses Profil an anderer Stelle verwendet wird. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut.", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 27e413a0..0c648c78 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "This profile claims a touch device, which the bot cannot drive. Use a desktop fingerprint.", "profileRunningRemotely": "This profile is running on a remote machine. Stop the remote session first.", "profileRemoteSyncPending": "A remote session just finished. Waiting for its changes to download before this profile can open here.", + "profileDataNotDownloaded": "This profile's files have not downloaded to this computer yet. Check that sync is connected, then try again.", "profileLockedByMember": "This profile is in use by {{email}}.", "profileLockedElsewhere": "This profile is in use on another device.", "profileLockUnavailable": "Could not check whether this profile is in use elsewhere. Check your connection and try again.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 66fd138f..bf555a21 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2317,6 +2317,7 @@ "cookieBotTouchFingerprintUnsupported": "Este perfil declara un dispositivo táctil, que el bot no puede controlar. Usa una huella de escritorio.", "profileRunningRemotely": "Este perfil se está ejecutando en una máquina remota. Detén primero la sesión remota.", "profileRemoteSyncPending": "Una sesión remota acaba de terminar. Esperando a que se descarguen sus cambios antes de abrir este perfil aquí.", + "profileDataNotDownloaded": "Los archivos de este perfil aún no se han descargado en este equipo. Comprueba que la sincronización esté conectada e inténtalo de nuevo.", "profileLockedByMember": "Este perfil está siendo usado por {{email}}.", "profileLockedElsewhere": "Este perfil está en uso en otro dispositivo.", "profileLockUnavailable": "No se pudo comprobar si este perfil está en uso en otro lugar. Revisa tu conexión e inténtalo de nuevo.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 425b71c2..d7403e86 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -2317,6 +2317,7 @@ "cookieBotTouchFingerprintUnsupported": "Ce profil déclare un appareil tactile, que le bot ne peut pas piloter. Utilisez une empreinte de bureau.", "profileRunningRemotely": "Ce profil s'exécute sur une machine distante. Arrêtez d'abord la session distante.", "profileRemoteSyncPending": "Une session distante vient de se terminer. Ses modifications doivent être téléchargées avant d'ouvrir ce profil ici.", + "profileDataNotDownloaded": "Les fichiers de ce profil n'ont pas encore été téléchargés sur cet ordinateur. Vérifiez que la synchronisation est connectée, puis réessayez.", "profileLockedByMember": "Ce profil est utilisé par {{email}}.", "profileLockedElsewhere": "Ce profil est utilisé sur un autre appareil.", "profileLockUnavailable": "Impossible de vérifier si ce profil est utilisé ailleurs. Vérifiez votre connexion et réessayez.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7796b21c..31e8d34b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "このプロファイルはタッチ端末を名乗っており、ボットは操作できません。デスクトップのフィンガープリントをお使いください。", "profileRunningRemotely": "このプロファイルはリモートマシンで実行中です。先にリモートセッションを停止してください。", "profileRemoteSyncPending": "リモートセッションが終了しました。この profile をここで開く前に、変更のダウンロードを待っています。", + "profileDataNotDownloaded": "このプロファイルのファイルはまだこのコンピューターにダウンロードされていません。同期が接続されていることを確認してから、もう一度お試しください。", "profileLockedByMember": "このプロファイルは {{email}} が使用中です。", "profileLockedElsewhere": "このプロファイルは別のデバイスで使用中です。", "profileLockUnavailable": "このプロファイルが他で使用中か確認できませんでした。接続を確認して再試行してください。", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 4f6879df..ac60ff32 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "이 프로필은 터치 기기를 표방하며, 봇이 조작할 수 없습니다. 데스크톱 지문을 사용하세요.", "profileRunningRemotely": "이 프로필은 원격 머신에서 실행 중입니다. 먼저 원격 세션을 중지하세요.", "profileRemoteSyncPending": "원격 세션이 방금 끝났습니다. 이 프로필을 여기서 열기 전에 변경 사항을 내려받는 중입니다.", + "profileDataNotDownloaded": "이 프로필의 파일이 아직 이 컴퓨터로 내려받아지지 않았습니다. 동기화가 연결되어 있는지 확인한 후 다시 시도하세요.", "profileLockedByMember": "이 프로필은 {{email}} 님이 사용 중입니다.", "profileLockedElsewhere": "이 프로필은 다른 기기에서 사용 중입니다.", "profileLockUnavailable": "이 프로필이 다른 곳에서 사용 중인지 확인할 수 없습니다. 연결을 확인한 뒤 다시 시도하세요.", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index bb4e2297..052742e7 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -2317,6 +2317,7 @@ "cookieBotTouchFingerprintUnsupported": "Este perfil declara um dispositivo de toque, que o bot não consegue controlar. Use uma impressão digital de computador.", "profileRunningRemotely": "Este perfil está em execução numa máquina remota. Pare primeiro a sessão remota.", "profileRemoteSyncPending": "Uma sessão remota acabou de terminar. A aguardar a transferência das alterações antes de abrir este perfil aqui.", + "profileDataNotDownloaded": "Os ficheiros deste perfil ainda não foram transferidos para este computador. Verifique se a sincronização está ligada e tente novamente.", "profileLockedByMember": "Este perfil está a ser utilizado por {{email}}.", "profileLockedElsewhere": "Este perfil está a ser utilizado noutro dispositivo.", "profileLockUnavailable": "Não foi possível verificar se este perfil está a ser utilizado noutro local. Verifique a ligação e tente novamente.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b0a17703..91499c09 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2327,6 +2327,7 @@ "cookieBotTouchFingerprintUnsupported": "Этот профиль выдаёт себя за сенсорное устройство, которым бот управлять не может. Используйте настольный отпечаток.", "profileRunningRemotely": "Этот профиль запущен на удалённой машине. Сначала остановите удалённый сеанс.", "profileRemoteSyncPending": "Удалённый сеанс только что завершился. Дождитесь загрузки его изменений, прежде чем открывать профиль здесь.", + "profileDataNotDownloaded": "Файлы этого профиля ещё не загружены на этот компьютер. Проверьте подключение синхронизации и попробуйте снова.", "profileLockedByMember": "Этот профиль используется пользователем {{email}}.", "profileLockedElsewhere": "Этот профиль используется на другом устройстве.", "profileLockUnavailable": "Не удалось проверить, используется ли профиль где-то ещё. Проверьте подключение и попробуйте снова.", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index fe03d1fe..d0d07fef 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "Bu profil dokunmatik bir cihaz olduğunu bildiriyor ve bot bunu süremez. Masaüstü parmak izi kullanın.", "profileRunningRemotely": "Bu profil uzak bir makinede çalışıyor. Önce uzak oturumu durdurun.", "profileRemoteSyncPending": "Uzak oturum az önce bitti. Bu profili burada açmadan önce değişikliklerinin inmesi bekleniyor.", + "profileDataNotDownloaded": "Bu profilin dosyaları henüz bu bilgisayara inmedi. Eşitlemenin bağlı olduğunu kontrol edip tekrar deneyin.", "profileLockedByMember": "Bu profil {{email}} tarafından kullanılıyor.", "profileLockedElsewhere": "Bu profil başka bir cihazda kullanılıyor.", "profileLockUnavailable": "Bu profilin başka bir yerde kullanılıp kullanılmadığı denetlenemedi. Bağlantınızı kontrol edip yeniden deneyin.", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index aa0d1f7a..6f9d059c 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "Hồ sơ này khai báo là thiết bị cảm ứng, bot không điều khiển được. Hãy dùng vân tay máy tính để bàn.", "profileRunningRemotely": "Hồ sơ này đang chạy trên máy từ xa. Hãy dừng phiên từ xa trước.", "profileRemoteSyncPending": "Một phiên từ xa vừa kết thúc. Đang chờ tải các thay đổi về trước khi mở hồ sơ này tại đây.", + "profileDataNotDownloaded": "Các tệp của hồ sơ này chưa được tải về máy tính này. Hãy kiểm tra kết nối đồng bộ rồi thử lại.", "profileLockedByMember": "Hồ sơ này đang được {{email}} sử dụng.", "profileLockedElsewhere": "Hồ sơ này đang được sử dụng trên thiết bị khác.", "profileLockUnavailable": "Không thể kiểm tra hồ sơ này có đang được dùng ở nơi khác hay không. Hãy kiểm tra kết nối và thử lại.", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 9eda43f9..b1ae4c05 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2307,6 +2307,7 @@ "cookieBotTouchFingerprintUnsupported": "该配置文件声称是触摸设备,机器人无法操作。请使用桌面端指纹。", "profileRunningRemotely": "该配置文件正在远程计算机上运行。请先停止远程会话。", "profileRemoteSyncPending": "远程会话刚刚结束。正在等待其更改下载完成后才能在此打开该配置文件。", + "profileDataNotDownloaded": "此配置文件的文件尚未下载到这台电脑。请确认同步已连接,然后重试。", "profileLockedByMember": "该配置文件正在被 {{email}} 使用。", "profileLockedElsewhere": "该配置文件正在另一台设备上使用。", "profileLockUnavailable": "无法检查该配置文件是否正在别处使用。请检查网络连接后重试。", diff --git a/src/lib/backend-errors.ts b/src/lib/backend-errors.ts index faaaf26b..b35726b1 100644 --- a/src/lib/backend-errors.ts +++ b/src/lib/backend-errors.ts @@ -155,6 +155,7 @@ export type BackendErrorCode = | "REMOTE_HOURS_EXHAUSTED" | "PROFILE_RUNNING_REMOTELY" | "PROFILE_REMOTE_SYNC_PENDING" + | "PROFILE_DATA_NOT_DOWNLOADED" | "PROFILE_LOCKED_BY_MEMBER" | "PROFILE_LOCKED_ELSEWHERE" | "PROFILE_LOCK_UNAVAILABLE" @@ -670,6 +671,8 @@ export function translateBackendError(t: TFunction, err: unknown): string { return t("backendErrors.profileRunningRemotely"); case "PROFILE_REMOTE_SYNC_PENDING": return t("backendErrors.profileRemoteSyncPending"); + case "PROFILE_DATA_NOT_DOWNLOADED": + return t("backendErrors.profileDataNotDownloaded"); case "PROFILE_LOCKED_BY_MEMBER": return t("backendErrors.profileLockedByMember", { email: parsed.params?.email ?? "", diff --git a/src/lib/browser-utils.ts b/src/lib/browser-utils.ts index 02a25baa..75b9da7b 100644 --- a/src/lib/browser-utils.ts +++ b/src/lib/browser-utils.ts @@ -90,10 +90,29 @@ export function getProfileIcon(profile: { return getBrowserIcon(profile.browser); } +/** + * The OS a fingerprint without an explicit `os` was generated for: the machine + * that created the profile. Mirrors `BrowserProfile::native_os`, without its + * fallback to this computer, so callers can tell "unknown" apart. + */ +export function getProfileNativeOs(profile: { + host_os?: string; +}): "macos" | "windows" | "linux" | undefined { + const os = profile.host_os; + return os === "macos" || os === "windows" || os === "linux" ? os : undefined; +} + +/** + * Whether a profile cannot run on this computer because a browser bound to + * its OS created it elsewhere. Mirrors `BrowserProfile::is_cross_os`: a + * Wayfern profile runs on every OS, so it is never cross-OS. + */ export function isCrossOsProfile(profile: { + browser: string; host_os?: string; wayfern_config?: { os?: string }; }): boolean { + if (profile.browser === "wayfern") return false; const profileOs = profile.host_os || profile.wayfern_config?.os; if (!profileOs) return false; return profileOs !== getCurrentOS();