Compare commits

..
7 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
18 changed files with 230 additions and 49 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
+13
View File
@@ -1,6 +1,19 @@
# 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
+8 -8
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel |
|---|---|---|
| **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) |
| **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.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)
[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.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) |
| **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.2";
releaseVersion = "0.29.3";
releaseAppImage =
if system == "x86_64-linux" then
pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.2/Donut_0.29.2_amd64.AppImage";
hash = "sha256-ExvjO1f5AkzzRr39LIBQbG2bavQxCHpyOuE+h16VHkU=";
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.2/Donut_0.29.2_aarch64.AppImage";
hash = "sha256-rcTyIJ8hehrS6Q4yEkEs6kZz14Jt0A1Qpsss5dIwepU=";
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.3/Donut_0.29.3_aarch64.AppImage";
hash = "sha256-kKDCcpuW51YpwKWhear+Tgo9q8Kc0KOdcAqLW67jxgk=";
}
else
null;
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.29.3",
"version": "0.29.4",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
+1 -1
View File
@@ -1815,7 +1815,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.3"
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.3"
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
+31 -3
View File
@@ -1138,6 +1138,11 @@ impl CloudAuthManager {
/// is nothing to fetch and nothing wrong.
pub async fn request_wayfern_token(&self) -> Result<(), String> {
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(());
}
@@ -1273,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
@@ -1291,7 +1298,14 @@ impl CloudAuthManager {
// 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 {
//
// 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 let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
log::warn!("Failed to refresh wayfern token: {e}");
@@ -1411,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)
}
+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");
}
}
}
+5 -1
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<_>>())
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.29.3",
"version": "0.29.4",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",