From e3d4959ebef05803101e2344a3f0e1f71f6c6ab1 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:20:42 +0400 Subject: [PATCH] fix: read identity devices --- e2e/coverage-map.mjs | 1 + e2e/tests/browser.test.mjs | 27 ++ sdk/api-paths.json | 7 +- sdk/node/src/client.mts | 16 + sdk/node/src/coverage.mts | 1 + sdk/node/src/types.mts | 9 + sdk/node/test/requests.test.mts | 8 + sdk/python/src/donutbrowser/client.py | 9 + sdk/python/src/donutbrowser/coverage.py | 1 + sdk/python/src/donutbrowser/models.py | 8 + sdk/python/tests/test_requests.py | 10 + src-tauri/src/api_server.rs | 70 ++++ .../api_server_fingerprint_browser_tests.rs | 322 ++++++++++++++++++ src-tauri/src/lib.rs | 16 + src-tauri/src/mcp_server.rs | 12 +- src-tauri/src/wayfern_manager.rs | 172 ++++++++++ src/components/wayfern-config-form.tsx | 102 +++++- 17 files changed, 770 insertions(+), 21 deletions(-) create mode 100644 src-tauri/src/api_server_fingerprint_browser_tests.rs diff --git a/e2e/coverage-map.mjs b/e2e/coverage-map.mjs index b270fa93..f757b53a 100644 --- a/e2e/coverage-map.mjs +++ b/e2e/coverage-map.mjs @@ -228,6 +228,7 @@ export const commandCoverage = { "ensure_active_browsers_downloaded", "update_wayfern_config", "generate_sample_fingerprint", + "get_profile_fingerprint", "is_geoip_database_available", "download_geoip_database", "fingerprint_consistency::match_profile_fingerprint_to_exit", diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index b33ca125..6fda6f15 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -750,6 +750,33 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc clientInfo: { name: "Donut E2E", version: "1" }, }); + // The device the profile presents, through each way it is read: the + // fingerprint form, REST and MCP. An identity-backed profile stores no + // device, so every read derives it, and it is what the running page reports. + const shown = JSON.parse( + await app.invoke("get_profile_fingerprint", { profileId: profile.id }), + ); + assert.ok(Object.keys(shown).length >= 10, JSON.stringify(shown)); + assert.equal(shown.platform, await cdp.evaluate("navigator.platform")); + assert.equal( + shown.hardwareConcurrency, + await cdp.evaluate("navigator.hardwareConcurrency"), + ); + const restFingerprint = await request( + `${base}/v1/profiles/${profile.id}/fingerprint`, + { token: saved.api_token }, + ); + assert.equal( + restFingerprint.response.status, + 200, + JSON.stringify(restFingerprint.value), + ); + assert.deepEqual(JSON.parse(restFingerprint.value.fingerprint), shown); + assert.deepEqual( + JSON.parse((await toolJson("get_profile_fingerprint")).fingerprint), + shown, + ); + const back = await tool("go_back"); assert.match(back.text, /^Went back/); assert.match(back.text, /Page: Donut E2E Browser Fixture <.*\/wayfern>/); diff --git a/sdk/api-paths.json b/sdk/api-paths.json index 3d3d2cbe..928606aa 100644 --- a/sdk/api-paths.json +++ b/sdk/api-paths.json @@ -2,7 +2,7 @@ "source": "src-tauri/src/api_server.rs", "regenerate_with": "python3 sdk/tools/extract-api-paths.py", "description": "Every operation the desktop app publishes in its /openapi.json. The SDK test suites assert this list and their own coverage tables match exactly, so an endpoint added to the app fails the SDK tests until it is either wrapped or deliberately listed as omitted.", - "operation_count": 84, + "operation_count": 85, "operations": [ { "operation_id": "download_browser_api", @@ -249,6 +249,11 @@ "method": "POST", "path": "/v1/profiles/{id}/cookies/import" }, + { + "operation_id": "get_profile_fingerprint_api", + "method": "GET", + "path": "/v1/profiles/{id}/fingerprint" + }, { "operation_id": "kill_profile", "method": "POST", diff --git a/sdk/node/src/client.mts b/sdk/node/src/client.mts index 76cbf6ef..4f3600ba 100644 --- a/sdk/node/src/client.mts +++ b/sdk/node/src/client.mts @@ -14,6 +14,7 @@ import type { AgentTyping, ApiBulkCreateResponse, ApiGroupResponse, + ApiProfileFingerprintResponse, ApiProfileResponse, ApiProfilesResponse, ApiProxyResponse, @@ -265,6 +266,21 @@ export class DonutClient { return this.#request("GET", `/v1/profiles/${segment(profileId)}`); } + /** + * GET /v1/profiles/{id}/fingerprint + * + * The device the profile presents. May start the profile's browser version + * headless for a moment. + */ + getProfileFingerprint( + profileId: string, + ): Promise { + return this.#request( + "GET", + `/v1/profiles/${segment(profileId)}/fingerprint`, + ); + } + /** * POST /v1/profiles * diff --git a/sdk/node/src/coverage.mts b/sdk/node/src/coverage.mts index f62c22fb..7d7fb138 100644 --- a/sdk/node/src/coverage.mts +++ b/sdk/node/src/coverage.mts @@ -59,6 +59,7 @@ export const OPERATIONS: ReadonlyMap = new Map([ ["GET /v1/profiles/import/detect", "detectImportProfiles"], ["DELETE /v1/profiles/{id}", "deleteProfile"], ["GET /v1/profiles/{id}", "getProfile"], + ["GET /v1/profiles/{id}/fingerprint", "getProfileFingerprint"], ["PUT /v1/profiles/{id}", "updateProfile"], ["POST /v1/profiles/{id}/agent/click", "agentClick"], ["POST /v1/profiles/{id}/agent/extract", "agentExtract"], diff --git a/sdk/node/src/types.mts b/sdk/node/src/types.mts index d32d45f0..b315f580 100644 --- a/sdk/node/src/types.mts +++ b/sdk/node/src/types.mts @@ -76,6 +76,15 @@ export interface ApiProfileResponse { profile: ApiProfile; } +export interface ApiProfileFingerprintResponse { + /** + * The device the profile presents, as a JSON-encoded object: the shape + * `wayfern_config.fingerprint` takes on create. `null` when there is none + * to show. + */ + fingerprint?: string | null; +} + export interface ApiGroupResponse { id: string; name: string; diff --git a/sdk/node/test/requests.test.mts b/sdk/node/test/requests.test.mts index 0b7f2428..d2cc8242 100644 --- a/sdk/node/test/requests.test.mts +++ b/sdk/node/test/requests.test.mts @@ -51,6 +51,14 @@ const CASES: Case[] = [ body: null, operation: "GET /v1/profiles/{id}", }, + { + method: "getProfileFingerprint", + args: ["p1"], + verb: "GET", + path: "/v1/profiles/p1/fingerprint", + body: null, + operation: "GET /v1/profiles/{id}/fingerprint", + }, { method: "createProfile", args: [{ name: "Shopper", browser: "wayfern", tags: ["eu"], ephemeral: true }], diff --git a/sdk/python/src/donutbrowser/client.py b/sdk/python/src/donutbrowser/client.py index ff2bc013..4958dc8c 100644 --- a/sdk/python/src/donutbrowser/client.py +++ b/sdk/python/src/donutbrowser/client.py @@ -24,6 +24,7 @@ from .models import ( AgentTyping, ApiBulkCreateResponse, ApiGroupResponse, + ApiProfileFingerprintResponse, ApiProfileResponse, ApiProfilesResponse, ApiProxyResponse, @@ -282,6 +283,14 @@ class DonutClient: """GET /v1/profiles/{id}""" return self._request("GET", f"/v1/profiles/{_segment(profile_id)}") + def get_profile_fingerprint(self, profile_id: str) -> ApiProfileFingerprintResponse: + """GET /v1/profiles/{id}/fingerprint + + The device the profile presents. May start the profile's browser + version headless for a moment. + """ + return self._request("GET", f"/v1/profiles/{_segment(profile_id)}/fingerprint") + def create_profile( self, *, diff --git a/sdk/python/src/donutbrowser/coverage.py b/sdk/python/src/donutbrowser/coverage.py index d7be1068..c0b25c97 100644 --- a/sdk/python/src/donutbrowser/coverage.py +++ b/sdk/python/src/donutbrowser/coverage.py @@ -66,6 +66,7 @@ OPERATIONS: Dict[Operation, str] = { ("GET", "/v1/profiles/import/detect"): "detect_import_profiles", ("DELETE", "/v1/profiles/{id}"): "delete_profile", ("GET", "/v1/profiles/{id}"): "get_profile", + ("GET", "/v1/profiles/{id}/fingerprint"): "get_profile_fingerprint", ("PUT", "/v1/profiles/{id}"): "update_profile", ("POST", "/v1/profiles/{id}/agent/click"): "agent_click", ("POST", "/v1/profiles/{id}/agent/extract"): "agent_extract", diff --git a/sdk/python/src/donutbrowser/models.py b/sdk/python/src/donutbrowser/models.py index 2c2c1b36..e7e352e7 100644 --- a/sdk/python/src/donutbrowser/models.py +++ b/sdk/python/src/donutbrowser/models.py @@ -23,6 +23,7 @@ __all__ = [ "ApiProfile", "ApiProfilesResponse", "ApiProfileResponse", + "ApiProfileFingerprintResponse", "ApiGroupResponse", "ApiStatusResponse", "ProfilePatch", @@ -145,6 +146,13 @@ class ApiProfileResponse(TypedDict): profile: ApiProfile +class ApiProfileFingerprintResponse(TypedDict, total=False): + #: The device the profile presents, as a JSON-encoded object: the shape + #: ``wayfern_config.fingerprint`` takes on create. ``None`` when there is + #: none to show. + fingerprint: Optional[str] + + class ApiGroupResponse(TypedDict): id: str name: str diff --git a/sdk/python/tests/test_requests.py b/sdk/python/tests/test_requests.py index 89a2dec5..0bb54b1f 100644 --- a/sdk/python/tests/test_requests.py +++ b/sdk/python/tests/test_requests.py @@ -44,6 +44,16 @@ CASES: List[Case] = [ # -- profiles ---------------------------------------------------------- ("list_profiles", (), {}, "GET", "/v1/profiles", None, {}, "/v1/profiles"), ("get_profile", ("p1",), {}, "GET", "/v1/profiles/p1", None, {}, "/v1/profiles/{id}"), + ( + "get_profile_fingerprint", + ("p1",), + {}, + "GET", + "/v1/profiles/p1/fingerprint", + None, + {}, + "/v1/profiles/{id}/fingerprint", + ), ( "create_profile", (), diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index a05148f9..7e7f60f2 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -133,6 +133,14 @@ pub struct ApiProfileResponse { pub profile: ApiProfile, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ApiProfileFingerprintResponse { + /// The device the profile presents, as a JSON-encoded object: the shape + /// `wayfern_config.fingerprint` takes on create. `null` when there is none + /// to show. + pub fingerprint: Option, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateProfileRequest { pub name: String, @@ -757,6 +765,7 @@ struct ImportProxiesResponse { paths( get_profiles, get_profile, + get_profile_fingerprint_api, create_profile, update_profile, delete_profile, @@ -844,6 +853,7 @@ struct ImportProxiesResponse { ApiProfile, ApiProfilesResponse, ApiProfileResponse, + ApiProfileFingerprintResponse, CreateProfileRequest, UpdateProfileRequest, ApiGroupResponse, @@ -1146,6 +1156,7 @@ fn build_v1_router() -> Router { let (routes, _) = OpenApiRouter::new() .routes(routes!(get_profiles, create_profile)) .routes(routes!(get_profile, update_profile, delete_profile)) + .routes(routes!(get_profile_fingerprint_api)) .routes(routes!(run_profile)) .routes(routes!(run_profile_remote)) // One `routes!` per PATH, not per handler: the GET and the DELETE share @@ -1655,6 +1666,44 @@ async fn get_profile( } } +/// The device a Wayfern profile presents. Reading it starts the profile's +/// browser version headless for a moment; nothing is launched for a profile +/// that stores its device. +#[utoipa::path( + get, + path = "/v1/profiles/{id}/fingerprint", + params( + ("id" = String, Path, description = "Profile ID") + ), + responses( + (status = 200, description = "The device the profile presents", body = ApiProfileFingerprintResponse), + (status = 400, description = "The browser could not rebuild the device"), + (status = 401, description = "Unauthorized"), + (status = 402, description = "The profile's fingerprint OS needs a paid plan"), + (status = 404, description = "Profile not found"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn get_profile_fingerprint_api( + Path(id): Path, +) -> Result, (StatusCode, String)> { + let profile = ProfileManager::instance() + .list_profiles() + .map_err(manager_error_response)? + .into_iter() + .find(|p| p.id.to_string() == id) + .ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?; + crate::wayfern_manager::WayfernManager::instance() + .profile_fingerprint(&profile) + .await + .map(|fingerprint| Json(ApiProfileFingerprintResponse { fingerprint })) + .map_err(manager_error_response) +} + /// Create a profile. /// /// - `browser` must be `"wayfern"`; any other value is rejected @@ -6352,6 +6401,23 @@ mod tests { assert!(spec["paths"]["/v1/templates/{id}/profiles"]["post"]["responses"]["429"].is_object()); } + #[test] + fn openapi_documents_the_profile_fingerprint() { + let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); + let get = &spec["paths"]["/v1/profiles/{id}/fingerprint"]["get"]; + for status in ["200", "404"] { + assert!(get["responses"][status].is_object(), "{status} documented"); + } + let fingerprint = + &spec["components"]["schemas"]["ApiProfileFingerprintResponse"]["properties"]["fingerprint"]; + assert_eq!( + fingerprint["type"], + serde_json::json!(["string", "null"]), + "the device is a JSON-encoded string, or null when there is none to show" + ); + assert!(schema_required(&spec, "ApiProfileFingerprintResponse").is_empty()); + } + #[test] fn openapi_documents_the_storage_state() { let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes"); @@ -7507,3 +7573,7 @@ mod tests { } } } + +#[cfg(all(test, feature = "e2e"))] +#[path = "api_server_fingerprint_browser_tests.rs"] +mod fingerprint_browser_tests; diff --git a/src-tauri/src/api_server_fingerprint_browser_tests.rs b/src-tauri/src/api_server_fingerprint_browser_tests.rs new file mode 100644 index 00000000..cb60fc37 --- /dev/null +++ b/src-tauri/src/api_server_fingerprint_browser_tests.rs @@ -0,0 +1,322 @@ +use super::*; +use crate::profile::BrowserProfile; +use crate::wayfern_manager::{HeadlessWayfern, WayfernConfig, WayfernManager}; +use serde_json::{json, Value}; +use std::path::{Path as FsPath, PathBuf}; + +const LOCATION: &str = + r#"{"timezone":"Europe/Berlin","language":"de-DE","latitude":52.52,"longitude":13.405}"#; + +fn wayfern_dir() -> PathBuf { + std::env::var("DONUT_TEST_WAYFERN_DIR") + .ok() + .filter(|dir| !dir.trim().is_empty()) + .map(PathBuf::from) + .expect("DONUT_TEST_WAYFERN_DIR is required") +} + +/// `DONUT_TEST_WAYFERN_VERSION`, or what the build reports. A Windows build +/// prints no version, so it needs the variable. +fn wayfern_version(dir: &FsPath) -> String { + if let Some(version) = std::env::var("DONUT_TEST_WAYFERN_VERSION") + .ok() + .filter(|version| !version.trim().is_empty()) + { + return version; + } + let exe = crate::browser::create_browser(crate::browser::BrowserType::Wayfern) + .get_executable_path(dir) + .expect("DONUT_TEST_WAYFERN_DIR holds a Wayfern build"); + let out = std::process::Command::new(exe) + .arg("--version") + .output() + .expect("the browser reports its version"); + String::from_utf8_lossy(&out.stdout) + .split_whitespace() + .find(|word| word.contains('.')) + .expect("a dotted version, or DONUT_TEST_WAYFERN_VERSION") + .to_string() +} + +/// The data dir's binaries entry for `version`, pointing at the build. +fn install(root: &FsPath, dir: &FsPath, version: &str) { + let parent = root.join("binaries").join("wayfern"); + std::fs::create_dir_all(&parent).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(dir, parent.join(version)).unwrap(); + #[cfg(windows)] + assert!(std::process::Command::new("cmd") + .args(["/c", "mklink", "/J"]) + .arg(parent.join(version)) + .arg(dir) + .output() + .unwrap() + .status + .success()); +} + +fn profile(version: &str, config: WayfernConfig) -> BrowserProfile { + let profile = BrowserProfile { + id: uuid::Uuid::new_v4(), + name: format!("fingerprint {}", uuid::Uuid::new_v4()), + browser: "wayfern".to_string(), + version: version.to_string(), + release_type: "stable".to_string(), + wayfern_config: Some(config), + host_os: Some(crate::profile::types::get_host_os()), + ..Default::default() + }; + ProfileManager::instance().save_profile(&profile).unwrap(); + profile +} + +/// A device minted the way creation and regeneration mint one. +async fn mint(version: &str, os: &str, method: &str, params: Value) -> Value { + let stand_in = BrowserProfile { + browser: "wayfern".to_string(), + version: version.to_string(), + ..Default::default() + }; + let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + let session = HeadlessWayfern::start( + WayfernManager::instance(), + &stand_in, + token.as_deref(), + "purpose=test", + ) + .await + .expect("the browser starts"); + let mut params = params; + params["operatingSystem"] = json!(os); + if let Some(token) = token { + params["wayfernToken"] = json!(token); + } + let answer = session + .call(WayfernManager::instance(), method, params) + .await; + session.stop().await; + answer.unwrap_or_else(|e| panic!("{method} failed: {e}")) +} + +async fn minted_identity(version: &str, os: &str) -> String { + mint(version, os, "Wayfern.createIdentity", json!({})).await["identityId"] + .as_str() + .expect("an identity id") + .to_string() +} + +/// What each read path answers for `profile`: the fingerprint form's command, +/// REST and MCP. All three must agree. +async fn read(profile: &BrowserProfile) -> Result, String> { + let id = profile.id.to_string(); + let form = crate::get_profile_fingerprint(id.clone()).await; + let rest = get_profile_fingerprint_api(Path(id.clone())) + .await + .map(|Json(answer)| answer.fingerprint) + .map_err(|(status, body)| format!("{status} {body}")); + let call = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_profile_fingerprint", "arguments": {"profile_id": id}} + }); + let crate::mcp_server::McpOutcome::Body { body, .. } = crate::mcp_server::McpServer::instance() + .handle_message_with(None, call.to_string().as_bytes(), None) + .await + else { + panic!("MCP did not answer"); + }; + let mcp = match body["result"]["content"][0]["text"].as_str() { + Some(text) => { + let info: Value = serde_json::from_str(text).unwrap(); + Ok(info["fingerprint"].as_str().map(str::to_string)) + } + None => Err(body["error"]["message"].to_string()), + }; + + let parse = |json: Option| json.map(|json| serde_json::from_str::(&json).unwrap()); + match (form, rest, mcp) { + (Ok(form), Ok(rest), Ok(mcp)) => { + let form = parse(form); + assert_eq!(parse(rest), form, "REST reads what the form reads"); + assert_eq!(parse(mcp), form, "MCP reads what the form reads"); + Ok(form) + } + (Err(form), Err(_), Err(_)) => Err(form), + (form, rest, mcp) => { + let brief = |answer: &Result, String>| match answer { + Ok(Some(json)) => format!("{} bytes", json.len()), + Ok(None) => "none".to_string(), + Err(e) => format!("error {e}"), + }; + panic!( + "the read paths disagree: form {}, REST {}, MCP {}", + brief(&form), + brief(&rest), + brief(&mcp) + ) + } + } +} + +fn assert_full(device: &Value, what: &str) { + let fields = device.as_object().map(|o| o.len()).unwrap_or_default(); + println!("{what}: {fields} fields"); + assert!(fields >= 10, "{what}: {fields} fields {device}"); +} + +/// Reads the device of identity-backed profiles through every path, against a +/// real browser build. Needs an e2e build (`--features e2e`), +/// `TAURI_AUTOMATION=1` and an entitled `WAYFERN_TEST_TOKEN`; the token-less +/// half of the test removes the token from the environment. +#[tokio::test(flavor = "current_thread")] +#[ignore = "needs a browser build in DONUT_TEST_WAYFERN_DIR and WAYFERN_TEST_TOKEN"] +async fn every_read_path_shows_the_device_an_identity_presents() { + assert!( + crate::e2e_automation_enabled(), + "TAURI_AUTOMATION=1 is required" + ); + assert!( + std::env::var("WAYFERN_TEST_TOKEN").is_ok_and(|token| !token.is_empty()), + "WAYFERN_TEST_TOKEN is required" + ); + crate::mcp_server::McpServer::instance().mark_engine_ready_for_tests(); + let root = tempfile::tempdir().unwrap(); + std::env::set_var("DONUTBROWSER_DATA_DIR", root.path()); + std::env::set_var("DONUTBROWSER_CACHE_DIR", root.path().join("cache")); + let _data = crate::app_dirs::set_test_data_dir(root.path().to_path_buf()); + let _cache = crate::app_dirs::set_test_cache_dir(root.path().join("cache")); + let dir = wayfern_dir(); + let version = wayfern_version(&dir); + assert!(crate::wayfern_manager::supports_wayfern_152(&version)); + install(root.path(), &dir, &version); + let host = crate::profile::types::get_host_os(); + let other = if host == "windows" { + "macos" + } else { + "windows" + }; + + // A new profile stores its identity and location, never the device. + let created = profile( + &version, + WayfernConfig { + identity_id: Some(minted_identity(&version, &host).await), + location: Some(LOCATION.to_string()), + ..Default::default() + }, + ); + let device = read(&created) + .await + .unwrap() + .expect("a new profile's device"); + assert_full(&device, "new profile"); + assert_eq!(device["timezone"], json!("Europe/Berlin")); + assert_eq!(device["language"], json!("de-DE")); + assert_eq!( + read(&created).await.unwrap().unwrap(), + device, + "the same identity reads as the same device" + ); + + // The user's edits are part of the device the profile presents. + let edited_cores = if device["hardwareConcurrency"] == json!(4) { + 8 + } else { + 4 + }; + let mut edited = created.clone(); + edited.wayfern_config.as_mut().unwrap().identity_overrides = + Some(json!({ "hardwareConcurrency": edited_cores }).to_string()); + ProfileManager::instance().save_profile(&edited).unwrap(); + let with_edit = read(&edited).await.unwrap().unwrap(); + assert_eq!(with_edit["hardwareConcurrency"], json!(edited_cores)); + for key in [ + "userAgent", + "platform", + "screenWidth", + "screenHeight", + "webglRenderer", + ] { + assert_eq!(with_edit[key], device[key], "{key} stays the identity's"); + } + + // A claim off this machine's OS. + let cross_os = profile( + &version, + WayfernConfig { + identity_id: Some(minted_identity(&version, other).await), + os: Some(other.to_string()), + location: Some(LOCATION.to_string()), + ..Default::default() + }, + ); + let foreign = read(&cross_os).await.unwrap().expect("a cross-OS device"); + assert_full(&foreign, "cross-OS profile"); + assert_eq!( + foreign["platform"], + json!(if other == "windows" { + "Win32" + } else { + "MacIntel" + }) + ); + + // A regenerated device replaces the identity; the new one reads in full. + let mut regenerated = created.clone(); + regenerated.wayfern_config.as_mut().unwrap().identity_id = + Some(minted_identity(&version, &host).await); + ProfileManager::instance() + .save_profile(®enerated) + .unwrap(); + let fresh = read(®enerated).await.unwrap().unwrap(); + assert_full(&fresh, "regenerated profile"); + assert_ne!(fresh, device, "a new identity is a new device"); + + // A legacy profile that stored a whole device converts to an identity and + // still shows that device. + let payload = mint(&version, &host, "Wayfern.createIdentity", json!({})).await; + let payload = payload["identity"].clone(); + assert_full(&payload, "legacy payload"); + let legacy = profile( + &version, + WayfernConfig { + fingerprint: Some(payload.to_string()), + ..Default::default() + }, + ); + assert_eq!( + read(&legacy).await.unwrap().unwrap(), + payload, + "a stored device is read as stored" + ); + let migrated = crate::wayfern_identity_storage::convert_for_launch(&legacy) + .await + .unwrap(); + let config = migrated.wayfern_config.clone().unwrap(); + assert!(config.identity_id.is_some() && config.fingerprint.is_none()); + let carried = read(&migrated).await.unwrap().expect("a migrated device"); + assert_full(&carried, "migrated profile"); + for key in [ + "userAgent", + "platform", + "hardwareConcurrency", + "screenWidth", + "screenHeight", + "webglRenderer", + ] { + assert_eq!( + carried[key], payload[key], + "{key} carried through migration" + ); + } + + // Without a token the browser returns no device, so there is none to show. + std::env::remove_var("WAYFERN_TEST_TOKEN"); + assert_eq!(read(&created).await.unwrap(), None); + assert_eq!(read(&edited).await.unwrap(), None); + assert!( + read(&cross_os).await.is_err(), + "a cross-OS device is not rebuilt without a plan" + ); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9150bb5b..120a59a6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1957,6 +1957,21 @@ async fn generate_sample_fingerprint( } } +/// The device the fingerprint form shows for a saved profile, as a JSON +/// object string, or `None` when there is none to show. +#[tauri::command] +async fn get_profile_fingerprint(profile_id: String) -> Result, String> { + let profile = crate::profile::ProfileManager::instance() + .list_profiles() + .map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))? + .into_iter() + .find(|profile| profile.id.to_string() == profile_id) + .ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))?; + crate::wayfern_manager::WayfernManager::instance() + .profile_fingerprint(&profile) + .await +} + // --- Remote sessions -------------------------------------------------------- // // Everything below is transport only. The session state machine, the schedule, @@ -3506,6 +3521,7 @@ pub fn run_with_builder( import_proxies_from_parsed, update_wayfern_config, generate_sample_fingerprint, + get_profile_fingerprint, get_profile_groups, get_groups_with_profile_counts, create_profile_group, diff --git a/src-tauri/src/mcp_server.rs b/src-tauri/src/mcp_server.rs index 9385ca55..e43a4664 100644 --- a/src-tauri/src/mcp_server.rs +++ b/src-tauri/src/mcp_server.rs @@ -3552,7 +3552,7 @@ impl McpServer { // Fingerprint management tools McpTool { name: "get_profile_fingerprint".to_string(), - description: "Get the fingerprint configuration for a Wayfern profile" + description: "Get the fingerprint configuration for a Wayfern profile. `fingerprint` is the device the profile presents, as a JSON string, or null when there is none to show." .to_string(), input_schema: serde_json::json!({ "type": "object", @@ -7898,9 +7898,17 @@ impl McpServer { let fingerprint_info = match profile.browser.as_str() { "wayfern" => { let config = profile.wayfern_config.as_ref().cloned().unwrap_or_default(); + let fingerprint = crate::wayfern_manager::WayfernManager::instance() + .profile_fingerprint(profile) + .await + .map_err(|e| McpError { + code: -32000, + message: format!("Failed to read the fingerprint: {e}"), + data: None, + })?; serde_json::json!({ "browser": "wayfern", - "fingerprint": config.fingerprint, + "fingerprint": fingerprint, "identity_id": config.identity_id, "identity_overrides": config.identity_overrides, "location": config.location, diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 95cefcf6..138a689c 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -1793,6 +1793,58 @@ impl WayfernManager { Some(serde_json::Value::Object(document)) } + /// The `Wayfern.setIdentity` parameters that rebuild an identity-backed + /// profile's device: the id, the claimed OS, the user's edits and the stored + /// location. `None` for a profile that stores a device payload or no id. + fn identity_params( + config: &WayfernConfig, + native_os: &str, + ) -> Option> { + if config.fingerprint.is_some() { + return None; + } + let identity_id = config + .identity_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty())?; + let os = Self::claimed_operating_system(config, None).unwrap_or(native_os); + let mut params = serde_json::Map::new(); + params.insert("identityId".to_string(), json!(identity_id)); + params.insert("operatingSystem".to_string(), json!(os)); + let overrides = Self::stored_object(config.identity_overrides.as_deref()); + if !overrides.is_empty() { + params.insert( + "overrides".to_string(), + serde_json::Value::Object(overrides), + ); + } + params.extend(Self::geo_params(&Self::stored_object( + config.location.as_deref(), + ))); + Some(params) + } + + /// The device in an identity answer, with the stored location filling the + /// location fields the answer leaves out. `None` when the answer carries no + /// device. + fn identity_view( + answer: &serde_json::Value, + location: &serde_json::Map, + ) -> Option> { + let mut view = answer + .get("identity")? + .as_object() + .filter(|device| !device.is_empty())? + .clone(); + for key in LOCALE_CARRY_OVER_KEYS { + if let Some(value) = location.get(key).filter(|_| !view.contains_key(key)) { + view.insert(key.to_string(), value.clone()); + } + } + Some(view) + } + /// Write the launch identity into the profile directory and return the /// absolute path the browser is given. Private to the user on Unix: the /// document names the identity and the user's overrides. @@ -2592,6 +2644,59 @@ impl WayfernManager { }) } + /// The device a profile presents, as a JSON object string: the stored + /// payload of a legacy profile, or the device the browser derives from an + /// identity-backed profile's id, edits and location. Nothing is stored. + /// `None` when the profile has neither, or the browser does not return the + /// device to this account. + pub async fn profile_fingerprint( + &self, + profile: &BrowserProfile, + ) -> Result, String> { + let config = profile.wayfern_config.clone().unwrap_or_default(); + if config.fingerprint.is_some() { + return Ok(config.fingerprint); + } + let Some(mut params) = Self::identity_params(&config, &profile.native_os()) + .filter(|_| supports_identity_api(&profile.version)) + else { + return Ok(None); + }; + let claimed_os = params["operatingSystem"].as_str().map(str::to_string); + let failure = |e: Box| { + wayfern_failure( + &e.to_string(), + "WAYFERN_FINGERPRINT_APPLY_FAILED", + claimed_os.as_deref(), + ) + }; + let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + if let Some(token) = &token { + params.insert("wayfernToken".to_string(), json!(token)); + } + let session = HeadlessWayfern::start( + self, + profile, + token.as_deref(), + &format!("purpose=fingerprint_view profile={}", profile.id), + ) + .await + .map_err(failure)?; + let answer = session + .call( + self, + "Wayfern.setIdentity", + serde_json::Value::Object(params), + ) + .await; + session.stop().await; + let location = Self::stored_object(config.location.as_deref()); + Ok( + Self::identity_view(&answer.map_err(failure)?, &location) + .map(|view| serde_json::Value::Object(view).to_string()), + ) + } + #[allow(clippy::too_many_arguments)] pub async fn launch_wayfern( &self, @@ -4505,6 +4610,73 @@ mod tests { ); } + #[test] + fn the_device_is_read_from_the_stored_identity_edits_and_location() { + let params = WayfernManager::identity_params(&identity_config(None), "macos") + .expect("an identity profile is read through its identity"); + assert_eq!( + serde_json::Value::Object(params), + json!({ + "identityId": "3fa85f64-5717-4562-b3fc-2c963f66afa6", + "operatingSystem": "windows", + "language": "de-DE", + "latitude": 52.52, + "longitude": 13.405, + "overrides": {"hardwareConcurrency": 8} + }), + "the edits travel, and a location without a timezone still does" + ); + + let mut no_os = identity_config(Some("Europe/Berlin")); + no_os.os = None; + assert_eq!( + WayfernManager::identity_params(&no_os, "linux").unwrap()["operatingSystem"], + json!("linux") + ); + + let mut legacy = identity_config(Some("Europe/Berlin")); + legacy.fingerprint = Some("{}".into()); + assert!(WayfernManager::identity_params(&legacy, "windows").is_none()); + let mut no_identity = identity_config(Some("Europe/Berlin")); + no_identity.identity_id = None; + assert!(WayfernManager::identity_params(&no_identity, "windows").is_none()); + } + + #[test] + fn the_device_view_is_the_answer_with_the_stored_location_filled_in() { + let location = obj( + r#"{"timezone":"Europe/Berlin","timezoneOffset":-60,"languages":["de-DE","de"],"accuracy":20}"#, + ); + let answer = json!({ + "identity": { + "platform": "Win32", + "hardwareConcurrency": 8, + "timezone": "Europe/Berlin", + "languages": ["de-DE"] + } + }); + let view = WayfernManager::identity_view(&answer, &location).unwrap(); + assert_eq!(view["hardwareConcurrency"], json!(8)); + assert_eq!(view["timezoneOffset"], json!(-60)); + assert_eq!(view["accuracy"], json!(20)); + assert_eq!( + view["languages"], + json!(["de-DE"]), + "what the browser answers wins over the stored location" + ); + + for withheld in [ + json!({"identity": {}}), + json!({}), + json!({"identity": null}), + ] { + assert!( + WayfernManager::identity_view(&withheld, &location).is_none(), + "{withheld}: an answer without a device is no device, location or not" + ); + } + } + #[test] fn start_pages_open_on_a_persons_launch_and_replace_the_last_session() { let pages = vec![ diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index 95e79e11..ecfedb92 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { LoadingButton } from "@/components/loading-button"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -296,6 +296,13 @@ export function WayfernConfigForm({ const [currentOS] = useState(getCurrentOS); const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false); const [isRegenerateConfirmOpen, setIsRegenerateConfirmOpen] = useState(false); + // The device an identity-backed profile presents, read from the browser, + // and the identity it was read for. Shown, never stored. + const [device, setDevice] = useState<{ + identityId: string; + fields: WayfernFingerprintConfig; + } | null>(null); + const ownEditRef = useRef<{ json: string | undefined } | null>(null); const handleGenerateFingerprint = async () => { if (!profileVersion) return; @@ -324,6 +331,12 @@ export function WayfernConfigForm({ result.identity_id ? undefined : result.fingerprint, ); onConfigChange("identity_overrides", undefined); + if (result.identity_id && !limitedMode) { + setDevice({ + identityId: result.identity_id, + fields: JSON.parse(result.fingerprint) as WayfernFingerprintConfig, + }); + } } catch (error) { console.error("Failed to generate fingerprint:", error); } finally { @@ -376,31 +389,67 @@ export function WayfernConfigForm({ const editedJson = config.identity_id ? config.identity_overrides : config.fingerprint; + const identityId = config.identity_id; + const shownDevice = + device && device.identityId === identityId ? device.fields : null; + useEffect(() => { + if ( + !profileId || + !identityId || + limitedMode || + device?.identityId === identityId + ) { + return; + } + let cancelled = false; + void invoke("get_profile_fingerprint", { profileId }) + .then((json) => { + if (!cancelled && json) { + setDevice({ + identityId, + fields: JSON.parse(json) as WayfernFingerprintConfig, + }); + } + }) + .catch((error: unknown) => { + console.error("Failed to read fingerprint:", error); + }); + return () => { + cancelled = true; + }; + }, [profileId, identityId, limitedMode, device?.identityId]); + + useEffect(() => { + // The form's own edit: what it shows already matches, including a field + // the user is in the middle of clearing. + if (ownEditRef.current && ownEditRef.current.json === editedJson) { + ownEditRef.current = null; + return; + } + ownEditRef.current = null; + let edited: WayfernFingerprintConfig = {}; if (editedJson) { try { - const parsed = JSON.parse(editedJson) as WayfernFingerprintConfig; - setFingerprintConfig(parsed); + edited = JSON.parse(editedJson) as WayfernFingerprintConfig; } catch (error) { console.error("Failed to parse fingerprint config:", error); - setFingerprintConfig({}); } - } else { - setFingerprintConfig({}); } - }, [editedJson]); + setFingerprintConfig(shownDevice ? { ...shownDevice, ...edited } : edited); + }, [editedJson, shownDevice]); const updateFingerprintConfig = ( key: keyof WayfernFingerprintConfig, value: unknown, ) => { const newConfig = { ...fingerprintConfig }; - - if ( + const cleared = value === undefined || value === "" || - (Array.isArray(value) && value.length === 0) - ) { + (Array.isArray(value) && value.length === 0); + + if (cleared) { delete newConfig[key]; } else { (newConfig as Record)[key] = value; @@ -409,13 +458,30 @@ export function WayfernConfigForm({ setFingerprintConfig(newConfig); try { - const jsonString = JSON.stringify(newConfig); - onConfigChange( - config.identity_id ? "identity_overrides" : "fingerprint", - Object.keys(newConfig).length === 0 && config.identity_id - ? undefined - : jsonString, - ); + if (config.identity_id) { + // Only the user's edits are stored. A value equal to the device the + // identity presents is not an edit. + const edits: Record = config.identity_overrides + ? JSON.parse(config.identity_overrides) + : {}; + if ( + cleared || + (!(key in edits) && + JSON.stringify(value) === JSON.stringify(shownDevice?.[key])) + ) { + delete edits[key]; + } else { + edits[key] = value; + } + const json = + Object.keys(edits).length === 0 ? undefined : JSON.stringify(edits); + if (json !== editedJson) ownEditRef.current = { json }; + onConfigChange("identity_overrides", json); + } else { + const json = JSON.stringify(newConfig); + if (json !== editedJson) ownEditRef.current = { json }; + onConfigChange("fingerprint", json); + } } catch (error) { console.error("Failed to serialize fingerprint config:", error); }