Compare commits

..
Author SHA1 Message Date
zhom e20c454ff2 chore: update pnpm 2026-08-12 14:36:08 +04:00
zhom 57dbf51372 chore: version bump 2026-08-12 14:35:57 +04:00
zhom ad1aa6cc5a refactor: minor improvement 2026-08-12 14:35:57 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
01e4afb687 chore: update flake.nix for v0.29.2 [skip ci] (#552)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-10 21:56:31 +00:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
4e52d9bc09 docs: update CHANGELOG.md and README.md for v0.29.2 [skip ci] (#551)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-10 21:56:17 +00:00
20 changed files with 162 additions and 43 deletions
+15
View File
@@ -1,6 +1,21 @@
# Changelog
## v0.29.2 (2026-08-10)
### Refactoring
- cleanup sync
- profile imports
### Maintenance
- chore: linting
- chore: version bump
- chore: linting
- chore: update flake.nix for v0.29.1 [skip ci] (#546)
## v0.29.1 (2026-08-08)
### Bug Fixes
+5 -5
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64.dmg) |
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_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.1/Donut_0.29.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64-portable.zip)
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_x64-portable.zip)
### Linux
| Format | x86_64 | ARM64 |
|---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut-0.29.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut-0.29.1-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.AppImage) |
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut-0.29.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut-0.29.2-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.29.1";
releaseVersion = "0.29.2";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.AppImage";
hash = "sha256-8EI1aUe0nuW1JsYoRD+PhIDtGbZ1SiNqKBAhFRE/K1w=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.AppImage";
hash = "sha256-ExvjO1f5AkzzRr39LIBQbG2bavQxCHpyOuE+h16VHkU=";
}
else if system == "aarch64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.AppImage";
hash = "sha256-MLRZFU1y5dbgPhD7okCHYbC8pGrG79GT70kdD3yvqKk=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_aarch64.AppImage";
hash = "sha256-rcTyIJ8hehrS6Q4yEkEs6kZz14Jt0A1Qpsss5dIwepU=";
}
else
null;
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.29.2",
"version": "0.29.3",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
@@ -111,7 +111,7 @@
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3"
},
"packageManager": "pnpm@11.20.0",
"packageManager": "pnpm@11.21.0",
"lint-staged": {
"**/*.{js,jsx,ts,tsx,json,css}": [
"biome check --fix"
+1 -1
View File
@@ -1815,7 +1815,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.2"
version = "0.29.3"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.29.2"
version = "0.29.3"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+116 -13
View File
@@ -802,11 +802,32 @@ impl CloudAuthManager {
}
/// Account is in a paid/active state. Used for the "any active plan" gates
/// (sync token, wayfern token); per-feature access uses the capability helpers.
/// (sync token); per-feature access uses the capability helpers.
pub async fn has_active_paid_subscription(&self) -> bool {
self.entitlements().await.map(|e| e.active).unwrap_or(false)
}
/// Whether this session's plan entitles it to a Wayfern automation token.
///
/// The token IS the automation entitlement, so this is `browser_automation`
/// and NOT `has_active_paid_subscription`. Gating the mint on "any active
/// plan" meant a Solo account — active, paying, and deliberately sold without
/// automation or fingerprint editing — asked for a token on every startup,
/// every login and every 10-hour refresh, collected a 403 each time, and got
/// the "account temporarily restricted" toast that belongs to the
/// multiple-device rule. Nothing was restricted; the plan simply does not
/// include the feature.
///
/// Reads the entitlement directly rather than going through
/// `can_use_browser_automation`, whose e2e override would send the browser
/// suite off to the live API for a token it already has as a test value.
pub async fn is_entitled_to_wayfern_token(&self) -> bool {
self
.entitlements()
.await
.is_some_and(|e| e.active && e.browser_automation)
}
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
pub fn has_active_paid_subscription_sync(&self) -> bool {
match self.state.try_lock() {
@@ -1108,9 +1129,15 @@ impl CloudAuthManager {
.await
}
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
/// Request a wayfern token from the cloud API. Only succeeds for plans that
/// include browser automation.
///
/// Self-gating on purpose: every caller used to repeat the check, and the one
/// they repeated was the wrong one. A plan without automation is not an error
/// state here — it clears any stale token and reports success, because there
/// is nothing to fetch and nothing wrong.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
if !self.has_active_paid_subscription().await {
if !self.is_entitled_to_wayfern_token().await {
self.clear_wayfern_token().await;
return Ok(());
}
@@ -1137,7 +1164,11 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
return Err(format!("Wayfern token request failed ({status})"));
// The body carries WHICH rule refused: a device-family conflict or
// a plan that lacks automation. They need different handling, so
// keep the text instead of collapsing every failure to a status.
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
}
let result: WayfernTokenResponse = response
@@ -1156,12 +1187,21 @@ impl CloudAuthManager {
// A 403 rejects the entitlement without invalidating the login session.
// Clear the browser token and refresh account state before notifying UI.
if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403)");
log::warn!("Wayfern token blocked by backend (403): {e}");
self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
}
let _ = crate::events::emit_empty("wayfern-paid-blocked");
// Only the device rules produce a restriction the user can lift, and
// the toast tells them to sign other devices out — so only those may
// raise it. A plan-level refusal that slipped past the gate above
// (cached entitlements the re-fetch just corrected) must stay silent:
// telling a Solo customer they are "temporarily restricted" describes
// a lockout that does not exist and hides the real answer, which is
// that their plan does not include browser automation.
if is_device_restriction(&e) {
let _ = crate::events::emit_empty("wayfern-paid-blocked");
}
}
return Err(e);
}
@@ -1248,15 +1288,13 @@ impl CloudAuthManager {
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop).
// request_wayfern_token owns the entitlement check and clears the cached
// token when the plan doesn't include automation.
if wayfern_refresh_counter >= 60 {
wayfern_refresh_counter = 0;
if CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
} else {
CLOUD_AUTH.clear_wayfern_token().await;
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
}
}
@@ -1265,6 +1303,17 @@ impl CloudAuthManager {
}
}
/// Whether a rejected wayfern-token request was refused by one of the
/// device-family rules (automation is pinned to the primary desktop session)
/// rather than by the plan's capabilities.
///
/// Matches on the backend's message because that is the only thing that
/// distinguishes them: both arrive as a bare 403. Only these two are a state
/// the user can clear themselves, which is what the toast asks them to do.
fn is_device_restriction(error: &str) -> bool {
error.contains("primary device") || error.contains("requires the desktop app")
}
fn solve_pow(prefix: &str, difficulty: u32) -> Option<String> {
if difficulty == 0 || difficulty > 32 {
return None;
@@ -1586,3 +1635,57 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn active_solo() -> Entitlements {
derive_entitlements("solo", Some("monthly"), "active", 20)
}
#[test]
fn solo_is_active_without_browser_automation() {
let solo = active_solo();
assert!(solo.active, "solo is a paid, active plan");
assert!(solo.cloud_backup, "solo buys cloud profile backups");
assert!(solo.cookie_bot, "solo buys the nightly cookie bot");
assert!(
!solo.browser_automation,
"solo is sold without browser automation"
);
assert!(
!solo.cross_os_fingerprints,
"solo is sold without fingerprint editing"
);
}
#[test]
fn wayfern_token_is_gated_on_automation_not_on_being_paid() {
// The regression this guards: gating the mint on `active` asked for a token
// on behalf of a Solo account, which the backend answers with a 403.
let solo = active_solo();
assert!(!(solo.active && solo.browser_automation));
let pro = derive_entitlements("pro", Some("monthly"), "active", 50);
assert!(pro.active && pro.browser_automation);
}
#[test]
fn only_the_device_rules_read_as_a_restriction() {
assert!(is_device_restriction(
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation is restricted to your primary device. Log out other devices to use it here.\",\"statusCode\":403}"
));
assert!(is_device_restriction(
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation requires the desktop app. Open Donut Browser and try again.\",\"statusCode\":403}"
));
// A plan-level refusal is not a restriction, and must not raise the toast
// that tells the user to sign other devices out.
assert!(!is_device_restriction(
"Wayfern token request failed (403 Forbidden): {\"message\":\"Browser automation subscription required\",\"statusCode\":403}"
));
assert!(!is_device_restriction(
"Wayfern token request failed (500 Internal Server Error): "
));
}
}
+2 -4
View File
@@ -2622,10 +2622,8 @@ pub fn run_with_builder(
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
};
let wayfern_fut = async {
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to request wayfern token on startup: {e}");
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
+4 -1
View File
@@ -883,9 +883,12 @@ impl WayfernManager {
args.push(format!("--wayfern-profile-color={profile_color}"));
let mut wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
// Waiting is only meaningful for a plan a token can actually be minted for.
// On "any active plan" this stalled every Solo launch by the full three
// seconds waiting for a token the backend will never issue to them.
if wayfern_token.is_none()
&& crate::cloud_auth::CLOUD_AUTH
.has_active_paid_subscription()
.is_entitled_to_wayfern_token()
.await
{
// Brief wait for the background token fetch — when the API is healthy
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.29.2",
"version": "0.29.3",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+1 -1
View File
@@ -2155,7 +2155,7 @@
},
"wayfernBlocked": {
"title": "Browser automation paused",
"description": "Your account was temporarily restricted from Pro browser features, usually from signing in on multiple devices at once. Sign out of other devices, then relaunch the profile to restore it."
"description": "Browser automation runs on one device at a time, and another signed-in device is currently using it. Sign out there, then relaunch the profile."
},
"wayfernConfigDialog": {
"titleView": "View Fingerprint Settings - {{name}} ({{browser}})",
+1 -1
View File
@@ -2162,7 +2162,7 @@
},
"wayfernBlocked": {
"title": "Automatización del navegador en pausa",
"description": "Tu cuenta fue restringida temporalmente de las funciones Pro del navegador, normalmente por iniciar sesión en varios dispositivos a la vez. Cierra sesión en los demás dispositivos y vuelve a iniciar el perfil para restaurarla."
"description": "La automatización del navegador funciona en un solo dispositivo a la vez y otro dispositivo con la sesión abierta la está usando. Cierra la sesión allí y vuelve a iniciar el perfil."
},
"wayfernConfigDialog": {
"titleView": "Ver configuración de huella - {{name}} ({{browser}})",
+1 -1
View File
@@ -2162,7 +2162,7 @@
},
"wayfernBlocked": {
"title": "Automatisation du navigateur en pause",
"description": "Votre compte a été temporairement privé des fonctionnalités Pro du navigateur, généralement à cause d'une connexion sur plusieurs appareils à la fois. Déconnectez-vous des autres appareils, puis relancez le profil pour la rétablir."
"description": "L'automatisation du navigateur fonctionne sur un seul appareil à la fois, et un autre appareil connecté l'utilise actuellement. Déconnectez-vous sur cet appareil, puis relancez le profil."
},
"wayfernConfigDialog": {
"titleView": "Voir les paramètres d'empreinte - {{name}} ({{browser}})",
+1 -1
View File
@@ -2155,7 +2155,7 @@
},
"wayfernBlocked": {
"title": "ブラウザの自動化が一時停止しました",
"description": "通常は複数のデバイスで同時にサインインしたことが原因で、アカウントのProブラウザ機能が一時的に制限されました。他のデバイスからサインアウトし、プロファイルを再起動すると復元されます。"
"description": "ブラウザの自動化は同時に1台のデバイスでのみ利用できます。現在、サインイン中の別のデバイスが使用しています。そのデバイスからサインアウトし、プロファイルを再起動してください。"
},
"wayfernConfigDialog": {
"titleView": "フィンガープリント設定を表示 - {{name}} ({{browser}})",
+1 -1
View File
@@ -2155,7 +2155,7 @@
},
"wayfernBlocked": {
"title": "브라우저 자동화가 일시 중지됨",
"description": "보통 여러 기기에서 동시에 로그인하여 계정의 Pro 브라우저 기능이 일시적으로 제한되었습니다. 다른 기기에서 로그아웃한 후 프로필을 다시 실행하면 복원됩니다."
"description": "브라우저 자동화는 한 번에 기기 한 대에서만 실행되며, 현재 로그인된 다른 기기가 사용 중입니다. 해당 기기에서 로그아웃한 후 프로필을 다시 실행하세요."
},
"wayfernConfigDialog": {
"titleView": "핑거프린트 설정 보기 - {{name}} ({{browser}})",
+1 -1
View File
@@ -2162,7 +2162,7 @@
},
"wayfernBlocked": {
"title": "Automação do navegador pausada",
"description": "Sua conta foi temporariamente restringida dos recursos Pro do navegador, geralmente por entrar em vários dispositivos ao mesmo tempo. Saia dos outros dispositivos e reinicie o perfil para restaurá-la."
"description": "A automação do navegador funciona em um dispositivo por vez e outro dispositivo com a sessão ativa está usando-a. Saia da conta nesse dispositivo e inicie o perfil novamente."
},
"wayfernConfigDialog": {
"titleView": "Ver configurações de impressão digital - {{name}} ({{browser}})",
+1 -1
View File
@@ -2169,7 +2169,7 @@
},
"wayfernBlocked": {
"title": "Автоматизация браузера приостановлена",
"description": "Доступ вашей учётной записи к Pro-функциям браузера временно ограничен — обычно из-за входа сразу на нескольких устройствах. Выйдите из аккаунта на других устройствах и перезапустите профиль, чтобы восстановить доступ."
"description": "Автоматизация браузера работает только на одном устройстве одновременно, и сейчас её использует другое устройство с активным входом. Выйдите из аккаунта на нём и запустите профиль снова."
},
"wayfernConfigDialog": {
"titleView": "Просмотр настроек отпечатка - {{name}} ({{browser}})",
+1 -1
View File
@@ -2155,7 +2155,7 @@
},
"wayfernBlocked": {
"title": "Tarayıcı otomasyonu duraklatıldı",
"description": "Hesabınız, genellikle aynı anda birden fazla cihazda oturum açmaktan kaynaklanan bir nedenle Pro tarayıcı özelliklerinden geçici olarak kısıtlandı. Diğer cihazlardaki oturumları kapatın, ardından geri yüklemek için profili yeniden başlatın."
"description": "Tarayıcı otomasyonu aynı anda yalnızca tek bir cihazda çalışır ve şu anda oturum açmış başka bir cihaz tarafından kullanılıyor. O cihazdan çıkış yapın, ardından profili yeniden başlatın."
},
"wayfernConfigDialog": {
"titleView": "Parmak İzi Ayarlarını Görüntüle - {{name}} ({{browser}})",
+1 -1
View File
@@ -2155,7 +2155,7 @@
},
"wayfernBlocked": {
"title": "Tự động hóa trình duyệt đã tạm dừng",
"description": "Tài khoản của bạn tạm thời bị hạn chế các tính năng Pro của trình duyệt, thường do đăng nhập trên nhiều thiết bị cùng lúc. Hãy đăng xuất khỏi các thiết bị khác rồi khởi chạy lại profile để khôi phục."
"description": "Tự động hóa trình duyệt chỉ chạy trên một thiết bị tại một thời điểm và một thiết bị khác đang đăng nhập hiện đang sử dụng nó. Hãy đăng xuất trên thiết bị đó, rồi khởi chạy lại cấu hình."
},
"wayfernConfigDialog": {
"titleView": "Xem cài đặt vân tay - {{name}} ({{browser}})",
+1 -1
View File
@@ -2155,7 +2155,7 @@
},
"wayfernBlocked": {
"title": "浏览器自动化已暂停",
"description": "您的账户暂时被限制使用 Pro 浏览器功能,通常是因为同时在多台设备上登录。请退出其他设备的登录,然后重新启动配置文件即可恢复。"
"description": "浏览器自动化一次只能在一台设备上运行,目前有另一台已登录的设备正在使用。请在该设备上退出登录,然后重新启动配置文件。"
},
"wayfernConfigDialog": {
"titleView": "查看指纹设置 - {{name}} ({{browser}})",