mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
feat: cross-OS profile sync
This commit is contained in:
1 parent
b7a89ac94e
commit
c359049e09
28 files changed
+532
-122
No files matched your search
+40
-20
@@ -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<String>,
|
||||
/// 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
|
||||
|
||||
@@ -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<String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@ pub fn purge_expired(trash_root: &Path, now: u64, retention_days: u32) -> Vec<St
|
||||
|
||||
/// A profile whose browser process is alive on this machine cannot be
|
||||
/// trashed: its data directory is in use. A stale process id (the browser
|
||||
/// crashed) does not count, and a cross-OS profile can never be running here.
|
||||
/// crashed) does not count, and a profile bound to another OS can never be
|
||||
/// running here.
|
||||
pub fn is_running_locally(profile: &BrowserProfile) -> 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;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+201
-19
@@ -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<u64> {
|
||||
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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<serde_json::Value> {
|
||||
///
|
||||
/// `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<serde_json::Value> {
|
||||
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!({
|
||||
|
||||
Reference in new issue
Block a user