refactor: minor improvement

This commit is contained in:
zhom
2026-08-12 14:35:57 +04:00
parent 01e4afb687
commit ad1aa6cc5a
13 changed files with 132 additions and 28 deletions
+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): "
));
}
}