fix: Pro fingerprint view

This commit is contained in:
zhom committed 2026-10-18 05:28:36 +04:00
1 parent ba5e25cdc6
commit 1f9c338098
15 files changed
+251 -23

No files matched your search

+93 -1
View File
@@ -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",
+2 -1
View File
@@ -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" = [])
+53 -5
View File
@@ -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::Mutex<HashMap<String, String>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
static WAYFERN_MANAGER: std::sync::LazyLock<WayfernManager> =
std::sync::LazyLock::new(WayfernManager::new);
+81 -5
View File
@@ -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<string | null>("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) ? (
<div
data-slot="fingerprint-device-state"
data-state={deviceError ? "failed" : "loading"}
role={deviceError ? "alert" : "status"}
className="flex items-center gap-3 rounded-lg bg-foreground/4 px-4 py-3 text-sm"
>
{deviceError ? (
<>
<span className="min-w-0 flex-1 text-pretty text-muted-foreground">
{deviceError}
</span>
<Button
variant="soft"
size="sm"
className="h-8 shrink-0"
onClick={() => setDeviceAttempt((attempt) => attempt + 1)}
>
{t("common.buttons.retry")}
</Button>
</>
) : (
<>
<LuLoaderCircle
aria-hidden="true"
className="size-4 shrink-0 animate-spin text-muted-foreground motion-reduce:animate-none"
/>
<span className="text-muted-foreground">
{t("fingerprint.readingDevice")}
</span>
</>
)}
</div>
) : (
!limitedMode &&
(isEditingDisabled ? (
<Alert>
<AlertDescription>
@@ -738,10 +808,16 @@ export function WayfernConfigForm({
{t("fingerprint.basicWarning")}
</AlertDescription>
</Alert>
))}
))
)}
<fieldset
disabled={isEditingDisabled || limitedMode}
disabled={
isEditingDisabled ||
limitedMode ||
deviceLoading ||
Boolean(deviceError)
}
className="space-y-6"
>
{/* User Agent and Platform */}
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "カスタムウィンドウサイズ",
+2 -1
View File
@@ -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": "사용자 지정 창 크기",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "Пользовательские размеры окна",
+2 -1
View File
@@ -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ı",
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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": "自定义窗口尺寸",