diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 2b7f5ec2..ccc10ca1 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, realpath, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import http from "node:http"; import path from "node:path"; import test from "node:test"; @@ -6099,6 +6099,98 @@ test("the profile network section saves, refuses and clears a DNS server", async }); }); +test("a Pro user's fingerprint tab shows the device, and says why when it cannot", async () => { + await withApp("ui-fingerprint-view", async (app) => { + const created = await createUiProfile(app, "Identity Device"); + // An identity-backed profile stores the identity, never the device, on a + // version that reads it from the browser. + const file = path.join( + app.dataRoot, + "data", + "profiles", + created.id, + "metadata.json", + ); + const stored = JSON.parse(await readFile(file, "utf8")); + stored.version = "154.0.8037.57"; + delete stored.wayfern_config.fingerprint; + stored.wayfern_config.identity_id = "6f9619ff-8b86-4d01-b42d-00c04fc964ff"; + await writeFile(file, JSON.stringify(stored)); + await app.restart(); + await app.waitForText("Identity Device"); + await stubCommand(app, "cloud_get_user", { + logged_in_at: "2020-01-01T00:00:00Z", + user: { + id: "fingerprint-pro", + email: "fingerprint@example.test", + plan: "pro", + planPeriod: "monthly", + subscriptionStatus: "active", + profileLimit: 50, + isPrimaryDevice: true, + }, + }); + await app.invoke("plugin:event|emit", { + event: "cloud-auth-changed", + payload: null, + }); + await app.clickSelector( + `[data-slot="profile-inspect-trigger"][data-profile-id="${created.id}"]`, + ); + await app.clickSelector( + '[data-slot="profile-info-section"][data-section="fingerprint"]', + ); + + // This suite has no browser build, so the read fails: the tab says so and + // keeps the fields shut instead of showing them empty. + const state = '[data-slot="fingerprint-device-state"]'; + await app.waitFor( + () => + app.execute( + `return document.querySelector(arguments[0])?.dataset.state === "failed";`, + [state], + ), + { description: "the failed device read is shown" }, + ); + assert.equal( + await app.execute( + `return document.querySelector("#user-agent").matches(":disabled");`, + ), + true, + ); + await app.capture("fingerprint-view-failed"); + + // A read that works fills the fields with the device the profile presents. + const device = { + userAgent: "Mozilla/5.0 (Macintosh) E2E/1.0", + platform: "MacIntel", + hardwareConcurrency: 8, + }; + await stubCommand(app, "get_profile_fingerprint", JSON.stringify(device)); + await app.clickTextIn(state, en.common.buttons.retry, { + roles: ["button"], + }); + await app.waitFor( + () => + app.execute( + `const input = document.querySelector("#user-agent"); + return input && !input.matches(":disabled") && input.value === arguments[0];`, + [device.userAgent], + ), + { description: "the device fills the fingerprint fields" }, + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector(arguments[0]));`, + [state], + ), + false, + ); + await app.capture("fingerprint-view-shown"); + await restoreStubs(app); + }); +}); + test("Pro users can edit and save launch arguments in each profile", async () => { await withApp( "ui-launch-args", diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 4ffb061a..d3d5c2db 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1683,7 +1683,8 @@ async fn get_profile( (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") + (status = 500, description = "Internal server error"), + (status = 503, description = "The device could not be read right now; try again") ), security( ("bearer_auth" = []) diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 412b3dde..63a69194 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -2785,7 +2785,34 @@ impl WayfernManager { claimed_os.as_deref(), ) }; - let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; + let auth = &crate::cloud_auth::CLOUD_AUTH; + // A view read once is shown again without starting a browser, while the + // plan includes custom fingerprints. + let view_key = format!( + "{}|{}", + profile.version, + serde_json::Value::Object(params.clone()) + ); + let entitled = auth.can_use_cross_os_fingerprints().await; + if let Some(view) = FINGERPRINT_VIEWS + .lock() + .ok() + .and_then(|views| views.get(&view_key).cloned()) + .filter(|_| entitled) + { + return Ok(Some(view)); + } + let mut token = auth.get_wayfern_token().await; + // A paid account whose Wayfern token has not arrived yet asks for one. + if token.is_none() && auth.is_entitled_to_wayfern_token().await { + if let Err(e) = auth.request_wayfern_token().await { + log::warn!( + "Wayfern token request for a fingerprint view failed profile={} err=\"{e}\"", + profile.id + ); + } + token = auth.get_wayfern_token().await; + } if let Some(token) = &token { params.insert("wayfernToken".to_string(), json!(token)); } @@ -2806,10 +2833,26 @@ impl WayfernManager { .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()), - ) + match Self::identity_view(&answer.map_err(failure)?, &location) { + Some(view) => { + let view = serde_json::Value::Object(view).to_string(); + if let Ok(mut views) = FINGERPRINT_VIEWS.lock() { + views.insert(view_key, view.clone()); + } + Ok(Some(view)) + } + // No device in the answer: a plan with custom fingerprints is told to + // try again, and any other plan has no device to show. + None if entitled => { + log::warn!( + "Fingerprint view empty profile={} token={}", + profile.id, + token.is_some() + ); + Err(crate::backend_error("WAYFERN_PLAN_CHECK_UNAVAILABLE")) + } + None => Ok(None), + } } #[allow(clippy::too_many_arguments)] @@ -4102,6 +4145,11 @@ fn kill_browser_process(pid: u32) { static TOKEN_AT_LAUNCH: crate::log_streak::Streak = crate::log_streak::Streak::new(module_path!(), "Wayfern token at launch"); +/// Fingerprint views already read, keyed by browser version and the identity +/// parameters. Process memory only: the device is never written to disk. +static FINGERPRINT_VIEWS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + static WAYFERN_MANAGER: std::sync::LazyLock = std::sync::LazyLock::new(WayfernManager::new); diff --git a/src/components/wayfern-config-form.tsx b/src/components/wayfern-config-form.tsx index 244bf247..79a107fd 100644 --- a/src/components/wayfern-config-form.tsx +++ b/src/components/wayfern-config-form.tsx @@ -4,8 +4,10 @@ import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { LuLoaderCircle } from "react-icons/lu"; import { LoadingButton } from "@/components/loading-button"; import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, @@ -30,6 +32,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { useCloudAuth } from "@/hooks/use-cloud-auth"; +import { translateBackendError } from "@/lib/backend-errors"; import { getEntitlements } from "@/lib/entitlements"; import type { PersonaField, @@ -303,6 +306,13 @@ export function WayfernConfigForm({ identityId: string; fields: WayfernFingerprintConfig; } | null>(null); + // Reading that device starts a browser, so it takes a few seconds and can + // fail; the form says so instead of showing empty fields. + const [deviceStatus, setDeviceStatus] = useState<{ + identityId: string; + error: string | null; + } | null>(null); + const [deviceAttempt, setDeviceAttempt] = useState(0); const ownEditRef = useRef<{ json: string | undefined } | null>(null); const handleGenerateFingerprint = async () => { @@ -394,6 +404,7 @@ export function WayfernConfigForm({ const shownDevice = device && device.identityId === identityId ? device.fields : null; + // biome-ignore lint/correctness/useExhaustiveDependencies: deviceAttempt is the Retry button, and a new attempt must read again useEffect(() => { if ( !profileId || @@ -404,9 +415,12 @@ export function WayfernConfigForm({ return; } let cancelled = false; + setDeviceStatus(null); void invoke("get_profile_fingerprint", { profileId }) .then((json) => { - if (!cancelled && json) { + if (cancelled) return; + setDeviceStatus({ identityId, error: null }); + if (json) { setDevice({ identityId, fields: JSON.parse(json) as WayfernFingerprintConfig, @@ -415,11 +429,33 @@ export function WayfernConfigForm({ }) .catch((error: unknown) => { console.error("Failed to read fingerprint:", error); + if (!cancelled) { + setDeviceStatus({ + identityId, + error: translateBackendError(t, error), + }); + } }); return () => { cancelled = true; }; - }, [profileId, identityId, limitedMode, device?.identityId]); + }, [ + profileId, + identityId, + limitedMode, + device?.identityId, + deviceAttempt, + t, + ]); + const deviceRead = + deviceStatus && deviceStatus.identityId === identityId + ? deviceStatus + : null; + const deviceError = deviceRead?.error ?? null; + const deviceLoading = + Boolean(profileId && identityId && !limitedMode) && + !shownDevice && + !deviceRead; useEffect(() => { // The form's own edit: what it shows already matches, including a field @@ -723,7 +759,41 @@ export function WayfernConfigForm({ limitedMode ? "relative overflow-hidden rounded-lg" : undefined } > - {!limitedMode && + {!limitedMode && (deviceLoading || deviceError) ? ( +
+ {deviceError ? ( + <> + + {deviceError} + + + + ) : ( + <> +
+ ) : ( + !limitedMode && (isEditingDisabled ? ( @@ -738,10 +808,16 @@ export function WayfernConfigForm({ {t("fingerprint.basicWarning")} - ))} + )) + )}
{/* User Agent and Platform */} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0f2da514..d203295b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1532,7 +1532,8 @@ "enterAsJson": "{{title}} als JSON eingeben", "launchArgs": "Startargumente", "launchArgsDescription": "Gib ein Argument pro Zeile ein, als --name oder --name=value. Nur unterstützte Argumente werden angenommen, zum Beispiel --disable-gpu, --mute-audio oder --disable-features=Name. Donut verwaltet die Argumente für Profil, Proxy, Fenster und Debugging.", - "launchArgsSkipped": "Die eigenen Startargumente wurden für {{profile}} nicht verwendet. {{reason}}" + "launchArgsSkipped": "Die eigenen Startargumente wurden für {{profile}} nicht verwendet. {{reason}}", + "readingDevice": "Fingerprint dieses Profils wird gelesen…" }, "warnings": { "windowResizeTitle": "Benutzerdefinierte Fenstergröße", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6822e5a5..3ebcc722 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1532,7 +1532,8 @@ "enterAsJson": "Enter {{title}} as JSON", "launchArgs": "Launch arguments", "launchArgsDescription": "Enter one argument per line, as --name or --name=value. Only supported arguments are accepted, for example --disable-gpu, --mute-audio or --disable-features=Name. Donut controls the profile, proxy, window and debugging arguments.", - "launchArgsSkipped": "Custom launch arguments were not used for {{profile}}. {{reason}}" + "launchArgsSkipped": "Custom launch arguments were not used for {{profile}}. {{reason}}", + "readingDevice": "Reading this profile's fingerprint…" }, "warnings": { "windowResizeTitle": "Custom Window Dimensions", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c554cb8f..84f26b03 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1543,7 +1543,8 @@ "enterAsJson": "Ingresa {{title}} como JSON", "launchArgs": "Argumentos de inicio", "launchArgsDescription": "Introduce un argumento por línea, como --name o --name=value. Solo se aceptan argumentos compatibles, por ejemplo --disable-gpu, --mute-audio o --disable-features=Name. Donut controla los argumentos de perfil, proxy, ventana y depuración.", - "launchArgsSkipped": "No se usaron los argumentos de inicio personalizados de {{profile}}. {{reason}}" + "launchArgsSkipped": "No se usaron los argumentos de inicio personalizados de {{profile}}. {{reason}}", + "readingDevice": "Leyendo la huella digital de este perfil…" }, "warnings": { "windowResizeTitle": "Dimensiones de ventana personalizadas", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3df7d2eb..fa832dfb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1543,7 +1543,8 @@ "enterAsJson": "Entrez {{title}} en JSON", "launchArgs": "Arguments de lancement", "launchArgsDescription": "Saisissez un argument par ligne, sous la forme --name ou --name=value. Seuls les arguments pris en charge sont acceptés, par exemple --disable-gpu, --mute-audio ou --disable-features=Name. Donut gère les arguments de profil, de proxy, de fenêtre et de débogage.", - "launchArgsSkipped": "Les arguments de lancement personnalisés n'ont pas été utilisés pour {{profile}}. {{reason}}" + "launchArgsSkipped": "Les arguments de lancement personnalisés n'ont pas été utilisés pour {{profile}}. {{reason}}", + "readingDevice": "Lecture de l’empreinte de ce profil…" }, "warnings": { "windowResizeTitle": "Dimensions de fenêtre personnalisées", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2c401cef..b4739835 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1532,7 +1532,8 @@ "enterAsJson": "{{title}} を JSON で入力", "launchArgs": "起動引数", "launchArgsDescription": "1 行に 1 つの引数を --name または --name=value の形式で入力します。使用できるのは対応している引数だけです(例: --disable-gpu、--mute-audio、--disable-features=Name)。プロファイル、プロキシ、ウィンドウ、デバッグの引数は Donut が管理します。", - "launchArgsSkipped": "{{profile}} ではカスタム起動引数が使われませんでした。{{reason}}" + "launchArgsSkipped": "{{profile}} ではカスタム起動引数が使われませんでした。{{reason}}", + "readingDevice": "このプロファイルのフィンガープリントを読み込んでいます…" }, "warnings": { "windowResizeTitle": "カスタムウィンドウサイズ", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 2607f461..ed6e273b 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -1532,7 +1532,8 @@ "enterAsJson": "{{title}}을(를) JSON으로 입력", "launchArgs": "실행 인수", "launchArgsDescription": "한 줄에 하나의 인수를 --name 또는 --name=value 형식으로 입력하세요. 지원되는 인수만 사용할 수 있습니다(예: --disable-gpu, --mute-audio, --disable-features=Name). 프로필, 프록시, 창 및 디버깅 인수는 Donut이 관리합니다.", - "launchArgsSkipped": "{{profile}}에 사용자 지정 실행 인수가 적용되지 않았습니다. {{reason}}" + "launchArgsSkipped": "{{profile}}에 사용자 지정 실행 인수가 적용되지 않았습니다. {{reason}}", + "readingDevice": "이 프로필의 핑거프린트를 읽는 중…" }, "warnings": { "windowResizeTitle": "사용자 지정 창 크기", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 43c1fa34..0052501c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1543,7 +1543,8 @@ "enterAsJson": "Insira {{title}} como JSON", "launchArgs": "Argumentos de inicialização", "launchArgsDescription": "Insira um argumento por linha, como --name ou --name=value. Apenas argumentos compatíveis são aceitos, por exemplo --disable-gpu, --mute-audio ou --disable-features=Name. O Donut controla os argumentos de perfil, proxy, janela e depuração.", - "launchArgsSkipped": "Os argumentos de inicialização personalizados não foram usados em {{profile}}. {{reason}}" + "launchArgsSkipped": "Os argumentos de inicialização personalizados não foram usados em {{profile}}. {{reason}}", + "readingDevice": "Lendo a impressão digital deste perfil…" }, "warnings": { "windowResizeTitle": "Dimensões de janela personalizadas", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f33db638..9bbc68a2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1554,7 +1554,8 @@ "enterAsJson": "Введите {{title}} в формате JSON", "launchArgs": "Аргументы запуска", "launchArgsDescription": "Введите по одному аргументу в строке: --name или --name=value. Принимаются только поддерживаемые аргументы, например --disable-gpu, --mute-audio или --disable-features=Name. Donut управляет аргументами профиля, прокси, окна и отладки.", - "launchArgsSkipped": "Собственные аргументы запуска не были применены к {{profile}}. {{reason}}" + "launchArgsSkipped": "Собственные аргументы запуска не были применены к {{profile}}. {{reason}}", + "readingDevice": "Читаем отпечаток этого профиля…" }, "warnings": { "windowResizeTitle": "Пользовательские размеры окна", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index de6e1770..cce3b9d0 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -1532,7 +1532,8 @@ "enterAsJson": "{{title}} değerini JSON olarak girin", "launchArgs": "Başlatma argümanları", "launchArgsDescription": "Her satıra bir argüman girin: --name veya --name=value. Yalnızca desteklenen argümanlar kabul edilir; örneğin --disable-gpu, --mute-audio veya --disable-features=Name. Profil, proxy, pencere ve hata ayıklama argümanlarını Donut yönetir.", - "launchArgsSkipped": "{{profile}} için özel başlatma argümanları kullanılmadı. {{reason}}" + "launchArgsSkipped": "{{profile}} için özel başlatma argümanları kullanılmadı. {{reason}}", + "readingDevice": "Bu profilin parmak izi okunuyor…" }, "warnings": { "windowResizeTitle": "Özel Pencere Boyutları", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index 5aa208ac..1cc3dd83 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -1532,7 +1532,8 @@ "enterAsJson": "Nhập {{title}} dưới dạng JSON", "launchArgs": "Tham số khởi chạy", "launchArgsDescription": "Nhập một tham số trên mỗi dòng, dạng --name hoặc --name=value. Chỉ chấp nhận các tham số được hỗ trợ, ví dụ --disable-gpu, --mute-audio hoặc --disable-features=Name. Donut quản lý các tham số hồ sơ, proxy, cửa sổ và gỡ lỗi.", - "launchArgsSkipped": "Các tham số khởi chạy tùy chỉnh không được dùng cho {{profile}}. {{reason}}" + "launchArgsSkipped": "Các tham số khởi chạy tùy chỉnh không được dùng cho {{profile}}. {{reason}}", + "readingDevice": "Đang đọc vân tay của hồ sơ này…" }, "warnings": { "windowResizeTitle": "Kích thước cửa sổ tùy chỉnh", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index d75922a8..f5d92729 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1532,7 +1532,8 @@ "enterAsJson": "以 JSON 格式输入 {{title}}", "launchArgs": "启动参数", "launchArgsDescription": "每行输入一个参数,格式为 --name 或 --name=value。只接受受支持的参数,例如 --disable-gpu、--mute-audio 或 --disable-features=Name。配置文件、代理、窗口和调试参数由 Donut 管理。", - "launchArgsSkipped": "{{profile}} 未使用自定义启动参数。{{reason}}" + "launchArgsSkipped": "{{profile}} 未使用自定义启动参数。{{reason}}", + "readingDevice": "正在读取此配置文件的指纹…" }, "warnings": { "windowResizeTitle": "自定义窗口尺寸",