diff --git a/e2e/app/Cargo.lock b/e2e/app/Cargo.lock index 6c631b62..21a92b5f 100644 --- a/e2e/app/Cargo.lock +++ b/e2e/app/Cargo.lock @@ -1720,7 +1720,7 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.31.1" +version = "0.31.2" dependencies = [ "aes", "aes-gcm", @@ -5029,9 +5029,9 @@ dependencies = [ [[package]] name = "process-wrap" -version = "9.1.0" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +checksum = "f95f6259ebd0640685b7cd26d635861f39208df0f89b2b61ff5cf76310fa54f6" dependencies = [ "futures", "indexmap 2.14.2", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index 7f3c247e..3879d44e 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -1521,3 +1521,106 @@ test("an interactive launch continues the last session once the identity travels await app.close(); } }); + +test("a payload profile an older version stored launches and is stored as an identity", async () => { + assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required"); + const localWayfernVersion = cachedFixtureVersion( + process.env.DONUT_E2E_PROJECT_ROOT, + ); + const app = appFromEnvironment("browser-legacy-payload", { + seedVersionCache: localWayfernVersion ?? false, + wayfernTermsAccepted: false, + }); + let browserPid; + try { + const prepared = await prepareWayfern( + app, + process.env.DONUT_E2E_PROJECT_ROOT, + ); + if (!app.session) await app.start(); + await app.invoke("accept_wayfern_terms"); + const major = Number.parseInt(prepared.version.split(".")[0], 10); + if (major < 152) { + console.log( + `[donut-e2e] Wayfern ${prepared.version} converts no stored payload; skipping the legacy launch`, + ); + return; + } + + const profile = await createRealProfile( + app, + prepared.version, + "Stored By 0.30", + ); + + // The file as v0.30 wrote it: the whole device payload, no identity, and + // none of the fields added since. Its raw JSON never equals the profile it + // parses to, and comparing the two raw once refused every launch of such + // a profile as WAYFERN_BROWSER_BUSY. + const metadataFile = path.join( + app.dataRoot, + "data", + "profiles", + profile.id, + "metadata.json", + ); + const stored = JSON.parse(await readFile(metadataFile, "utf8")); + // Set, so listing the profiles never rewrites the file in the new shape. + assert.equal(typeof stored.host_os, "string"); + delete stored.temporary; + for (const key of [ + "webrtc_mode", + "persona", + "camera_file", + "camera_crop", + "restore_session", + "identity_overrides", + "location", + ]) { + delete stored.wayfern_config[key]; + } + stored.wayfern_config = JSON.parse( + await readFile(process.env.DONUT_E2E_LEGACY_CONFIG, "utf8"), + ); + await writeFile(metadataFile, JSON.stringify(stored, null, 2)); + + const legacy = (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ); + assert.equal(legacy.wayfern_config.identity_id, null); + const launched = await app.invoke("launch_browser_profile", { + profile: legacy, + }); + assert.ok(launched.process_id); + browserPid = launched.process_id; + + const converted = (await app.invoke("list_browser_profiles")).find( + (p) => p.id === profile.id, + ); + assert.equal( + typeof converted.wayfern_config.identity_id, + "string", + "the launch must store the payload profile as an identity", + ); + assert.equal( + converted.wayfern_config.fingerprint, + undefined, + "a converted profile must store no device payload", + ); + await app.invoke("kill_browser_profile", { profile: converted }); + await waitForProcessExit(app, browserPid); + await app.invoke("delete_profile", { profileId: profile.id }); + } catch (error) { + await app.capture("failure"); + throw error; + } finally { + if (app.session && browserPid && processExists(browserPid)) { + const profile = ( + await app.invoke("list_browser_profiles").catch(() => []) + ).find((item) => item.process_id === browserPid); + if (profile) + await app.invoke("kill_browser_profile", { profile }).catch(() => {}); + } + await app.close(); + } +}); diff --git a/package.json b/package.json index 23baeb46..1a882f42 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "donutbrowser", "private": true, "license": "AGPL-3.0", - "version": "0.31.1", + "version": "0.31.2", "type": "module", "scripts": { "predev": "pnpm licenses:generate", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2065abbc..913043be 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "donutbrowser" -version = "0.31.1" +version = "0.31.2" dependencies = [ "aes", "aes-gcm", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b3f36676..0b55168c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "donutbrowser" -version = "0.31.1" +version = "0.31.2" description = "Simple Yet Powerful Anti-Detect Browser" authors = ["zhom@github"] edition = "2021" diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index bd20a18c..49231456 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -31,6 +31,15 @@ fn atomic_write_locked(path: &Path, data: &[u8]) -> std::io::Result<()> { fs::rename(&tmp, path) } +/// Whether a metadata.json still holds `expected`, compared as this version +/// reads it. A file an older version wrote lacks every field added since, so +/// its raw JSON never equals the profile it parses to, and a raw compare +/// reports a change on every call. +fn holds_profile(content: &str, expected: &BrowserProfile) -> Result { + let stored: BrowserProfile = serde_json::from_str(content)?; + Ok(serde_json::to_value(&stored)? == serde_json::to_value(expected)?) +} + /// Collapse an empty proxy/VPN id to `None`. /// /// REST and MCP clients send `""` to detach a proxy or VPN, since omitting the @@ -484,12 +493,9 @@ impl ProfileManager { .join(profile.id.to_string()) .join("metadata.json"); let json = serde_json::to_string_pretty(profile)?; - let expected_json = serde_json::to_string_pretty(expected)?; let _serialized = METADATA_WRITES.lock().unwrap_or_else(|e| e.into_inner()); let current = fs::read_to_string(&profile_file)?; - if serde_json::from_str::(¤t)? - != serde_json::from_str::(&expected_json)? - { + if !holds_profile(¤t, expected)? { return Ok(false); } atomic_write_locked(&profile_file, json.as_bytes())?; @@ -2087,6 +2093,24 @@ mod tests { assert!(merged.identity_baseline.is_none()); } + #[test] + fn a_file_an_older_version_wrote_holds_the_profile_it_reads_as() { + // No `temporary` and none of the newer Wayfern fields, as v0.30 wrote it. + let legacy = r#"{ + "id": "7b0f7c9e-3f55-4c1b-9d7e-0f2f1f0c5a11", + "name": "Old", + "browser": "wayfern", + "version": "152.0.7977.64", + "wayfern_config": { "fingerprint": "{}", "os": "macos", "identity_id": null } + }"#; + let expected: BrowserProfile = serde_json::from_str(legacy).unwrap(); + assert!(holds_profile(legacy, &expected).unwrap()); + + let mut changed = expected.clone(); + changed.wayfern_config.as_mut().unwrap().identity_id = Some("id-1".to_string()); + assert!(!holds_profile(legacy, &changed).unwrap()); + } + fn create_test_profile_manager() -> (&'static ProfileManager, TempDir) { let temp_dir = TempDir::new().unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4227fc87..86517ce6 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Donut", - "version": "0.31.1", + "version": "0.31.2", "identifier": "com.donutbrowser", "build": { "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",