Compare commits

..
12 Commits
Author SHA1 Message Date
zhom 7b09260926 chore: version bump 2026-08-16 02:14:32 +04:00
zhom 927fe37cda refactor: cleanup 2026-08-16 02:09:27 +04:00
dependabot[bot] c07039e0a6 ci(deps): bump the github-actions group with 4 updates (#559)
Bumps the github-actions group with 4 updates: [anomalyco/opencode/github](https://github.com/anomalyco/opencode), [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action), [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) and [swatinem/rust-cache](https://github.com/swatinem/rust-cache).


Updates `anomalyco/opencode/github` from 1.18.14 to 1.18.17
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/65cf14df16c191f3e9684f0d9a8bae69103ced6d...02546dfc2e4515a4f90aaf9ceb3890df2ac2b479)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.8 to 2.5.0
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/9a498708959aeaef5ef730655706c5a1df1edbc2...8deb546fdb875b9996d27d4950be7312dac076a1)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.8 to 2.5.0
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/9a498708959aeaef5ef730655706c5a1df1edbc2...8deb546fdb875b9996d27d4950be7312dac076a1)

Updates `swatinem/rust-cache` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/c19371144df3bb44fab255c43d04cbc2ab54d1c4...6323deb102c322ba6fcbdcafc7e3dddab59af2b6)

---
updated-dependencies:
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-15 09:07:59 +00:00
github-actions[bot]andgithub-actions[bot] df5ece8e2d chore: update flake.nix for v0.29.3 [skip ci] (#557)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-12 12:18:14 +00:00
github-actions[bot]andgithub-actions[bot] d3734ef876 docs: update CHANGELOG.md and README.md for v0.29.3 [skip ci] (#556)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-12 12:17:55 +00:00
andy b446e20350 Merge pull request #555 from zhom/contributors-readme-action-fg8R0XTOCb
docs(contributor): contributors readme action update
2026-08-12 03:38:04 -07:00
github-actions[bot] 2ccf23eea3 docs(contributor): contrib-readme-action has updated readme 2026-08-12 10:36:47 +00:00
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]andgithub-actions[bot] 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]andgithub-actions[bot] 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
29 changed files with 378 additions and 78 deletions
+1 -1
View File
@@ -693,7 +693,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
- name: Run opencode
uses: anomalyco/opencode/github@65cf14df16c191f3e9684f0d9a8bae69103ced6d #v1.18.14
uses: anomalyco/opencode/github@02546dfc2e4515a4f90aaf9ceb3890df2ac2b479 #v1.18.17
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
scan-scheduled:
name: Scheduled Security Scan
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
@@ -58,7 +58,7 @@ jobs:
scan-pr:
name: PR Security Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
name: Security Vulnerability Scan
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
@@ -127,7 +127,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
with:
workdir: ./src-tauri
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
security-scan:
if: github.repository == 'zhom/donutbrowser'
name: Security Vulnerability Scan
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2" # v2.3.8
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@8deb546fdb875b9996d27d4950be7312dac076a1" # v2.5.0
with:
scan-args: |-
-r
@@ -134,7 +134,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config unzip xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 #v2.9.2
with:
workdir: ./src-tauri
+28
View File
@@ -1,6 +1,34 @@
# Changelog
## v0.29.3 (2026-08-12)
### Refactoring
- minor improvement
### Maintenance
- chore: update pnpm
- chore: version bump
- chore: update flake.nix for v0.29.2 [skip ci] (#552)
## 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
+8 -8
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.3/Donut_0.29.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut_0.29.3_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.3/Donut_0.29.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut_0.29.3_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.3/Donut_0.29.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut_0.29.3_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut-0.29.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut-0.29.3-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut_0.29.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut_0.29.3_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
@@ -131,10 +131,10 @@ Donut Browser is built by the people who use it, and plenty of the most useful h
</a>
</td>
<td align="center">
<a href="https://github.com/HassiyYT">
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="HassiyYT"/>
<a href="https://github.com/kalinichaa">
<img src="https://avatars.githubusercontent.com/u/81773493?v=4" width="100;" alt="kalinichaa"/>
<br />
<sub><b>Hassiy</b></sub>
<sub><b>Alexander Kalinich</b></sub>
</a>
</td>
<td align="center">
+1 -1
View File
@@ -1803,7 +1803,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.2"
version = "0.29.4"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
+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.3";
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.3/Donut_0.29.3_amd64.AppImage";
hash = "sha256-gGM9SeT7YFYV3vUnFrxbNQpJzk1hFXlfZ816R/I5KkU=";
}
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.3/Donut_0.29.3_aarch64.AppImage";
hash = "sha256-kKDCcpuW51YpwKWhear+Tgo9q8Kc0KOdcAqLW67jxgk=";
}
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.4",
"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.4"
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.4"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+100 -11
View File
@@ -56,6 +56,12 @@ pub struct ApiProfile {
/// Such a profile cannot be launched locally, and must only ever run on a
/// remote host of its own OS — Chromium profile state is OS-specific.
pub is_cross_os: bool,
/// The fingerprint operating system set at creation via `wayfern_config.os`
/// (`"windows"`, `"macos"`, `"linux"`, `"android"` or `"ios"`), or `null`
/// when the fingerprint was generated for the host. This is what the browser
/// reports to sites; `host_os` is the machine the profile was created on and
/// is a different thing.
pub fingerprint_os: Option<String>,
}
impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
@@ -83,6 +89,7 @@ impl From<&crate::profile::types::BrowserProfile> for ApiProfile {
cloud_sync_enabled: profile.is_sync_enabled(),
host_os: profile.resolved_os().map(|os| os.to_string()),
is_cross_os: profile.is_cross_os(),
fingerprint_os: profile.wayfern_config.as_ref().and_then(|c| c.os.clone()),
}
}
}
@@ -1228,15 +1235,19 @@ async fn get_profile(
/// locally (this endpoint does not download new versions); 400 if none is.
/// - Omitting the matching `wayfern_config`, or passing an
/// empty object `{}`, generates a fresh fingerprint automatically.
/// - `wayfern_config.os` picks the fingerprint OS (`"windows"`, `"macos"`,
/// `"linux"`, `"android"`, `"ios"`). Omit it to match the host. Any other
/// OS is cross-OS spoofing and needs an active Pro plan; 402 otherwise.
/// A `wayfern_config` that fails to parse is a 400, never a silent default.
#[utoipa::path(
post,
path = "/v1/profiles",
request_body = CreateProfileRequest,
responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Invalid browser, or no downloaded version available"),
(status = 400, description = "Invalid browser, invalid wayfern_config, or no downloaded version available"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Selected proxy requires payment"),
(status = 402, description = "Selected proxy requires payment, or a cross-OS fingerprint requires Pro"),
(status = 500, description = "Internal server error")
),
security(
@@ -1292,13 +1303,33 @@ async fn create_profile(
}
};
// Parse wayfern config if provided
let wayfern_config = if let Some(config) = &request.wayfern_config {
serde_json::from_value(config.clone()).ok()
} else {
None
// Parse wayfern config if provided. A malformed config is a 400, never a
// silent fallback: swallowing it here produced a host-OS profile from a
// request that explicitly asked for another OS, with a 200 and no diagnostic.
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = match &request.wayfern_config
{
Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid wayfern_config: {e}"),
)
})?),
None => None,
};
// Cross-OS fingerprints are a paid capability. The Tauri command, the
// importer and MCP each check this; REST did not, so the restriction was
// bypassable through this endpoint alone.
if !crate::cloud_auth::CLOUD_AUTH
.is_fingerprint_os_allowed(wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
.await
{
return Err((
StatusCode::PAYMENT_REQUIRED,
serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string(),
));
}
// Reject a dead/unreachable proxy or VPN before creating the profile. A 402
// (expired proxy subscription) maps to 402; anything else is a 400.
if let Err(err) =
@@ -3769,10 +3800,19 @@ async fn import_profiles_api(
State(state): State<ApiServerState>,
Json(request): Json<ImportProfilesRequest>,
) -> Result<Json<crate::profile_importer::ProfileImportBatchResult>, (StatusCode, String)> {
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> = request
.wayfern_config
.as_ref()
.and_then(|config| serde_json::from_value(config.clone()).ok());
// A malformed config is a 400. Dropping it silently also dropped the `os`
// it carried, which made `is_fingerprint_os_allowed(None)` return true and
// bypassed the Pro gate below while generating host-OS fingerprints.
let wayfern_config: Option<crate::wayfern_manager::WayfernConfig> =
match request.wayfern_config.as_ref() {
Some(config) => Some(serde_json::from_value(config.clone()).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Invalid wayfern_config: {e}"),
)
})?),
None => None,
};
// The Pro gate for fingerprint OS spoofing lives inside import_profiles, so
// every surface inherits it; manager_error_response maps the code to 402.
@@ -4148,6 +4188,55 @@ mod tests {
assert!(parsed.wayfern_config.is_none());
}
#[test]
fn wayfern_config_os_survives_the_untyped_request_field() {
// `wayfern_config` arrives as an untyped Value and is only turned into a
// WayfernConfig inside the handler. That second hop is where an `os` used
// to be lost, so assert it round-trips.
let json = r#"{"name": "p", "browser": "wayfern", "wayfern_config": {"os": "android"}}"#;
let parsed: CreateProfileRequest = serde_json::from_str(json).expect("body must parse");
let config: crate::wayfern_manager::WayfernConfig =
serde_json::from_value(parsed.wayfern_config.expect("config present"))
.expect("a well-formed config must parse");
assert_eq!(config.os.as_deref(), Some("android"));
}
#[test]
fn malformed_wayfern_config_is_an_error_not_a_default() {
// `fingerprint` is a JSON-encoded string, so passing an object fails to
// parse. The handler must surface that as a 400: previously `.ok()` threw
// the whole config away, dropping the caller's `os` with it and returning
// a host-OS profile with 200 and no diagnostic.
let json = r#"{"os": "android", "fingerprint": {"platform": "Linux armv81"}}"#;
let value: serde_json::Value = serde_json::from_str(json).expect("value parses");
let parsed = serde_json::from_value::<crate::wayfern_manager::WayfernConfig>(value);
assert!(
parsed.is_err(),
"an object fingerprint must not silently deserialize"
);
}
#[test]
fn api_profile_exposes_the_fingerprint_os_separately_from_host_os() {
// host_os is the machine; fingerprint_os is what the browser reports. A
// cross-OS profile has to be distinguishable through the API alone.
let spec = ApiDoc::openapi();
let spec = serde_json::to_value(&spec).expect("spec serializes");
let props = &spec["components"]["schemas"]["ApiProfile"]["properties"];
assert!(
props.get("fingerprint_os").is_some(),
"ApiProfile must publish fingerprint_os"
);
let required = spec["components"]["schemas"]["ApiProfile"]["required"]
.as_array()
.cloned()
.unwrap_or_default();
assert!(
!required.iter().any(|r| r == "fingerprint_os"),
"fingerprint_os is nullable and must stay optional"
);
}
#[test]
fn create_profile_browser_validation_matches_supported_engines() {
// The handler rejects anything that isn't a launchable engine; this is the
+147 -16
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,20 @@ 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 {
// Ok(()) here means callers log nothing, so a session that declined to
// mint left no trace at all and looked identical to one that succeeded.
log::info!(
"Skipping wayfern token request: the cached plan does not include browser automation"
);
self.clear_wayfern_token().await;
return Ok(());
}
@@ -1137,7 +1169,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 +1192,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);
}
@@ -1233,9 +1278,11 @@ impl CloudAuthManager {
}
}
// Refresh profile data periodically
// Refresh profile data periodically. A failure here leaves the cached
// plan stale, which silently gates paid features, so it belongs at warn
// rather than debug where the shipped log level hides it.
if let Err(e) = CLOUD_AUTH.fetch_profile().await {
log::debug!("Failed to refresh cloud profile: {e}");
log::warn!("Failed to refresh cloud profile: {e}");
}
// Reconnect profile lock manager if needed
@@ -1248,15 +1295,20 @@ impl CloudAuthManager {
// Sync cloud proxy credentials
CLOUD_AUTH.sync_cloud_proxy().await;
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
if wayfern_refresh_counter >= 60 {
// 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.
//
// Also mint one as soon as the plan starts granting it. `fetch_profile`
// above picks up an upgrade within ten minutes, but nothing watched that
// transition, so a session that signed in before upgrading stayed
// tokenless for up to ten hours while reporting the feature as unlocked.
let missing_entitled_token = CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none();
if wayfern_refresh_counter >= 60 || missing_entitled_token {
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 +1317,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;
@@ -1362,6 +1425,20 @@ pub async fn cloud_get_user() -> Result<Option<CloudAuthState>, String> {
pub async fn cloud_refresh_profile() -> Result<CloudUser, String> {
let mut user = CLOUD_AUTH.fetch_profile().await?;
user.entitlements = Some(user.entitlements());
// Minting the token is what actually unlocks cross-OS fingerprints, and it
// only happened at login, at startup and once every 10 hours. An account
// that upgraded after its last sign-in therefore refreshed into the correct
// entitlements while still holding no token, and "Refresh" did not fix it.
// Only mint when one is genuinely missing, so this stays a no-op afterwards.
if CLOUD_AUTH.is_entitled_to_wayfern_token().await
&& CLOUD_AUTH.get_wayfern_token().await.is_none()
{
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Refresh could not obtain a wayfern token: {e}");
}
}
Ok(user)
}
@@ -1586,3 +1663,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);
+20 -8
View File
@@ -2,7 +2,7 @@ use crate::browser::{create_browser, BrowserType};
use crate::cloud_auth::CLOUD_AUTH;
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::types::{get_host_os, is_host_os, BrowserProfile, SyncMode};
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
use std::fs::{self, create_dir_all};
@@ -384,11 +384,23 @@ impl ProfileManager {
};
// Backfill host_os from browser config for profiles created before
// the field existed (or synced without it).
if profile.host_os.is_none() {
let inferred_os = profile.resolved_os().map(str::to_string);
if let Some(os) = inferred_os {
profile.host_os = Some(os);
// the field existed (or synced without it), and repair any profile
// already stamped with a fingerprint-only OS.
//
// Only a real host OS may be stored here. The fallback in
// `resolved_os` reads `wayfern_config.os`, which is a fingerprint OS
// and may be "android"/"ios". Persisting that made `is_cross_os`
// permanently true and locked the profile out of every local launch,
// with no way to undo it from the UI. Leaving `host_os` as None keeps
// the profile launchable, which is what it was before the field.
let needs_repair = profile.host_os.as_deref().is_some_and(|os| !is_host_os(os));
if profile.host_os.is_none() || needs_repair {
let inferred_os = profile
.resolved_os()
.filter(|os| is_host_os(os))
.map(str::to_string);
if inferred_os != profile.host_os {
profile.host_os = inferred_os;
if let Ok(json) = serde_json::to_string_pretty(&profile) {
let _ = atomic_write(&metadata_file, json.as_bytes());
}
@@ -1924,7 +1936,7 @@ pub async fn create_browser_profile_new(
.is_fingerprint_os_allowed(fingerprint_os)
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
// A dead/unreachable proxy or VPN (or a 402 from an expired proxy
@@ -1968,7 +1980,7 @@ pub async fn update_wayfern_config(
.is_fingerprint_os_allowed(config.os.as_deref())
.await
{
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
return Err(serde_json::json!({ "code": "FINGERPRINT_REQUIRES_PRO" }).to_string());
}
let profile_manager = ProfileManager::instance();
+35
View File
@@ -103,6 +103,16 @@ pub fn get_host_os() -> String {
}
}
/// Whether a value is one `get_host_os` can actually return.
///
/// A fingerprint OS is a wider set than a host OS: `"android"` and `"ios"` are
/// valid fingerprints but no machine ever reports them as its host. Storing one
/// in `host_os` makes `is_cross_os` permanently true, which bars the profile
/// from every local launch path on the very machine that created it.
pub fn is_host_os(value: &str) -> bool {
matches!(value, "macos" | "windows" | "linux")
}
impl BrowserProfile {
/// Get the path to the profile data directory (profiles/{uuid}/profile)
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
@@ -138,3 +148,28 @@ impl BrowserProfile {
self.sync_mode == SyncMode::Encrypted
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_os_is_always_a_valid_host_os() {
// The invariant the host_os backfill guard rests on: whatever this machine
// reports must satisfy is_host_os, on every platform.
assert!(is_host_os(&get_host_os()));
}
#[test]
fn mobile_fingerprint_targets_are_not_host_operating_systems() {
// Backfilling host_os from a fingerprint OS used to store these, and since
// get_host_os can never return them, is_cross_os stayed true forever and
// the profile could not be launched on the machine that created it.
for os in ["macos", "windows", "linux"] {
assert!(is_host_os(os), "{os} must count as a host OS");
}
for os in ["android", "ios", "", "Windows", "chromeos"] {
assert!(!is_host_os(os), "{os} must not be stored as a host OS");
}
}
}
+9 -2
View File
@@ -657,9 +657,13 @@ impl WayfernManager {
let fingerprint_json = serde_json::to_string(&fingerprint)
.map_err(|e| format!("Failed to serialize fingerprint: {e}"))?;
// Report the platform the engine actually produced alongside the one that
// was asked for. Logging only the request made this line useless for
// diagnosing a fingerprint that came back as something else.
log::info!(
"Generated Wayfern fingerprint for OS: {}, fields: {:?}",
"Generated Wayfern fingerprint for requested OS: {}, produced platform: {:?}, fields: {:?}",
os,
fingerprint.get("platform").and_then(|p| p.as_str()),
fingerprint
.as_object()
.map(|o| o.keys().collect::<Vec<_>>())
@@ -883,9 +887,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.4",
"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}})",