Compare commits

..
Author SHA1 Message Date
zhom 8b4f657e15 chore: linting 2026-08-24 02:53:42 +04:00
zhom 405e11eeb6 chore: version bump 2026-08-24 00:58:30 +04:00
zhom 90f1bf2569 chore: linting 2026-08-24 00:52:17 +04:00
zhom 33d3af0386 t push
Merge branch 'main' of github.com:zhom/donutbrowser
2026-08-24 00:13:23 +04:00
zhom de88fbbafe refactor: cleanup 2026-08-24 00:10:37 +04:00
zhom 4e1ec4b882 docs: readme 2026-08-21 13:04:27 -07:00
zhom c602a1ce0c docs: switch to svg 2026-08-16 22:35:46 +04:00
zhom 0b1b05c1db test: integration cleanup 2026-08-16 22:31:55 +04:00
zhom 634511d0b0 chore: switch to gitdebt 2026-08-16 22:31:55 +04:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> 1ca8ea3691 chore: update flake.nix for v0.29.5 [skip ci] (#563)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 17:28:52 +00:00
github-actions[bot]andgithub-actions[bot] <github-actions[bot]@users.noreply.github.com> 5d77c5dd61 docs: update CHANGELOG.md and README.md for v0.29.5 [skip ci] (#562)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-16 17:28:30 +00:00
21 changed files with 1326 additions and 311 deletions
-29
View File
@@ -1,29 +0,0 @@
name: Contributors
on:
push:
branches:
- main
release:
types:
- published
permissions:
contents: write
pull-requests: write
jobs:
contrib-readme-job:
if: github.repository == 'zhom/donutbrowser'
runs-on: ubuntu-latest
name: Automatically update the contributors list in the README
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18
View File
@@ -1,6 +1,24 @@
# Changelog
## v0.29.5 (2026-08-16)
### Features
- extension export via api
### Refactoring
- better proxy clipboard autofill
- improve ephemeral ux
- store logs and window state inside portable build
### Maintenance
- chore: version bump
- chore: update flake.nix for v0.29.4 [skip ci] (#561)
## v0.29.4 (2026-08-15)
### Refactoring
+16 -98
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64.dmg) |
Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut-0.29.4-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut-0.29.4-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut-0.29.5-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -109,103 +109,21 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
## Star History
<a href="https://www.star-history.com/?repos=zhom%2Fdonutbrowser&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
</picture>
<a href="https://gitdebt.com/zhom/donutbrowser?ref=readme">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/chart.svg?theme=dark&animate=1" />
<img alt="Cumulative GitHub stars for zhom/donutbrowser over time" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/chart.svg?theme=light&animate=1" />
</picture>
</a>
## Contributors
<!-- readme: collaborators,contributors -start -->
<table>
<tbody>
<tr>
<td align="center">
<a href="https://github.com/zhom">
<img src="https://avatars.githubusercontent.com/u/2717306?v=4" width="100;" alt="zhom"/>
<br />
<sub><b>zhom</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/kalinichaa">
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="kalinichaa"/>
<br />
<sub><b>Alexander Kalinich</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/xenos1337">
<img src="https://avatars.githubusercontent.com/u/66328734?v=4" width="100;" alt="xenos1337"/>
<br />
<sub><b>xenos</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/webees">
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
<br />
<sub><b>JockLee</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/yb403">
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
<br />
<sub><b>yb403</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/huy97">
<img src="https://avatars.githubusercontent.com/u/30153437?v=4" width="100;" alt="huy97"/>
<br />
<sub><b>Huy Le</b></sub>
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
<br />
<sub><b>drunkod</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
<br />
<sub><b>Jory Severijnse</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ThiagoMafra-Integrare">
<img src="https://avatars.githubusercontent.com/u/222241596?v=4" width="100;" alt="ThiagoMafra-Integrare"/>
<br />
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/mchnkkc">
<img src="https://avatars.githubusercontent.com/u/251900355?v=4" width="100;" alt="mchnkkc"/>
<br />
<sub><b>mchnkkc</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/liasica">
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
<br />
<sub><b>liasica</b></sub>
</a>
</td>
</tr>
<tbody>
</table>
<!-- readme: collaborators,contributors -end -->
<a href="https://gitdebt.com/zhom/donutbrowser?ref=readme">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=dark&animate=1" />
<img alt="Everyone who has landed commits in zhom/donutbrowser, ranked by commit count" src="https://api.gitdebt.com/api/repos/zhom/donutbrowser/stats/contributors.svg?theme=light&animate=1" />
</picture>
</a>
## Contact
+3 -3
View File
@@ -1803,7 +1803,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.4"
version = "0.29.6"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -2889,9 +2889,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [
"atomic-waker",
"bytes",
+42 -1
View File
@@ -217,11 +217,26 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
version: prepared.version,
configJson: JSON.stringify({ geoip: false }),
});
const fingerprint = JSON.parse(sample);
const fingerprint = JSON.parse(sample.fingerprint);
assert.ok(
Object.keys(fingerprint).length >= 10,
"Wayfern returned an incomplete fingerprint",
);
// A browser with the identity API must hand back the UUID the device was
// derived from, plus the pre-edit baseline the launch path diffs against.
// Without both, the profile stores a device it cannot reproduce.
const identityCapable =
Number.parseInt(prepared.version.split(".")[0], 10) >= 151;
assert.equal(
typeof sample.identity_id === "string",
identityCapable,
"identity_id must be present exactly on browsers with the identity API",
);
assert.equal(
typeof sample.identity_baseline === "string",
identityCapable,
"identity_baseline must be present exactly on browsers with the identity API",
);
const profile = await createRealProfile(
app,
@@ -232,6 +247,13 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
assert.ok(
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
);
// Profile creation stores the identity alongside the device it derived, or
// the launch path would treat the profile as un-migrated and replace it.
assert.equal(
typeof profile.wayfern_config.identity_id === "string",
identityCapable,
"a created profile must carry the identity its device came from",
);
assert.equal(await app.invoke("check_missing_geoip_database"), true);
assert.equal(await app.invoke("is_geoip_database_available"), false);
await app.invoke("download_geoip_database");
@@ -245,6 +267,25 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
profileId: profile.id,
exitIp: "8.8.8.8",
});
// The identity is internal state that neither call above sends back.
// Losing it would silently re-mint the device on the next launch and throw
// the user's edits away with it, so both paths must carry it forward
// unchanged.
if (identityCapable) {
const stored = (await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
assert.equal(
stored.wayfern_config.identity_id,
profile.wayfern_config.identity_id,
"the identity must survive update_wayfern_config and an exit re-match",
);
assert.equal(
stored.wayfern_config.identity_baseline,
profile.wayfern_config.identity_baseline,
"the baseline must survive with the identity it describes",
);
}
// Pre-launch gate: local-only checks that must answer without starting a
// proxy, an Xray worker or the browser.
const checks = await app.invoke("get_profile_pre_launch_checks", {
+27 -2
View File
@@ -567,8 +567,24 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
);
assert.equal(assigned.response.status, 200, JSON.stringify(assigned.value));
assert.equal(assigned.value.profile.id, launchProfile.id);
// `ApiProfile` carries no `extension_group_id`, so the assignment can only
// be read back through the surface the launcher itself resolves.
// A caller that can set the assignment must be able to read it back without
// dropping to the desktop app.
assert.equal(
assigned.value.profile.extension_group_id,
launchGroupId,
"the update response must echo the assignment it just made",
);
assert.equal(
(
await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, {
token: saved.api_token,
})
).value.profile.extension_group_id,
launchGroupId,
"a fresh GET must report the assignment",
);
// Also read it back through the surface the launcher itself resolves, so
// the REST field and the launch path cannot drift apart.
const assignedGroup = () =>
app.invoke("get_extension_group_for_profile", {
profileId: launchProfile.id,
@@ -610,6 +626,15 @@ test("authenticated REST API serves its complete OpenAPI contract and CRUD lifec
200,
);
assert.equal(await assignedGroup(), null);
assert.equal(
(
await jsonRequest(`${base}/v1/profiles/${launchProfile.id}`, {
token: saved.api_token,
})
).value.profile.extension_group_id,
null,
"clearing the assignment must be visible over REST too",
);
assert.equal(
(
await jsonRequest(`${base}/v1/extension-groups/${launchGroupId}`, {
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.29.4";
releaseVersion = "0.29.5";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_amd64.AppImage";
hash = "sha256-Y9HpvwsVXQ4gg4vUMPsjtLEd4Yu8sLK/W09mngtPajI=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_amd64.AppImage";
hash = "sha256-/zOFGaHTk4S53DLZe2BXmILe/P1dlb9dT94hyvEqXcM=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.4/Donut_0.29.4_aarch64.AppImage";
hash = "sha256-duw3lDRLH7rYhOWn5SKn1bevsUIDLmXEs+wnyeiNt1c=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.5/Donut_0.29.5_aarch64.AppImage";
hash = "sha256-w2xibn/GZq681vZDj9pYNOrwhnqxX0POhmBO8XMFb+c=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.29.5",
"version": "0.29.6",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
+3 -3
View File
@@ -1815,7 +1815,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.5"
version = "0.29.6"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -2827,9 +2827,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.15"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228"
dependencies = [
"atomic-waker",
"bytes",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.29.5"
version = "0.29.6"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+1 -1
View File
@@ -188,7 +188,7 @@ fn generate_tray_icons() {
// macOS will automatically handle light/dark mode by inverting the icon
// For template icons: RGB should be 0,0,0 (black) and alpha controls visibility
let data = pixmap.data_mut();
for pixel in data.chunks_exact_mut(4) {
for pixel in data.as_chunks_mut::<4>().0 {
// Keep the original alpha (shows where icon content is)
// but make the color black for template icon format
pixel[0] = 0; // R
+85 -21
View File
@@ -458,38 +458,91 @@ impl BrowserRunner {
wayfern_config.proxy
);
// Check if we need to generate a new fingerprint on every launch
// Check if we need to generate a device for this launch.
//
// Two cases share the block: the user asked for a fresh device on every
// launch, or the profile stores none at all. The second is how a clone
// arrives here — cloning clears the fingerprint and the identity so the
// clone gets an independent device instead of the browser's default —
// and it also covers any profile that reached disk without one, which
// used to launch on whatever device the browser drew for itself.
//
// A profile that ALREADY stores a device keeps it across a browser
// upgrade: nothing here mints a replacement, and its stored payload is
// what the launch applies. The one thing that does replace a stored
// device is the user asking for it - `randomize_fingerprint_on_launch`,
// tested immediately below - which is a deliberate per-profile setting
// and not a consequence of the version.
let mut updated_profile = profile.clone();
if wayfern_config.randomize_fingerprint_on_launch == Some(true) {
log::info!(
"Generating random fingerprint for Wayfern profile: {}",
profile.name
);
let randomize_requested = wayfern_config.randomize_fingerprint_on_launch == Some(true);
let needs_device = wayfern_config.fingerprint.is_none();
if randomize_requested || needs_device {
if needs_device && !randomize_requested {
log::info!(
"No stored device for Wayfern profile {}; generating one",
profile.name
);
} else {
log::info!(
"Generating random fingerprint for Wayfern profile: {}",
profile.name
);
}
// Create a config copy without the existing fingerprint to force generation of a new one
let mut config_for_generation = wayfern_config.clone();
config_for_generation.fingerprint = None;
// Generate a new fingerprint
let (new_fingerprint, geolocation_applied) = self
// A failed generation fails the launch on purpose: continuing would
// start the browser on whatever device it drew for itself, unmanaged
// and unrecorded, while the UI still reports a successful launch. For
// an anti-detect product a silently wrong device is worse than no
// launch at all, because nothing tells the user to stop using it.
//
// Structured rather than prose, because the most common failure is the
// browser refusing a generation once the account's hourly quota is
// spent. That has to reach the user as an explanation; a raw CDP string
// is not one, and the frontend only translates a coded error.
let generated = self
.wayfern_manager
.generate_fingerprint_config(&app_handle, profile, &config_for_generation)
.await
.map_err(|e| format!("Failed to generate random fingerprint: {e}"))?;
.map_err(|e| {
let detail = e.to_string();
// BOTH refusal texts, because this path serves BOTH releases. 151
// says "Fingerprint generation limit reached for this account.";
// the shipped 150 browser says "Too many profiles are being
// created." Matching only the 151 wording leaves a quota-blocked
// 150 user staring at a raw CDP string, which is the exact defect
// this mapping exists to remove.
if detail.contains("generation limit reached") || detail.contains("Too many profiles") {
crate::backend_error_with_detail("WAYFERN_GENERATION_LIMIT_REACHED", detail)
} else {
crate::backend_error_with_detail("WAYFERN_FINGERPRINT_GENERATION_FAILED", detail)
}
})?;
let geolocation_applied = generated.geolocation_applied;
log::info!(
"New fingerprint generated, length: {} chars",
new_fingerprint.len()
"New fingerprint generated, length: {} chars, identity: {:?}",
generated.fingerprint.len(),
generated.identity_id
);
// Update the config with the new fingerprint for launching
wayfern_config.fingerprint = Some(new_fingerprint.clone());
wayfern_config.fingerprint = Some(generated.fingerprint.clone());
wayfern_config.identity_id = generated.identity_id.clone();
wayfern_config.identity_baseline = generated.identity_baseline.clone();
// Save the updated fingerprint to the profile so it persists.
let mut updated_wayfern_config = updated_profile.wayfern_config.clone().unwrap_or_default();
updated_wayfern_config.fingerprint = Some(new_fingerprint);
updated_wayfern_config.fingerprint = Some(generated.fingerprint);
updated_wayfern_config.identity_id = generated.identity_id;
updated_wayfern_config.identity_baseline = generated.identity_baseline;
// Preserve the randomize flag so it persists across launches
updated_wayfern_config.randomize_fingerprint_on_launch = Some(true);
updated_wayfern_config.randomize_fingerprint_on_launch =
wayfern_config.randomize_fingerprint_on_launch;
// Preserve the OS setting so it's used for future fingerprint generation
if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone();
@@ -601,7 +654,11 @@ impl BrowserRunner {
)
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("Failed to launch Wayfern: {e}").into()
// A refused apply reports itself as a structured error so the dialog
// can name the cause. Prefixing it would put English in front of the
// JSON the frontend parses, and the whole thing would reach the user
// as raw machine output.
crate::wrap_backend_error(e, "Failed to launch Wayfern").into()
})?;
// Get the process ID from launch result
@@ -653,20 +710,27 @@ impl BrowserRunner {
guard.worker_id = None;
}
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
// applied, which may be UPGRADED from the stored one (e.g. when the
// stored fingerprint targets an older browser version). Persist it so the
// next launch starts from the upgraded value — saved below via
// The apply command echoes back the device the browser actually used,
// which may differ from the stored one. Persist it so the next launch
// starts from that value — saved below via
// save_process_info(&updated_profile).
if let Some(used_fp) = wayfern_result.used_fingerprint.clone() {
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) {
let baseline_changed = wayfern_result.used_identity_baseline.is_some()
&& cfg.identity_baseline != wayfern_result.used_identity_baseline;
if cfg.fingerprint.as_deref() != Some(used_fp.as_str()) || baseline_changed {
log::info!(
"Persisting upgraded fingerprint from Wayfern.setFingerprint for profile: {} (len {})",
"Persisting applied fingerprint echoed by Wayfern for profile: {} (len {})",
profile.name,
used_fp.len()
);
cfg.fingerprint = Some(used_fp);
// The baseline must move with the fingerprint it was computed
// against, or the next launch diffs the two apart and invents
// overrides the user never asked for.
if let Some(baseline) = wayfern_result.used_identity_baseline.clone() {
cfg.identity_baseline = Some(baseline);
}
updated_profile.wayfern_config = Some(cfg);
}
}
+20 -2
View File
@@ -1263,13 +1263,27 @@ async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
)
}
/// What the fingerprint form gets back from `generate_sample_fingerprint`.
///
/// The identity fields are `None` on a browser without the identity API. They
/// have to travel with the fingerprint rather than be re-derived later: the
/// form writes all three into the profile's Wayfern config in one edit, and a
/// fingerprint stored without its identity is one the launch path would throw
/// away and mint again.
#[derive(serde::Serialize)]
struct SampleFingerprint {
fingerprint: String,
identity_id: Option<String>,
identity_baseline: Option<String>,
}
#[tauri::command]
async fn generate_sample_fingerprint(
app_handle: tauri::AppHandle,
browser: String,
version: String,
config_json: String,
) -> Result<String, String> {
) -> Result<SampleFingerprint, String> {
let temp_profile = crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "temp_fingerprint_gen".to_string(),
@@ -1309,7 +1323,11 @@ async fn generate_sample_fingerprint(
manager
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
.await
.map(|(fingerprint, _geolocation_applied)| fingerprint)
.map(|generated| SampleFingerprint {
fingerprint: generated.fingerprint,
identity_id: generated.identity_id,
identity_baseline: generated.identity_baseline,
})
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
} else {
Err(format!(
+56 -4
View File
@@ -223,9 +223,14 @@ impl ProfileManager {
.generate_fingerprint_config(app_handle, &temp_profile, &config)
.await
{
Ok((generated_fingerprint, geo_applied)) => {
config.fingerprint = Some(generated_fingerprint);
geolocation_applied = geo_applied;
Ok(generated) => {
config.fingerprint = Some(generated.fingerprint);
// Set together with the fingerprint they describe. A profile that
// stored one without the other would either lose reproducibility or
// diff its whole device into overrides on the next launch.
config.identity_id = generated.identity_id;
config.identity_baseline = generated.identity_baseline;
geolocation_applied = generated.geolocation_applied;
log::info!("Successfully generated fingerprint for Wayfern profile: {name}");
}
Err(e) => {
@@ -628,6 +633,36 @@ impl ProfileManager {
return Err(format!("Browser version {version} is not downloaded").into());
}
// A move back to a version without the identity API cannot carry an
// identity-backed device with it, and leaving it in place is worse than
// dropping it: the older browser would splice it onto a freshly drawn
// device and persist the result, which then fails on every later launch.
// Clearing `fingerprint` makes the next launch generate a fresh one;
// `geo_proxy_signature` goes with it because it certifies location fields
// of a fingerprint that no longer exists.
//
// Do NOT refuse the version change: `consolidate_browser_versions` only
// reaches this direction once the newer binary is already gone from disk,
// so refusing would strand the profile pointing at a missing executable.
// Every other direction falls through untouched.
let target_speaks_identity_api = crate::wayfern_manager::supports_identity_api(version);
if let Some(cfg) = profile
.wayfern_config
.as_mut()
.filter(|c| c.identity_id.is_some() && !target_speaks_identity_api)
{
cfg.identity_id = None;
cfg.identity_baseline = None;
cfg.fingerprint = None;
cfg.geo_proxy_signature = None;
log::warn!(
"Profile '{}' moved from Wayfern {} to {}. Its stored fingerprint cannot be used there, so it was cleared and a fresh one will be generated on the next launch.",
profile.name,
profile.version,
version
);
}
// Update version
profile.version = version.to_string();
@@ -1101,6 +1136,11 @@ impl ProfileManager {
// isolation between a clone and its source.
if let Some(cfg) = new_profile.wayfern_config.as_mut() {
cfg.fingerprint = None;
// The identity is a stronger link than the payload: the same UUID rebuilds
// the SAME device on any version, so a clone that kept it would stay
// byte-identical to its source forever, not just until the next upgrade.
cfg.identity_id = None;
cfg.identity_baseline = None;
}
self.save_profile(&new_profile)?;
@@ -1116,7 +1156,7 @@ impl ProfileManager {
&self,
app_handle: tauri::AppHandle,
profile_id: &str,
config: WayfernConfig,
mut config: WayfernConfig,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// Find the profile by ID
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
@@ -1148,6 +1188,18 @@ impl ProfileManager {
);
}
// The identity is internal state, so a caller that edits the fingerprint
// through the API or MCP will not send it back. Dropping it would silently
// re-mint the device on the next launch and throw the edit away with it,
// which is the opposite of what an override is for. Carry it forward unless
// the caller either supplied its own or cleared the fingerprint outright.
if config.identity_id.is_none() && config.fingerprint.is_some() {
if let Some(stored) = profile.wayfern_config.as_ref() {
config.identity_id = stored.identity_id.clone();
config.identity_baseline = stored.identity_baseline.clone();
}
}
// Update the Wayfern configuration
profile.wayfern_config = Some(config);
+5 -1
View File
@@ -988,7 +988,11 @@ impl ProfileImporter {
{
// geo_proxy_signature is intentionally left unset here: the first
// launch's signature-mismatch refresh verifies the location either way.
Ok((fp, _geolocation_applied)) => config.fingerprint = Some(fp),
Ok(generated) => {
config.fingerprint = Some(generated.fingerprint);
config.identity_id = generated.identity_id;
config.identity_baseline = generated.identity_baseline;
}
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.29.5",
"version": "0.29.6",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+91 -52
View File
@@ -41,6 +41,14 @@ const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => {
return config.randomize_fingerprint_on_launch === true;
};
/** What `generate_sample_fingerprint` returns. Identity fields are null on a
* browser version that predates the Wayfern identity API. */
interface GeneratedFingerprint {
fingerprint: string;
identity_id: string | null;
identity_baseline: string | null;
}
const getCurrentOS = (): WayfernOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
@@ -83,12 +91,23 @@ export function WayfernConfigForm({
setIsGeneratingFingerprint(true);
try {
const configJson = JSON.stringify(config);
const result = await invoke<string>("generate_sample_fingerprint", {
browser: profileBrowser ?? "wayfern",
version: profileVersion,
configJson,
});
onConfigChange("fingerprint", result);
const result = await invoke<GeneratedFingerprint>(
"generate_sample_fingerprint",
{
browser: profileBrowser ?? "wayfern",
version: profileVersion,
configJson,
},
);
onConfigChange("fingerprint", result.fingerprint);
// The identity travels with the fingerprint it produced. Storing one
// without the other leaves a device the launch path cannot reproduce, so
// it would be discarded and re-minted on the next launch.
onConfigChange("identity_id", result.identity_id ?? undefined);
onConfigChange(
"identity_baseline",
result.identity_baseline ?? undefined,
);
} catch (error) {
console.error("Failed to generate fingerprint:", error);
} finally {
@@ -171,6 +190,12 @@ export function WayfernConfigForm({
const isEditingDisabled = isFingerprintEditingDisabled(config) || readOnly;
/** For an identity-backed profile these fields have no stored value to show,
* and editing them by hand produces an inconsistent device. Hidden rather
* than rendered blank-and-disabled, because a blank box reads as data loss
* whereas their absence is the truth. */
const isIdentityDerived = config.identity_id != null;
const renderAdvancedForm = () => (
<div className="space-y-6">
{/* Operating System Selection */}
@@ -894,21 +919,23 @@ export function WayfernConfigForm({
</div>
{/* WebGL Parameters (JSON) */}
<div className="space-y-3">
<Label>{t("fingerprint.webglParametersJson")}</Label>
<Textarea
value={fingerprintConfig.webglParameters ?? ""}
onChange={(e) => {
updateFingerprintConfig(
"webglParameters",
e.target.value || undefined,
);
}}
placeholder='{"7936": "Intel", "7937": "Intel(R) HD Graphics"}'
className="font-mono text-sm"
rows={4}
/>
</div>
{!isIdentityDerived && (
<div className="space-y-3">
<Label>{t("fingerprint.webglParametersJson")}</Label>
<Textarea
value={fingerprintConfig.webglParameters ?? ""}
onChange={(e) => {
updateFingerprintConfig(
"webglParameters",
e.target.value || undefined,
);
}}
placeholder='{"7936": "Intel", "7937": "Intel(R) HD Graphics"}'
className="font-mono text-sm"
rows={4}
/>
</div>
)}
{/* Canvas Noise Seed */}
<div className="space-y-3">
@@ -1040,37 +1067,49 @@ export function WayfernConfigForm({
{/* Vendor Info */}
<div className="space-y-3">
<Label>{t("fingerprint.vendorInfo")}</Label>
<div className="grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
<Input
id="vendor"
value={fingerprintConfig.vendor ?? ""}
onChange={(e) => {
updateFingerprintConfig(
"vendor",
e.target.value || undefined,
);
}}
placeholder={t("common.placeholders.example", {
value: "Google Inc.",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vendor-sub">{t("fingerprint.vendorSub")}</Label>
<Input
id="vendor-sub"
value={fingerprintConfig.vendorSub ?? ""}
onChange={(e) => {
updateFingerprintConfig(
"vendorSub",
e.target.value || undefined,
);
}}
placeholder=""
/>
</div>
<div
className={
isIdentityDerived
? "grid grid-cols-1 gap-4"
: "grid grid-cols-1 gap-4 @md:grid-cols-2 @2xl:grid-cols-3"
}
>
{!isIdentityDerived && (
<>
<div className="space-y-2">
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
<Input
id="vendor"
value={fingerprintConfig.vendor ?? ""}
onChange={(e) => {
updateFingerprintConfig(
"vendor",
e.target.value || undefined,
);
}}
placeholder={t("common.placeholders.example", {
value: "Google Inc.",
})}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vendor-sub">
{t("fingerprint.vendorSub")}
</Label>
<Input
id="vendor-sub"
value={fingerprintConfig.vendorSub ?? ""}
onChange={(e) => {
updateFingerprintConfig(
"vendorSub",
e.target.value || undefined,
);
}}
placeholder=""
/>
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="product-sub">
{t("fingerprint.productSub")}
+6 -1
View File
@@ -994,6 +994,7 @@
"autoLocationDescription": "Automatically configure location information based on proxy configuration or your connection if no proxy provided",
"editingDisabledRunning": "Fingerprint editing is disabled because the profile is currently running. Stop the profile to make changes.",
"editingDisabledRandomized": "Fingerprint editing is disabled because random fingerprint generation is enabled. Disable the option above to manually edit the fingerprint configuration.",
"derivedFromIdentity": "Derived from this profile's identity and not editable.",
"advancedWarning": "Warning: Only edit these parameters if you know what you're doing. Incorrect values may break websites, make them detect you, and lead to hard-to-debug bugs.",
"basicWarning": "Warning: Only edit these parameters if you know what you're doing.",
"automatic": "Automatic",
@@ -1977,7 +1978,11 @@
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data.",
"importSourceNotChromium": "This folder is not a Chromium browser profile",
"importSourceNotChromiumNamed": "{{family}} profiles cannot be imported; only Chromium-based browsers are supported",
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway"
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway",
"wayfernFingerprintApplyFailed": "Could not apply this profile's fingerprint, so the browser was not started. {{detail}}",
"wayfernFingerprintGenerationFailed": "Could not create a fingerprint for this profile. {{detail}}",
"wayfernGenerationLimitReached": "The fingerprint generation limit for this account has been reached. New fingerprints are unavailable for up to 24 hours. This limit applies to this computer, not to one profile.",
"wayfernCrossOsRequiresPlan": "This profile claims {{detail}}, which needs a paid plan and an active sign-in. Sign in or switch the profile to your own operating system."
},
"rail": {
"profiles": "Profiles",
+29
View File
@@ -128,6 +128,21 @@ export type BackendErrorCode =
| "COOKIE_BOT_REQUIRES_PROXY"
| "COOKIE_BOT_TOUCH_FINGERPRINT_UNSUPPORTED"
| "FINGERPRINT_EXIT_MISMATCH"
// The launch refuses instead of opening a window on an unmanaged device: a
// silent fallback would leave the user browsing a random fingerprint while
// the UI reported success, which is the worst failure an anti-detect product
// can have. `detail` carries the underlying browser error for support.
| "WAYFERN_FINGERPRINT_APPLY_FAILED"
| "WAYFERN_FINGERPRINT_GENERATION_FAILED"
// Its own code rather than a generation failure: the browser's quota block is
// account-wide and lasts 24 hours, so "try again" is wrong advice, and a user
// whose every profile refuses at once has to be told this is one limit and
// not a broken install.
| "WAYFERN_GENERATION_LIMIT_REACHED"
// A cross-OS claim needs a signed plan token, so an expired or offline
// session cannot apply one. Distinct from the generic apply failure because
// signing in again is the fix; `detail` names the claimed OS.
| "WAYFERN_CROSS_OS_REQUIRES_PLAN"
| "LAUNCH_CONSENT_EXPIRED"
| "VPN_WORKER_START_FAILED"
| "EXIT_PROBE_FAILED"
@@ -472,6 +487,20 @@ export function translateBackendError(t: TFunction, err: unknown): string {
// room for one sentence.
case "FINGERPRINT_EXIT_MISMATCH":
return t("backendErrors.fingerprintExitMismatch");
case "WAYFERN_FINGERPRINT_APPLY_FAILED":
return t("backendErrors.wayfernFingerprintApplyFailed", {
detail: parsed.params?.detail ?? "",
});
case "WAYFERN_FINGERPRINT_GENERATION_FAILED":
return t("backendErrors.wayfernFingerprintGenerationFailed", {
detail: parsed.params?.detail ?? "",
});
case "WAYFERN_GENERATION_LIMIT_REACHED":
return t("backendErrors.wayfernGenerationLimitReached");
case "WAYFERN_CROSS_OS_REQUIRES_PLAN":
return t("backendErrors.wayfernCrossOsRequiresPlan", {
detail: parsed.params?.detail ?? "",
});
case "LAUNCH_CONSENT_EXPIRED":
return t("backendErrors.launchConsentExpired");
case "VPN_WORKER_START_FAILED":
+2
View File
@@ -395,6 +395,8 @@ export interface WayfernConfig {
randomize_fingerprint_on_launch?: boolean; // Generate new fingerprint on every launch
os?: WayfernOS; // Operating system for fingerprint generation
geo_proxy_signature?: string; // Internal: routing the fingerprint's location was computed for
identity_id?: string; // Internal: UUID the device is derived from on browsers with the identity API
identity_baseline?: string; // Internal: derived fingerprint before edits, diffed to recover overrides
}
// Wayfern fingerprint config - matches the C++ FingerprintData structure