From c6419e06aa1310e5ea021ffec5d0d419ef0d5c12 Mon Sep 17 00:00:00 2001 From: Jinhui Zheng Date: Wed, 30 Sep 2026 00:33:50 +0800 Subject: [PATCH] fix: speed up account refresh and recover captcha setup failures --- .github/workflows/ci.yml | 14 +++ README.en.md | 6 ++ README.md | 6 ++ captcha.html | 1 + package.json | 1 + src-tauri/src/claim.rs | 150 ++++++++++++++++++++++++--- src-tauri/src/i18n.rs | 2 + src-tauri/src/lib.rs | 84 +++++++++++---- src-tauri/src/quota.rs | 129 ++++++++++++++++++----- src/captcha.js | 66 ++++++++---- src/locales/en.js | 4 +- src/locales/zh.js | 4 +- src/main.js | 196 +++++++++++++++++++++++------------ src/request-queue.js | 36 +++++++ src/styles.css | 8 +- tests/captcha.test.mjs | 70 +++++++++++++ tests/refresh.test.mjs | 117 +++++++++++++++++++++ tests/request-queue.test.mjs | 56 ++++++++++ tests/ui-harness.mjs | 34 ++++++ 19 files changed, 837 insertions(+), 147 deletions(-) create mode 100644 src/request-queue.js create mode 100644 tests/captcha.test.mjs create mode 100644 tests/refresh.test.mjs create mode 100644 tests/request-queue.test.mjs create mode 100644 tests/ui-harness.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43065e5..bb44706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,20 @@ on: pull_request: jobs: + test-ui: + name: frontend tests and build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm test + - run: npm run check:i18n + - run: npm run build + test-windows: name: cargo test (windows) runs-on: windows-latest diff --git a/README.en.md b/README.en.md index 8b19365..dcbcd89 100644 --- a/README.en.md +++ b/README.en.md @@ -45,6 +45,12 @@ CLI password (export / import): prefer the `ZSW_PASSWORD` environment variable ( ## FAQ +### Slow refreshes or a failed verification configuration request? + +Quota, plan preview and eligibility refreshes share a limit of 3 concurrent requests. Manual actions take priority over background quota refreshes. Failed accounts retain error details and can be retried individually. Bulk eligibility refresh retains its 60-second cooldown; individual retries are independent of that cooldown. + +Successful CAPTCHA configuration is cached in memory for 5 minutes, with at most one retry for a transport failure. A failed setup offers Retry in the manual verification window; batch claims retain the failed account and continue with the others. Claim submissions are not automatically replayed, and interactive verification still requires the user. + ### macOS asks for Microphone / Accessibility / Screen Recording permission? Deny all of them — nothing breaks. diff --git a/README.md b/README.md index f03fc44..742e815 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,12 @@ CLI 密码(export / import):优先环境变量 `ZSW_PASSWORD`(不出现 ## 常见问题 +### 刷新慢或领取时验证配置加载失败? + +额度、套餐预览和资格刷新共用最多 3 个并行请求,手动操作优先于后台额度刷新。失败账号保留错误详情,可在账号行单独重试;批量资格刷新仍有 60 秒冷却,单账号重试不受该整轮冷却限制。 + +验证配置成功后在当前进程缓存 5 分钟,连接类故障最多重试一次。配置或组件加载失败时,手动验证窗口提供「重试」;批量领取会保留失败账号并继续其他账号。领取请求本身不会自动重发,人工验证仍需用户完成。 + ### macOS 弹出「麦克风 / 辅助功能 / 录屏」权限询问? 全部拒绝即可,不影响任何功能。 diff --git a/captcha.html b/captcha.html index 9f3c622..9a23154 100644 --- a/captcha.html +++ b/captcha.html @@ -20,6 +20,7 @@
+
diff --git a/package.json b/package.json index 678e4f6..32fcf58 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "version": "1.7.0-beta.1", "type": "module", "scripts": { + "test": "node --test tests/*.test.mjs", "dev": "vite", "build": "vite build", "preview": "vite preview", diff --git a/src-tauri/src/claim.rs b/src-tauri/src/claim.rs index b6fcd23..c25cf16 100644 --- a/src-tauri/src/claim.rs +++ b/src-tauri/src/claim.rs @@ -4,7 +4,8 @@ use crate::zcrypto; use serde::Serialize; use serde_json::Value; use std::path::Path; -use std::time::Duration; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; pub const BILLING_PREVIEW_URL: &str = "https://zcode.z.ai/api/v1/zcode-plan/billing/preview"; pub const BILLING_CLAIM_URL: &str = "https://zcode.z.ai/api/v1/zcode-plan/billing/claim"; @@ -121,10 +122,11 @@ fn decrypt_credential(v: &str, secret: &str) -> Option { } fn agent() -> ureq::Agent { - ureq::AgentBuilder::new() + static AGENT: OnceLock = OnceLock::new(); + AGENT.get_or_init(|| ureq::AgentBuilder::new() .timeout_connect(Duration::from_secs(10)) .timeout(Duration::from_secs(CLAIM_TIMEOUT_SECS)) - .build() + .build()).clone() } pub const EVENT_REPORT_URL: &str = "https://zcode.z.ai/api/v1/event/report"; @@ -185,10 +187,7 @@ fn device_os_category() -> &'static str { } pub fn report_activation_events(user_id: &str, device_mid: &str) -> Result<(), String> { - let agent = ureq::AgentBuilder::new() - .timeout_connect(Duration::from_secs(10)) - .timeout(Duration::from_secs(ACTIVATION_TIMEOUT_SECS)) - .build(); + let agent = agent(); for element in ACTIVATION_EVENTS { let body = activation_event_body( element, @@ -198,6 +197,7 @@ pub fn report_activation_events(user_id: &str, device_mid: &str) -> Result<(), S ); let resp = agent .post(EVENT_REPORT_URL) + .timeout(Duration::from_secs(ACTIVATION_TIMEOUT_SECS)) .set("Content-Type", "application/json") .send_json(body) .map_err(|e| { @@ -312,19 +312,65 @@ pub fn submit_claim( Ok(v) } +#[derive(Debug)] +struct ConfigError { + message: String, + transient: bool, +} + +impl ConfigError { + fn permanent(message: String) -> Self { Self { message, transient: false } } +} + +#[derive(Default)] +struct ConfigCache { + value: Option<(Instant, CaptchaConfig)>, +} + +impl ConfigCache { + fn get(&mut self, mut fetch: impl FnMut() -> Result, mut pause: impl FnMut()) -> Result { + if let Some((at, cfg)) = &self.value { + if at.elapsed() < Duration::from_secs(300) { return Ok(cfg.clone()); } + } + self.value = None; + let cfg = match fetch() { + Err(e) if e.transient => { pause(); fetch().map_err(|e| e.message)? } + result => result.map_err(|e| e.message)?, + }; + // Only successful, usable configuration is shared between accounts. + if cfg.enabled && !cfg.scene_id.trim().is_empty() { + self.value = Some((Instant::now(), cfg.clone())); + } + Ok(cfg) + } +} + pub fn fetch_captcha_config() -> Result { - let mut req = agent().get(CLIENT_CONFIGS_URL); + static CACHE: Mutex = Mutex::new(ConfigCache { value: None }); + let mut cache = CACHE.lock().unwrap_or_else(|p| p.into_inner()); + cache.get(|| fetch_captcha_config_at(&agent(), CLIENT_CONFIGS_URL), || { + std::thread::sleep(Duration::from_millis(300)); + }) +} + +fn fetch_captcha_config_at(agent: &ureq::Agent, url: &str) -> Result { + let mut req = agent.get(url).timeout(Duration::from_secs(12)); for (k, v) in quota::zai_billing_headers("") { req = req.set(&k, &v); } let resp = req .call() - .map_err(|e| crate::i18n::trf("err.claim.config_req", &[("e", &e.to_string())]))? + .map_err(|e| ConfigError { + transient: matches!(&e, ureq::Error::Transport(t) if matches!(t.kind(), + ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::Dns | ureq::ErrorKind::Io)), + message: http_err(&crate::i18n::tr("err.claim.config_unavailable"), e), + })? .into_string() - .map_err(|e| crate::i18n::trf("err.http.read", &[("e", &e.to_string())]))?; - let v: Value = serde_json::from_str(&resp).unwrap_or(Value::String(resp)); + .map_err(|e| ConfigError { transient: true, message: crate::i18n::trf("err.http.read", &[("e", &e.to_string())]) })?; + let v: Value = serde_json::from_str(&resp) + .map_err(|_| ConfigError::permanent(crate::i18n::tr("err.claim.config_unavailable")))?; if v.get("code").and_then(|c| c.as_i64()).unwrap_or(-1) != 0 { - return Err(crate::i18n::tr("err.claim.config_unavailable")); + return Err(ConfigError::permanent(crate::i18n::tr("err.claim.config_unavailable"))); } let c = v.pointer("/data/configs/captcha").cloned().unwrap_or(Value::Null); let s = |k: &str| c.get(k).and_then(|x| x.as_str()).unwrap_or_default().to_string(); @@ -440,3 +486,83 @@ pub fn failure_message(code: i64, body: &Value) -> String { crate::i18n::trf("claim.fail.with_server", &[("base", &base), ("server_msg", &server_msg)]) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + fn config() -> CaptchaConfig { + CaptchaConfig { enabled: true, scene_id: "scene".into(), region: "sgp".into(), prefix: "prefix".into() } + } + + #[test] + fn transient_config_error_retries_once_then_caches_success() { + let mut cache = ConfigCache::default(); + let (mut calls, mut pauses) = (0, 0); + let result = cache.get(|| { + calls += 1; + if calls == 1 { Err(ConfigError { transient: true, message: "timeout".into() }) } + else { Ok(config()) } + }, || pauses += 1).unwrap(); + assert_eq!(result.scene_id, "scene"); + assert_eq!((calls, pauses), (2, 1)); + cache.get(|| panic!("fresh cache must avoid HTTP"), || panic!()).unwrap(); + cache.value.as_mut().unwrap().0 = Instant::now() - Duration::from_secs(301); + cache.get(|| { calls += 1; Ok(config()) }, || panic!()).unwrap(); + assert_eq!(calls, 3); + } + + #[test] + fn failure_is_not_cached_and_retries_are_bounded() { + let mut cache = ConfigCache::default(); + let mut calls = 0; + assert!(cache.get(|| { + calls += 1; + Err(ConfigError { transient: true, message: "offline".into() }) + }, || {}).is_err()); + assert_eq!(calls, 2); + assert!(cache.value.is_none()); + cache.get(|| Ok(config()), || panic!()).unwrap(); + } + + #[test] + fn permanent_error_and_disabled_config_are_not_retried_or_cached() { + let mut cache = ConfigCache::default(); + assert!(cache.get(|| Err(ConfigError::permanent("HTTP 401".into())), || panic!()).is_err()); + let mut disabled = config(); disabled.enabled = false; + assert!(!cache.get(|| Ok(disabled.clone()), || panic!()).unwrap().enabled); + assert!(cache.value.is_none()); + let mut missing_scene = config(); missing_scene.scene_id.clear(); + cache.get(|| Ok(missing_scene.clone()), || panic!()).unwrap(); + assert!(cache.value.is_none()); + } + + fn http_config(status: u16, body: &str) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}/configs", listener.local_addr().unwrap()); + let body = body.to_string(); + let server = std::thread::spawn(move || { + let (mut socket, _) = listener.accept().unwrap(); + socket.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = [0; 8192]; + socket.read(&mut request).unwrap(); + write!(socket, "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + }); + let agent = ureq::AgentBuilder::new().try_proxy_from_env(false).build(); + let result = fetch_captcha_config_at(&agent, &url); + server.join().unwrap(); + result + } + + #[test] + fn real_http_config_path_parses_success_and_classifies_failures() { + let cfg = http_config(200, r#"{"code":0,"data":{"configs":{"captcha":{"enabled":true,"region":"sgp","prefix":"p","sceneId":"s"}}}}"#).unwrap(); + assert_eq!(cfg.scene_id, "s"); + for (status, body) in [(401, r#"{"msg":"unauthorized"}"#), (429, "{}"), (200, "not json"), (200, r#"{"code":3007}"#)] { + let err = http_config(status, body).unwrap_err(); + assert!(!err.transient, "status {status} must not be retried"); + } + } +} diff --git a/src-tauri/src/i18n.rs b/src-tauri/src/i18n.rs index 9870728..4fffb61 100644 --- a/src-tauri/src/i18n.rs +++ b/src-tauri/src/i18n.rs @@ -189,6 +189,7 @@ const ZH: &[(&str, &str)] = &[ ("err.claim.config_unavailable", "验证码配置不可用"), ("err.claim.gone", "该套餐已不可领取,请刷新"), ("err.claim.none_pending", "没有待领取的套餐"), + ("err.claim.busy", "已有领取正在验证,请先完成或取消"), ("err.claim.activate_req", "激活上报失败:{e}"), ("claim.fail.1001", "套餐不存在"), ("claim.fail.1002", "活动已结束或套餐暂不可领取"), @@ -346,6 +347,7 @@ const EN: &[(&str, &str)] = &[ ("err.claim.config_unavailable", "Captcha config unavailable"), ("err.claim.gone", "This plan is no longer claimable — refresh the list"), ("err.claim.none_pending", "No pending plan to claim"), + ("err.claim.busy", "A claim is awaiting verification; complete or cancel it first"), ("err.claim.activate_req", "Activation report failed: {e}"), ("claim.fail.1001", "Plan does not exist"), ("claim.fail.1002", "The event has ended or the plan is not claimable yet"), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7b0fb5..a4b1024 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ const TRAY_ID: &str = "main"; static STORE_LOCK: Mutex<()> = Mutex::new(()); struct PendingClaim { + claim_id: String, account_id: String, account_name: String, plan_id: String, @@ -48,6 +49,10 @@ fn store_guard() -> std::sync::MutexGuard<'static, ()> { } } +async fn blocking(f: impl FnOnce() -> Result + Send + 'static) -> Result { + tauri::async_runtime::spawn_blocking(f).await.map_err(|e| e.to_string())? +} + fn sanitize_filename(name: &str) -> String { name.chars() .map(|c| match c { @@ -205,20 +210,22 @@ async fn switch_to(app: AppHandle, id: String, force: bool, restart: bool) -> Re #[tauri::command] async fn get_live_quota() -> Result { - store::live_quota(&Paths::detect()) + blocking(|| store::live_quota(&Paths::detect())).await } #[tauri::command] async fn get_account_quota(id: String) -> Result { - store::account_quota(&Paths::detect(), &id) + blocking(move || store::account_quota(&Paths::detect(), &id)).await } #[tauri::command] async fn claim_preview(id: String) -> Result, String> { + blocking(move || { let paths = Paths::detect(); let mid = store::ensure_virtual_device_mid(&paths, &id)?; let acc = load_account(&paths, &id)?; claim::preview_plans(&paths.home, &acc.credentials, acc.config.as_ref(), Some(mid)) + }).await } #[derive(serde::Serialize)] @@ -231,6 +238,7 @@ struct ClaimRefreshResult { #[tauri::command] async fn claim_refresh(id: String) -> Result { + blocking(move || { let paths = Paths::detect(); let mid = store::ensure_virtual_device_mid(&paths, &id)?; let acc = load_account(&paths, &id)?; @@ -244,6 +252,7 @@ async fn claim_refresh(id: String) -> Result { }; let plans = claim::preview_plans(&paths.home, &acc.credentials, acc.config.as_ref(), Some(mid))?; Ok(ClaimRefreshResult { plans, activated, activation_error }) + }).await } #[tauri::command] @@ -251,8 +260,11 @@ async fn claim_start( app: AppHandle, id: String, plan_id: String, + claim_id: String, auto: Option, ) -> Result { + if uuid::Uuid::parse_str(&claim_id).is_err() { return Err(i18n::tr("err.claim.none_pending")); } + let pending = blocking(move || { let paths = Paths::detect(); let mid = store::ensure_virtual_device_mid(&paths, &id)?; let acc = load_account(&paths, &id)?; @@ -263,7 +275,8 @@ async fn claim_start( .ok_or_else(|| i18n::tr("err.claim.gone"))?; let display = if plan.name.is_empty() { plan.plan_id.clone() } else { plan.name.clone() }; - *pending_guard() = Some(PendingClaim { + Ok(PendingClaim { + claim_id, account_id: acc.id.clone(), account_name: acc.name.clone(), plan_id: plan.plan_id.clone(), @@ -271,14 +284,25 @@ async fn claim_start( credentials: acc.credentials, config: acc.config, device_mid: mid, - }); - open_captcha_window(&app, auto.unwrap_or(false))?; - Ok(json!({ "account": acc.name, "plan": display })) + }) + }).await?; + let result = json!({ "account": pending.account_name, "plan": pending.plan_name }); + let claim_id = pending.claim_id.clone(); + { + let mut guard = pending_guard(); + if guard.is_some() { return Err(i18n::tr("err.claim.busy")); } + *guard = Some(pending); + } + if let Err(e) = open_captcha_window(&app, auto.unwrap_or(false), &claim_id) { + *pending_guard() = None; + return Err(e); + } + Ok(result) } #[tauri::command] async fn claim_captcha_config() -> Result { - claim::fetch_captcha_config() + blocking(claim::fetch_captcha_config).await } #[tauri::command] @@ -286,8 +310,10 @@ async fn claim_captcha_submit( app: AppHandle, param: String, region: Option, + claim_id: String, ) -> Result { - let pending = pending_guard().take().ok_or_else(|| i18n::tr("err.claim.none_pending"))?; + let pending = take_pending_claim(&claim_id).ok_or_else(|| i18n::tr("err.claim.none_pending"))?; + let (pending, res) = tauri::async_runtime::spawn_blocking(move || { let paths = Paths::detect(); let res = claim::submit_claim( &paths.home, @@ -296,9 +322,11 @@ async fn claim_captcha_submit( &pending.plan_id, ¶m, region.as_deref(), - Some(pending.device_mid), + Some(pending.device_mid.clone()), ); - close_captcha_window(&app); + (pending, res) + }).await.map_err(|e| e.to_string())?; + close_captcha_window(&app, &pending.claim_id); let payload = match res { Ok(v) => { let ms = |k: &str| -> Option { @@ -318,17 +346,19 @@ async fn claim_captcha_submit( ends_at: ms("ends_at"), server_time, }; - let p = serde_json::to_value(&outcome).unwrap_or(Value::Null); + let mut p = serde_json::to_value(&outcome).unwrap_or(Value::Null); + p["claimId"] = json!(pending.claim_id); let _ = app.emit("claim://result", &p); p } Err(e) => { - let p = claim::failure_payload( + let mut p = claim::failure_payload( &pending.account_id, &pending.account_name, &pending.plan_name, &e, ); + p["claimId"] = json!(pending.claim_id); let _ = app.emit("claim://result", &p); return Ok(p); } @@ -337,15 +367,22 @@ async fn claim_captcha_submit( } #[tauri::command] -async fn claim_cancel(app: AppHandle) -> Result<(), String> { - *pending_guard() = None; - close_captcha_window(&app); +async fn claim_cancel(app: AppHandle, claim_id: String) -> Result<(), String> { + take_pending_claim(&claim_id); + close_captcha_window(&app, &claim_id); Ok(()) } -fn close_captcha_window(app: &AppHandle) { +fn take_pending_claim(claim_id: &str) -> Option { + let mut pending = pending_guard(); + if pending.as_ref().map(|p| p.claim_id.as_str()) == Some(claim_id) { pending.take() } else { None } +} + +fn close_captcha_window(app: &AppHandle, claim_id: &str) { if let Some(w) = app.get_webview_window("captcha") { - let _ = w.close(); + if w.url().ok().is_some_and(|url| url.query_pairs().any(|(k, v)| k == "claimId" && v == claim_id)) { + let _ = w.close(); + } } } @@ -793,10 +830,11 @@ fn spawn_poll_loop(app: AppHandle, provider: String, flow: String, mid: String, }); } -fn open_captcha_window(app: &AppHandle, auto: bool) -> Result<(), String> { +fn open_captcha_window(app: &AppHandle, auto: bool, claim_id: &str) -> Result<(), String> { let (w, h) = (380.0, 320.0); + let url = format!("captcha.html?claimId={claim_id}"); if let Some(win) = app.get_webview_window("captcha") { - let _ = win.eval("location.reload()"); + win.eval(&format!("location.href = {}", json!(url))).map_err(|e| e.to_string())?; center_over_main(app, &win, w, h); if auto { let _ = win.hide(); @@ -809,7 +847,7 @@ fn open_captcha_window(app: &AppHandle, auto: bool) -> Result<(), String> { let win = tauri::WebviewWindowBuilder::new( app, "captcha", - tauri::WebviewUrl::App("captcha.html".into()), + tauri::WebviewUrl::App(url.into()), ) .title(i18n::tr("title.captcha")) .theme(Some(tauri::Theme::Dark)) @@ -1181,7 +1219,11 @@ pub fn run() { } } if window.label() == "captcha" { - *pending_guard() = None; + if let Some(id) = window.get_webview_window("captcha").and_then(|w| w.url().ok()).and_then(|url| url.query_pairs().find(|(k, _)| k == "claimId").map(|(_, v)| v.into_owned())) { + if let Some(p) = take_pending_claim(&id) { + let _ = window.emit("claim://cancelled", json!({ "claimId": id, "accountId": p.account_id })); + } + } } } }) diff --git a/src-tauri/src/quota.rs b/src-tauri/src/quota.rs index 7f05dae..d00d83a 100644 --- a/src-tauri/src/quota.rs +++ b/src-tauri/src/quota.rs @@ -2,6 +2,7 @@ use crate::zcrypto; use serde_json::Value; use std::path::Path; +use std::sync::OnceLock; use std::thread::sleep; use std::time::Duration; @@ -378,10 +379,11 @@ pub fn candidate_tokens(creds: &Value, config: Option<&Value>, secret: &str, new fn http_get_json(url: &str, token: &str, retry_429: bool) -> Result { let retry_delays = [500u64, 1500, 4000]; - let agent = ureq::AgentBuilder::new() + static AGENT: OnceLock = OnceLock::new(); + let agent = AGENT.get_or_init(|| ureq::AgentBuilder::new() .timeout_connect(Duration::from_secs(10)) .timeout(Duration::from_secs(20)) - .build(); + .build()); let headers = if url.contains("zcode.z.ai") { zai_billing_headers(token) } else { @@ -407,8 +409,8 @@ fn http_get_json(url: &str, token: &str, retry_429: bool) -> Result { let body = r.into_string().unwrap_or_default(); if let Ok(v) = serde_json::from_str::(&body) { - if v.get("code").and_then(|c| c.as_i64()) == Some(401) { - return Err(crate::i18n::tr("err.token.biz401")); + if matches!(v.get("code").and_then(|c| c.as_i64()), Some(401 | 1006)) { + return Ok(v); } } if code == 429 { @@ -421,7 +423,10 @@ fn http_get_json(url: &str, token: &str, retry_429: bool) -> Result return Err(last_err.unwrap_or_else(|| crate::i18n::tr("err.quota.http429"))), } } - if code == 401 || code == 403 { + if code == 401 { + return Ok(serde_json::json!({ "code": 401 })); + } + if code == 403 { return Err(crate::i18n::trf("err.token.http401", &[("code", &code.to_string())])); } let msg = serde_json::from_str::(&body) @@ -483,10 +488,6 @@ fn query_with_token_via(token: &str, fetch: &FetchFn) -> Result Result { - query_with_token_via(token, &|url, tok| http_get_json(url, tok, true)) -} - pub(crate) fn business_ok(v: &Value) -> bool { let code = v.get("code").and_then(|c| c.as_i64()); let success = v.get("success").and_then(|s| s.as_bool()); @@ -507,7 +508,7 @@ pub(crate) fn is_active_coding_plan_entry(s: &Value) -> bool { && s.get("status").and_then(|v| v.as_str()) == Some("VALID") } -pub fn query_quota(tokens: &[String]) -> Result { +fn query_quota_via(tokens: &[String], fetch: &FetchFn) -> Result { if tokens.is_empty() { return Err(crate::i18n::tr("err.quota.no_token")); } @@ -515,7 +516,7 @@ pub fn query_quota(tokens: &[String]) -> Result { let mut first_business: Option = None; let mut auth_fail = 0usize; for t in tokens { - match query_with_token(t) { + match query_with_token_via(t, fetch) { Ok(ov) => return Ok(ov), Err(e) => { if e.contains("401") { @@ -528,10 +529,6 @@ pub fn query_quota(tokens: &[String]) -> Result { } } if auth_fail > 0 && auth_fail == tokens.len() { - sleep(Duration::from_millis(1500)); - if let Ok(ov) = query_with_token(&tokens[0]) { - return Ok(ov); - } return Err(crate::i18n::tr("err.token.expired")); } Err(first_business.or(last_err).unwrap_or_else(|| crate::i18n::tr("err.quota.fail"))) @@ -796,27 +793,111 @@ fn merge_parts(parts: Vec) -> QuotaOverview { } } -fn query_channels(channels: &[Channel]) -> Result { - query_channels_via(channels, &|url, tok| http_get_json(url, tok, true)) +fn query_routes_via(channels: &[Channel], tokens: impl FnOnce() -> Vec, fetch: &FetchFn) -> Result { + use std::cell::{Cell, RefCell}; + let saw_auth = Cell::new(false); + let may_fallback = Cell::new(true); + let responses: RefCell>> = RefCell::new(std::collections::HashMap::new()); + let once = |url: &str, token: &str| { + let key = (url.to_string(), token.to_string()); + if let Some(result) = responses.borrow().get(&key) { return result.clone(); } + let result = fetch(url, token); + match &result { + Ok(v) if matches!(v.get("code").and_then(|c| c.as_i64()), Some(401 | 1006)) => saw_auth.set(true), + Ok(v) if business_ok(v) => {}, + _ => may_fallback.set(false), + } + responses.borrow_mut().insert(key, result.clone()); + result + }; + if !channels.is_empty() { + match query_channels_via(channels, &once) { + // An expired configured key may still have a valid saved OAuth/JWT. + // Network, rate-limit and other service errors never trigger this pass. + Err(_) if saw_auth.get() && may_fallback.get() => {}, + result => return result, + } + } + query_quota_via(&tokens(), &once) } pub fn quota_for_live(home: &Path, creds: &Value, config: Option<&Value>) -> Result { let secret = zcrypto::default_secret(home); let new_gen = crate::store::new_gen_provider_config(home); let channels = pick_channels(creds, config, &secret, new_gen); - if !channels.is_empty() { - if let Ok(ov) = query_channels(&channels) { - return Ok(ov); - } - } - let tokens = candidate_tokens(creds, config, &secret, new_gen); - query_quota(&tokens) + query_routes_via(&channels, + || candidate_tokens(creds, config, &secret, new_gen), + &|url, tok| http_get_json(url, tok, true)) } pub fn quota_for_snapshot(home: &Path, creds: &Value, config: Option<&Value>) -> Result { quota_for_live(home, creds, config) } +#[cfg(test)] +mod refresh_tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn known_route_error_does_not_spray_other_credentials_or_endpoints() { + let calls = RefCell::new(Vec::new()); + let result = query_routes_via(&[Channel::ZaiBilling("known".into())], + || panic!("known route must not enumerate legacy credentials"), + &|url, token| { + calls.borrow_mut().push((url.to_string(), token.to_string())); + Err("connection timed out".into()) + }); + assert_eq!(result.unwrap_err(), "connection timed out"); + assert_eq!(calls.borrow().len(), 1); + assert!(calls.borrow()[0].0.starts_with(BILLING_BALANCE_URL)); + assert_eq!(calls.borrow()[0].1, "known"); + } + + #[test] + fn legacy_accounts_without_known_routes_still_query_quota() { + let calls = RefCell::new(Vec::new()); + let result = query_routes_via(&[], || vec!["legacy".into()], &|url, token| { + calls.borrow_mut().push((url.to_string(), token.to_string())); + Ok(serde_json::json!({"code": 200, "data": {}})) + }); + assert!(result.is_ok()); + assert_eq!(calls.borrow()[0], (QUOTA_LIMIT_URL.to_string(), "legacy".to_string())); + } + + #[test] + fn rejected_configured_key_can_use_valid_saved_credential_without_duplicate_requests() { + let calls = RefCell::new(Vec::new()); + let result = query_routes_via(&[Channel::Monitor("stale".into())], + || vec!["stale".into(), "valid".into()], &|url, token| { + calls.borrow_mut().push((url.to_string(), token.to_string())); + if token == "stale" { Ok(serde_json::json!({"code":401})) } + else { Ok(serde_json::json!({"code":200,"data":{}})) } + }); + assert!(result.is_ok()); + assert_eq!(calls.borrow().iter().filter(|(url, token)| url == QUOTA_LIMIT_URL && token == "stale").count(), 1); + assert!(calls.borrow().iter().any(|(_, token)| token == "valid")); + } + + #[test] + fn rate_limit_never_triggers_credential_fallback() { + let result = query_routes_via(&[Channel::Monitor("known".into())], + || panic!("rate limiting is not a stale credential"), + &|_, _| Ok(serde_json::json!({"code":3008,"message":"rate limited"}))); + assert!(result.is_err()); + } + + #[test] + fn a_failed_provider_does_not_discard_a_successful_other_provider() { + let result = query_routes_via(&[Channel::ZaiBilling("down".into()), Channel::Monitor("up".into())], + || panic!("no legacy fallback"), &|_, token| { + if token == "down" { Err("offline".into()) } + else { Ok(serde_json::json!({"code":200,"data":{}})) } + }); + assert!(result.is_ok()); + } +} + fn unit_label(unit: Option, number: Option) -> (String, String) { match unit { Some(3) => { diff --git a/src/captcha.js b/src/captcha.js index e53c7d6..5ef286b 100644 --- a/src/captcha.js +++ b/src/captcha.js @@ -5,11 +5,14 @@ import { init, t, lang, stripErr } from "./i18n.js"; const SDK_URL = "https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"; const TRACELESS_TIMEOUT = 8000; +const SETUP_TIMEOUT = 12000; +const claimId = new URLSearchParams(location.search).get("claimId"); const $text = document.getElementById("cap-text"); const $detail = document.getElementById("cap-detail"); const $dot = document.getElementById("cap-dot"); const $btn = document.getElementById("cap-btn"); +const $retry = document.getElementById("cap-retry"); function status(text, tone = "run") { $text.textContent = text; @@ -24,15 +27,31 @@ document.addEventListener("securitypolicyviolation", (e) => { detail(t("c.cspBlocked", { directive: e.violatedDirective, uri: String(e.blockedURI).slice(0, 70) })); }); -const notifyStuck = () => { emit("captcha://interactive").catch(() => {}); }; +const notifyStuck = () => { emit("captcha://interactive", { claimId }).catch(() => {}); }; + +function setupFailed(text, message = "") { + clearTimeout(tracelessTimer); + clearTimeout(initTimer); + setupStopped = true; + status(text, "err"); + detail(message); + $btn.hidden = true; + $retry.hidden = false; + emit("captcha://failed", { claimId, message: message ? `${text}: ${message}` : text }).catch(() => {}); +} function loadSdk() { return new Promise((resolve, reject) => { if (typeof window.initAliyunCaptcha === "function") return resolve(); const s = document.createElement("script"); s.src = SDK_URL; - s.onload = () => resolve(); - s.onerror = () => reject(new Error(t("c.sdkFail"))); + const timer = setTimeout(() => { s.remove(); reject(new Error(t("c.sdkFail"))); }, SETUP_TIMEOUT); + s.onload = () => { + clearTimeout(timer); + if (typeof window.initAliyunCaptcha === "function") resolve(); + else reject(new Error(t("c.sdkFail"))); + }; + s.onerror = () => { clearTimeout(timer); s.remove(); reject(new Error(t("c.sdkFail"))); }; document.head.appendChild(s); }); } @@ -40,6 +59,8 @@ function loadSdk() { let submitted = false; let region = null; let tracelessTimer = 0; +let initTimer = 0; +let setupStopped = false; async function run() { try { @@ -48,6 +69,7 @@ async function run() { } catch { } document.title = t("c.title"); $btn.textContent = t("c.btn"); + $retry.textContent = t("btn.retry"); document.querySelector(".cap-foot").textContent = t("c.foot"); status(t("c.preparing")); @@ -55,42 +77,41 @@ async function run() { try { cfg = await invoke("claim_captcha_config"); } catch (e) { - notifyStuck(); - status(t("c.cfgFail"), "err"); - detail(stripErr(e)); + setupFailed(t("c.cfgFail"), stripErr(e)); return; } if (!cfg.enabled || !cfg.scene_id) { - notifyStuck(); - status(t("c.cfgUnavailable"), "err"); - detail(t("c.cfgUnavailableDetail")); + setupFailed(t("c.cfgUnavailable"), t("c.cfgUnavailableDetail")); return; } region = cfg.region || null; + // Aliyun requires region/prefix before loading the SDK. + window.AliyunCaptchaConfig = { region: cfg.region, prefix: cfg.prefix }; try { await loadSdk(); } catch (e) { - notifyStuck(); - status(e.message || t("c.sdkFail"), "err"); + setupFailed(e.message || t("c.sdkFail")); return; } - window.AliyunCaptchaConfig = { region: cfg.region, prefix: cfg.prefix }; - status(t("c.traceless")); const submit = (param) => { - if (submitted || !param || !param.trim()) return; + if (submitted || setupStopped || typeof param !== "string" || !param.trim()) return; submitted = true; clearTimeout(tracelessTimer); status(t("c.passed")); - invoke("claim_captcha_submit", { param, region }).catch((e) => { + clearTimeout(initTimer); + $retry.hidden = true; + invoke("claim_captcha_submit", { param, region, claimId }).catch((e) => { status(t("c.claimReqFail"), "err"); detail(stripErr(e)); + emit("captcha://failed", { claimId, message: stripErr(e), terminal: true }).catch(() => {}); }); }; const interactive = (why) => { + if (submitted || setupStopped) return; notifyStuck(); clearTimeout(tracelessTimer); status(t("c.interactive")); @@ -100,6 +121,7 @@ async function run() { }; try { + initTimer = setTimeout(() => setupFailed(t("c.initFail")), SETUP_TIMEOUT); window.initAliyunCaptcha({ SceneId: cfg.scene_id, mode: "popup", @@ -108,21 +130,21 @@ async function run() { element: "#cap-holder", button: "#cap-btn", getInstance: (instance) => { + if (setupStopped) return; + clearTimeout(initTimer); if (typeof instance.startTracelessVerification === "function") { - instance.startTracelessVerification(); tracelessTimer = setTimeout(interactive, TRACELESS_TIMEOUT); + instance.startTracelessVerification(); } else { interactive(); } }, success: (param) => submit(typeof param === "string" ? param : param?.captchaVerifyParam), fail: (p) => interactive(p), - onError: (p) => interactive(p), + onError: () => { if (!submitted) setupFailed(t("c.initFail")); }, }); } catch (e) { - notifyStuck(); - status(t("c.initFail"), "err"); - detail(String(e)); + setupFailed(t("c.initFail"), String(e)); } } @@ -130,4 +152,8 @@ $btn.addEventListener("click", () => { if (!$btn.hidden) status(t("c.inPopup")); }); +// A fresh document drops any partially initialized SDK instance and timers. +// PendingClaim stays bound to this claimId until submit or close. +$retry.addEventListener("click", () => { $retry.disabled = true; location.reload(); }); + run(); diff --git a/src/locales/en.js b/src/locales/en.js index 38576f5..6593a94 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -75,7 +75,9 @@ export const en = { "m.claimStartsAt": "Credits activate at {time}", "m.claimEndsAt": "Valid until {time}", "m.claimNextAt": " — available again after {time}", - "m.refreshClaimDone": "Refreshed {n} accounts — {k} with claimable plans", + "m.refreshClaimDone": "Checked {n} accounts — {k} with claimable plans, {failed} refresh errors", + "m.claimCancelled": "Verification cancelled — retry this account", + "btn.retry": "Retry", "m.refreshClaimAcctErr": "\"{name}\" refresh failed: {err}", "m.autoClaimOn": "Auto claim enabled", "m.autoClaimOnDetail": "Checks immediately once enabled, then every 10 minutes", diff --git a/src/locales/zh.js b/src/locales/zh.js index 432a763..6cc918f 100644 --- a/src/locales/zh.js +++ b/src/locales/zh.js @@ -75,7 +75,9 @@ export const zh = { "m.claimStartsAt": "额度将于 {time} 生效", "m.claimEndsAt": "有效期至 {time}", "m.claimNextAt": ",{time} 后可再领", - "m.refreshClaimDone": "已刷新 {n} 个账号,{k} 个有可领套餐", + "m.refreshClaimDone": "已检查 {n} 个账号,{k} 个有可领套餐,{failed} 个刷新异常", + "m.claimCancelled": "验证已取消,可单独重试", + "btn.retry": "重试", "m.refreshClaimAcctErr": "「{name}」刷新失败:{err}", "m.autoClaimOn": "已开启自动领取", "m.autoClaimOnDetail": "开启后立即检测一次,此后每 10 分钟一次", diff --git a/src/main.js b/src/main.js index d1c2ffb..fb07da0 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import { esc, toast, openPwModal, openConfirmModal, openProviderModal, installDelegation, dismissSplash } from "./ui.js"; import { ic } from "./icons.js"; import { init, t, has, lang, localeTag, stripErr } from "./i18n.js"; +import { createRequestQueue } from "./request-queue.js"; const $app = document.getElementById("app"); let state = null; @@ -11,6 +12,11 @@ let busy = false; let appVer = ""; let acctQuota = {}; let claimable = {}; +const request = createRequestQueue(3); +const quotaRequests = new Map(); +const previewRequests = new Map(); +const eligibilityRequests = new Map(); +const claimFailures = {}; let claimAllRunning = false; const REFRESH_CLAIM_COOLDOWN_MS = 60_000; let refreshClaim = { running: false, done: 0, total: 0, cooldownUntil: 0 }; @@ -75,50 +81,94 @@ async function guard(fn) { } } -async function loadAcctQuota(id) { +function loadAcctQuota(id, priority = true) { + if (quotaRequests.has(id)) { + if (priority) request(`quota:${id}`, () => {}, true); + return quotaRequests.get(id); + } const cur = acctQuota[id] || {}; - if (cur.busy) return; - acctQuota[id] = { busy: true }; + acctQuota[id] = { ...cur, busy: true }; if (!uiLocked()) render(); + const work = request(`quota:${id}`, async () => { try { const data = await invoke("get_account_quota", { id }); acctQuota[id] = { data, err: null, busy: false }; } catch (e) { - acctQuota[id] = { data: null, err: stripErr(e), busy: false }; + acctQuota[id] = { data: cur.data, err: stripErr(e), busy: false }; } if (!uiLocked()) render(); + }, priority).finally(() => quotaRequests.delete(id)); + quotaRequests.set(id, work); + return work; } -async function loadClaimPreview(id) { +function loadClaimPreview(id) { + if (eligibilityRequests.has(id)) return eligibilityRequests.get(id); + if (previewRequests.has(id)) return previewRequests.get(id); const cur = claimable[id] || {}; - if (cur.busy) return; claimable[id] = { plans: cur.plans || [], busy: true }; + const work = request(`preview:${id}`, async () => { try { const plans = await invoke("claim_preview", { id }); claimable[id] = { plans: plans || [], err: null, busy: false }; } catch (e) { claimable[id] = { plans: cur.plans || [], err: String(e), busy: false }; } + }, true).finally(() => previewRequests.delete(id)); + previewRequests.set(id, work); + return work; +} + +function refreshEligibility(id) { + if (eligibilityRequests.has(id)) return eligibilityRequests.get(id); + const previous = previewRequests.get(id); + const work = Promise.resolve(previous).then(() => { + claimable[id] = { ...(claimable[id] || {}), busy: true }; + if (!uiLocked()) render(); + return request(`eligibility:${id}`, async () => { + try { + const r = await invoke("claim_refresh", { id }); + claimable[id] = { plans: r.plans || [], err: r.activationError ? stripErr(r.activationError) : null, busy: false }; + return r; + } catch (e) { + claimable[id] = { plans: claimable[id]?.plans || [], err: stripErr(e), busy: false }; + throw e; + } + }, true); + }).finally(() => eligibilityRequests.delete(id)); + eligibilityRequests.set(id, work); + return work; } let claimWaiter = null; -function waitForClaimResult(accountId, timeoutMs = 90000) { +function waitForClaimResult(accountId, claimId, timeoutMs = 90000) { return new Promise((resolve) => { let done = false; const finish = (v) => { if (!done) { done = true; claimWaiter = null; clearTimeout(t); resolve(v); } }; const t = setTimeout(() => finish(null), timeoutMs); - claimWaiter = { accountId, finish }; + claimWaiter = { accountId, claimId, finish }; }); } -async function awaitClaimPreviewFresh(id) { - claimable[id] = { ...(claimable[id] || {}), busy: true }; +async function performClaim(id, plan, auto = false, timeoutMs = 90000) { + const claimId = crypto.randomUUID(); + // Register before opening the WebView: setup can fail (or verification can + // finish) before claim_start returns to this window. + const result = waitForClaimResult(id, claimId, timeoutMs); try { - const plans = await invoke("claim_preview", { id }); - claimable[id] = { plans: plans || [], err: null, busy: false }; + await invoke("claim_start", { id, planId: plan.plan_id, auto, claimId }); } catch (e) { - claimable[id] = { plans: claimable[id]?.plans || [], err: String(e), busy: false }; + if (claimWaiter?.claimId === claimId) claimWaiter.finish({ ok: false }); + claimFailures[id] = stripErr(e); + throw e; } + const r = await result; + if (!r) { + await invoke("claim_cancel", { claimId }).catch(() => {}); + claimFailures[id] = t("m.claimTimeout"); + } + if (!uiLocked()) render(); + return r; } const accountName = (id) => (state?.accounts || []).find((a) => a.id === id)?.name || id; @@ -311,20 +361,19 @@ const actions = { }, async claim(id) { - if (claimActive || claimAllRunning || refreshClaim.running) { toast(t("m.claimBusy"), "warn"); return; } + if (claimActive || claimAllRunning || refreshClaim.running || autoClaimRunning) { toast(t("m.claimBusy"), "warn"); return; } const plans = claimable[id]?.plans || []; const plan = plans[0]; if (!plan) { toast(t("m.noClaimable"), "warn"); return; } claimActive = true; try { - await invoke("claim_start", { id, planId: plan.plan_id }); - toast(t("m.claimVerify", { name: plan.name || plan.plan_id }), "ok", t("m.claimVerifyDetail")); - const r = await waitForClaimResult(id); + const r = await performClaim(id, plan); if (!r) toast(t("m.claimTimeout"), "warn"); } catch (e) { toast(stripErr(e), "err"); } finally { claimActive = false; + if (!uiLocked()) render(); } }, @@ -342,30 +391,27 @@ const actions = { const plan = claimable[id].plans[0]; const name = state.accounts.find((a) => a.id === id)?.name || id; try { - await invoke("claim_start", { id, planId: plan.plan_id }); + const r = await performClaim(id, plan, false, 120000); + if (!r) toast(t("m.claimAcctTimeout", { name }), "warn"); } catch (e) { toast(t("m.claimAccountErr", { name, err: stripErr(e) }), "err"); continue; } - const r = await waitForClaimResult(id, 120000); - if (!r) { - toast(t("m.claimAcctTimeout", { name }), "warn"); - await invoke("claim_cancel").catch(() => {}); - } if (i < ids.length - 1) await new Promise((res) => setTimeout(res, 1200)); } } finally { claimAllRunning = false; claimActive = false; + if (!uiLocked()) render(); } }, - async refreshClaim() { - const ids = (state?.accounts || []).map((a) => a.id); + async refreshClaim(onlyId) { + const ids = (state?.accounts || []).map((a) => a.id).filter((id) => !onlyId || id === onlyId); if (!ids.length) return; const now = Date.now(); if (refreshClaim.running || claimAllRunning || (claimActive && !autoClaimRunning)) return; - if (now < refreshClaim.cooldownUntil) { + if (!onlyId && now < refreshClaim.cooldownUntil) { toast(t("btn.refreshClaimCooldownTitle", { n: Math.ceil((refreshClaim.cooldownUntil - now) / 1000) }), "warn"); return; } @@ -386,33 +432,27 @@ const actions = { } } let okCount = 0; + let failedCount = 0; try { - for (let i = 0; i < ids.length; i++) { - const id = ids[i]; - const name = state.accounts.find((a) => a.id === id)?.name || id; - refreshClaim.done = i + 1; - claimable[id] = { plans: claimable[id]?.plans || [], busy: true }; - if (!uiLocked()) render(); + await Promise.all(ids.map(async (id) => { try { - const r = await invoke("claim_refresh", { id }); - claimable[id] = { plans: r.plans || [], err: null, busy: false }; + const r = await refreshEligibility(id); if ((r.plans || []).length) okCount++; - if (r.activationError) toast(t("m.refreshClaimAcctErr", { name, err: stripErr(r.activationError) }), "warn"); + if (r.activationError) failedCount++; } catch (e) { - claimable[id] = { plans: claimable[id]?.plans || [], err: String(e), busy: false }; - toast(t("m.refreshClaimAcctErr", { name, err: stripErr(e) }), "err"); + failedCount++; + } finally { + refreshClaim.done++; + if (!uiLocked()) render(); } - scheduleNext(id); - if (!uiLocked()) render(); - if (i < ids.length - 1) await new Promise((res) => setTimeout(res, 5000)); - } + })); } finally { refreshClaim.running = false; refreshClaim.cooldownUntil = Date.now() + REFRESH_CLAIM_COOLDOWN_MS; if (!uiLocked()) render(); setTimeout(stopRefreshTickerIfIdle, 1100); } - toast(t("m.refreshClaimDone", { n: ids.length, k: okCount }), "ok"); + toast(t("m.refreshClaimDone", { n: ids.length, k: okCount, failed: failedCount }), failedCount ? "warn" : "ok"); }, async toggleAutoClaim() { @@ -482,8 +522,7 @@ async function autoClaimTick() { let gotAny = false; claimable[id] = { ...(claimable[id] || {}), busy: true }; try { - const r = await invoke("claim_refresh", { id }); - claimable[id] = { plans: r.plans || [], err: null, busy: false }; + await refreshEligibility(id); } catch (e) { claimable[id] = { plans: claimable[id]?.plans || [], err: String(e), busy: false }; autoClaimCooldown[id] = Date.now() + AUTO_CLAIM_INTERVAL_MS; @@ -499,16 +538,14 @@ async function autoClaimTick() { progressed = false; const plan = claimable[id]?.plans?.[0]; if (!plan) break; + let r; try { - await invoke("claim_start", { id, planId: plan.plan_id, auto: true }); + r = await performClaim(id, plan, true, AUTO_CLAIM_WAIT_MS); } catch (e) { - await invoke("claim_cancel").catch(() => {}); autoClaimCooldown[id] = Date.now() + AUTO_CLAIM_INTERVAL_MS; break; } - const r = await waitForClaimResult(id, AUTO_CLAIM_WAIT_MS); if (!r) { - await invoke("claim_cancel").catch(() => {}); autoClaimCooldown[id] = Date.now() + AUTO_CLAIM_INTERVAL_MS; break; } @@ -517,7 +554,7 @@ async function autoClaimTick() { break; } gotAny = true; roundClaimed++; - await awaitClaimPreviewFresh(id); + await loadClaimPreview(id); if (!uiLocked()) render(); progressed = true; await new Promise((res) => setTimeout(res, 1200)); @@ -633,15 +670,21 @@ function grantLabel(plan) { function claimStripHtml(id) { const c = claimable[id]; const plan = c?.plans?.[0]; - if (!plan) return ""; + const failure = claimFailures[id] || c?.err; + const disabled = claimAllRunning || refreshClaim.running || claimActive || autoClaimRunning || c?.busy; + const error = failure ? `
+ ${esc(failure)} + +
` : ""; + if (!plan) return error; const grants = grantLabel(plan); const label = plan.name || plan.plan_id; - return ` + return error + `
${ic("gift", 15)} ${esc(label)} ${grants ? `${esc(grants)}` : ""} - +
`; } @@ -694,7 +737,7 @@ function acctQuotaSlot(id) { inner = `${t("q.loading")}`; } else if (q?.err) { const msg = q.err.length > 46 ? q.err.slice(0, 46) + "…" : q.err; - inner = `${esc(msg)}`; + inner = `${esc(msg)}`; } else if (q?.data) { const plans = q.data.plans || []; if (plans.length >= 2) { @@ -881,14 +924,17 @@ listen("tray-action", (ev) => { listen("claim://result", (ev) => { const p = ev.payload || {}; - if (claimWaiter && claimWaiter.accountId === p.accountId) claimWaiter.finish(p); + if (!claimWaiter || claimWaiter.claimId !== p.claimId) return; + claimWaiter.finish(p); if (p.ok === false) { + claimFailures[p.accountId] = p.message || t("m.unknownErr"); let msg = p.message || t("m.unknownErr"); if (p.code === 1005 && p.nextAt) { msg += t("m.claimNextAt", { time: new Date(p.nextAt).toLocaleString(localeTag(), { hour12: false }) }); } toast(t("m.claimFailed", { name: p.accountName, msg }), "err"); } else { + delete claimFailures[p.accountId]; const bits = []; const now = p.serverTime || Date.now(); if (p.startsAt && p.startsAt > now) bits.push(t("m.claimStartsAt", { time: new Date(p.startsAt).toLocaleString(localeTag(), { hour12: false }) })); @@ -904,13 +950,32 @@ listen("claim://result", (ev) => { } }); -listen("captcha://interactive", () => { - if (!autoClaimRunning || !claimWaiter) return; - const id = claimWaiter.accountId; - invoke("claim_cancel").catch(() => {}); +listen("claim://cancelled", (ev) => { + const p = ev.payload || {}; + if (claimWaiter?.claimId !== p.claimId) return; + claimFailures[p.accountId] = t("m.claimCancelled"); + claimWaiter.finish({ ok: false, code: "cancelled" }); +}); + +listen("captcha://failed", async (ev) => { + const p = ev.payload || {}; + if (!claimWaiter || claimWaiter.claimId !== p.claimId) return; + claimFailures[claimWaiter.accountId] = p.message || t("c.cfgFail"); + if (!autoClaimRunning && !claimAllRunning && !p.terminal) { if (!uiLocked()) render(); return; } + const waiter = claimWaiter; + await invoke("claim_cancel", { claimId: waiter.claimId }).catch(() => {}); + waiter.finish({ ok: false, code: "setup", message: p.message }); +}); + +listen("captcha://interactive", async (ev) => { + if (!autoClaimRunning || !claimWaiter || claimWaiter.claimId !== ev.payload?.claimId) return; + const waiter = claimWaiter; + const id = waiter.accountId; + await invoke("claim_cancel", { claimId: waiter.claimId }).catch(() => {}); autoClaimCooldown[id] = Date.now() + 60 * 60 * 1000; toast(t("m.autoClaimInteractive", { name: accountName(id) }), "warn", t("m.autoClaimInteractiveDetail")); - claimWaiter.finish({ ok: false, code: "interactive" }); + claimFailures[id] = t("c.interactive"); + waiter.finish({ ok: false, code: "interactive" }); }); listen("oauth://done", (ev) => { @@ -956,16 +1021,17 @@ async function sweepTick() { if (ticking) return; enrollAccounts(); const now = Date.now(); - const due = (state?.accounts || []).find( + const due = (state?.accounts || []).filter( (a) => (quotaDue[a.id] ?? Infinity) <= now && !acctQuota[a.id]?.busy && !claimable[a.id]?.busy, ); - if (!due) return; + if (!due.length) return; ticking = true; - const dueAt = quotaDue[due.id]; try { - await loadAcctQuota(due.id); - if (quotaDue[due.id] === dueAt) scheduleNext(due.id); - if (!uiLocked()) render(); + await Promise.all(due.map(async ({ id }) => { + const dueAt = quotaDue[id]; + await loadAcctQuota(id, false); + if (quotaDue[id] === dueAt) scheduleNext(id); + })); } finally { ticking = false; } diff --git a/src/request-queue.js b/src/request-queue.js new file mode 100644 index 0000000..471ac99 --- /dev/null +++ b/src/request-queue.js @@ -0,0 +1,36 @@ +// One shared budget for quota, preview and eligibility requests. Duplicate clicks +// share the pending promise; a rejected request releases its slot as usual. +export function createRequestQueue(limit = 3) { + const pending = new Map(); + const queue = []; + let active = 0; + function drain() { + while (active < limit && queue.length) { + const { key, task, resolve, reject } = queue.shift(); + active++; + const finish = (settle, value) => { + active--; + pending.delete(key); + settle(value); + drain(); + }; + Promise.resolve().then(task).then((value) => finish(resolve, value), (error) => finish(reject, error)); + } + } + return (key, task, priority = false) => { + if (pending.has(key)) { + if (priority) { + const index = queue.findIndex((job) => job.key === key); + if (index >= 0) { queue[index].priority = true; queue.sort((a, b) => Number(b.priority) - Number(a.priority)); } + } + return pending.get(key); + } + const promise = new Promise((resolve, reject) => { + queue.push({ key, task, resolve, reject, priority }); + queue.sort((a, b) => Number(b.priority) - Number(a.priority)); + }); + pending.set(key, promise); + drain(); + return promise; + }; +} diff --git a/src/styles.css b/src/styles.css index eced75f..b2da701 100644 --- a/src/styles.css +++ b/src/styles.css @@ -973,7 +973,7 @@ input:focus-visible, word-break: break-all; min-height: 19px; } -#cap-btn { +#cap-btn, #cap-retry { font-family: var(--body); font-size: 14px; font-weight: 600; @@ -984,8 +984,10 @@ input:focus-visible, border: 1px solid var(--amber); color: var(--amber-ink); } -#cap-btn:hover { background: #f0b055; } -#cap-btn:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; } +#cap-btn:hover, #cap-retry:hover { background: #f0b055; } +#cap-btn:focus-visible, #cap-retry:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; } +.claim-error { color: var(--red); } +.claim-error .claim-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .cap-foot { position: absolute; bottom: 12px; diff --git a/tests/captcha.test.mjs b/tests/captcha.test.mjs new file mode 100644 index 0000000..6c50fcb --- /dev/null +++ b/tests/captcha.test.mjs @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { harness, turn } from "./ui-harness.mjs"; + +test("config failure offers retry and reports setup failure, never an interactive challenge or claim", async () => { + const calls = [], events = []; + let reloaded = 0; + const h = harness("captcha.js", async (cmd) => { + calls.push(cmd); + if (cmd === "get_state") return {}; + throw new Error("connection timed out"); + }, { emit: async (event, payload) => events.push({ event, payload }), location: { search: "?claimId=session-a", reload() { reloaded++; } } }); + await turn(); + assert.equal(h.nodes.get("cap-retry").hidden, false); + assert.deepEqual(calls, ["get_state", "claim_captcha_config"]); + assert.equal(events[0].event, "captcha://failed"); + assert.equal(events[0].payload.claimId, "session-a"); + h.nodes.get("cap-retry").click(); + assert.equal(reloaded, 1); +}); + +test("SDK sees configuration on load; duplicate success submits only once with the same claim ID", async () => { + let h, options; + const calls = []; + const fakeWindow = {}; + h = harness("captcha.js", async (cmd, args) => { + calls.push({ cmd, args }); + return cmd === "get_state" ? {} : { enabled: true, scene_id: "scene", region: "sgp", prefix: "prefix" }; + }, { window: fakeWindow }); + h.context.document.head.appendChild = (script) => { + assert.equal(fakeWindow.AliyunCaptchaConfig.region, "sgp"); + fakeWindow.initAliyunCaptcha = (value) => { options = value; value.getInstance({ startTracelessVerification() {} }); }; + script.onload(); + }; + await turn(); + options.success("verified-param"); options.success("duplicate"); + await turn(); + const submissions = calls.filter(({ cmd }) => cmd === "claim_captcha_submit"); + assert.equal(submissions.length, 1); + assert.equal(submissions[0].args.claimId, "test-claim"); + assert.equal(h.timers.size, 0); +}); + +test("SDK load hang terminates with a retry instead of hanging the batch", async () => { + const events = []; + const h = harness("captcha.js", async (cmd) => cmd === "get_state" ? {} : { enabled: true, scene_id: "s" }, + { emit: async (name) => events.push(name) }); + await turn(); + const timer = [...h.timers.values()].find(({ ms }) => ms === 12000); + assert.ok(timer); + timer.fn(); + await turn(); + assert.equal(h.nodes.get("cap-retry").hidden, false); + assert.ok(events.includes("captcha://failed")); +}); + +test("initialization timeout ignores late SDK callbacks", async () => { + let options; + const calls = [], events = []; + const h = harness("captcha.js", async (cmd) => { + calls.push(cmd); + return cmd === "get_state" ? {} : { enabled: true, scene_id: "s" }; + }, { window: { initAliyunCaptcha(value) { options = value; } }, emit: async (name) => events.push(name) }); + await turn(); + [...h.timers.values()].find(({ ms }) => ms === 12000).fn(); + options.success("late-result"); + assert.equal(calls.includes("claim_captcha_submit"), false); + assert.equal(h.nodes.get("cap-retry").hidden, false); + assert.deepEqual(events, ["captcha://failed"]); +}); diff --git a/tests/refresh.test.mjs b/tests/refresh.test.mjs new file mode 100644 index 0000000..2b2ce67 --- /dev/null +++ b/tests/refresh.test.mjs @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { harness, turn } from "./ui-harness.mjs"; + +test("eligibility completes out of order, counts settlements and preserves failed accounts", async () => { + const pending = new Map(); + const h = harness("main.js", (cmd, { id }) => { + assert.equal(cmd, "claim_refresh"); + return new Promise((resolve, reject) => pending.set(id, { resolve, reject })); + }); + h.run("state.accounts = Array.from({length: 20}, (_, i) => ({id: String(i), name: String(i)})); claimable['0'] = {plans:[{plan_id:'saved'}]};"); + const work = h.run("actions.refreshClaim()"); + await turn(); + assert.equal(pending.size, 3); + assert.equal(h.run("refreshClaim.done"), 0); + pending.get("1").resolve({ plans: [{ plan_id: "new" }] }); pending.delete("1"); + await turn(); + assert.equal(h.run("refreshClaim.done"), 1); + pending.get("0").reject("connection timed out"); pending.delete("0"); + while (h.run("refreshClaim.done") < 20) { + await turn(); + for (const [id, p] of pending) { p.resolve({ plans: [] }); pending.delete(id); } + } + await work; + assert.equal(h.run("claimable['0'].plans[0].plan_id"), "saved"); + assert.match(h.run("claimStripHtml('0')"), /connection timed out/); + assert.match(h.run("claimStripHtml('0')"), /actions.refreshClaim\('0'\)/); + assert.equal(h.run("refreshClaim.running"), false); + assert.match(h.toasts.at(-1)[0], /"failed":1/); +}); + +test("all due quotas drain in one sweep with a shared three-slot budget and no duplicate clicks", async () => { + let active = 0, peak = 0, calls = 0; + const pending = []; + const h = harness("main.js", async (cmd, { id }) => { + calls++; peak = Math.max(peak, ++active); + await new Promise((r) => pending.push(r)); active--; + if (id === "1") throw new Error("offline"); + return { plans: [] }; + }); + h.run("state.accounts = Array.from({length:20}, (_,i) => ({id:String(i)}));"); + const work = h.run("sweepTick()"); + const duplicate = h.run("loadAcctQuota('0')"); + await turn(); assert.equal(calls, 3); + while (calls < 20 || active) { pending.splice(0).forEach((r) => r()); await turn(); } + await work; await duplicate; + assert.equal(calls, 20); assert.equal(peak, 3); + assert.match(h.run("acctQuota['1'].err"), /offline/); + assert.equal(h.run("Object.values(quotaDue).every(at => at > Date.now())"), true); +}); + +test("a preview already in flight cannot overwrite a newer eligibility result", async () => { + let resolvePreview; + const calls = []; + const h = harness("main.js", (cmd) => { + calls.push(cmd); + if (cmd === "claim_preview") return new Promise((r) => { resolvePreview = r; }); + return Promise.resolve({ plans: [{ plan_id: "fresh" }] }); + }); + const preview = h.run("loadClaimPreview('a')"); await turn(); + const refresh = h.run("refreshEligibility('a')"); await turn(); + assert.equal(calls.length, 1); + resolvePreview([{ plan_id: "old" }]); + await preview; await refresh; + assert.equal(h.run("claimable.a.plans[0].plan_id"), "fresh"); +}); + +test("batch setup failure before claim_start returns cancels promptly and ignores stale events", async () => { + let h, claimId; + const calls = []; + h = harness("main.js", async (cmd, args) => { + calls.push(cmd); + if (cmd === "claim_start") { + claimId = args.claimId; + await h.listeners.get("captcha://failed")({ payload: { claimId, message: "config timeout" } }); + } + }); + h.run("claimAllRunning = true;"); + const result = await h.run("performClaim('a', {plan_id:'p'})"); + assert.equal(result.code, "setup"); + assert.deepEqual(calls, ["claim_start", "claim_cancel"]); + assert.equal(h.run("claimFailures.a"), "config timeout"); + h.run("waitForClaimResult('b', 'new-session')"); + await h.listeners.get("captcha://failed")({ payload: { claimId, message: "late error" } }); + assert.equal(h.run("claimWaiter.accountId"), "b"); + assert.equal(calls.length, 2); + h.run("claimWaiter.finish(null)"); +}); + +test("manual setup failure keeps the claim pending for in-dialog retry", async () => { + const h = harness("main.js", async () => {}); + const result = h.run("waitForClaimResult('a', 'manual')"); + await h.listeners.get("captcha://failed")({ payload: { claimId: "manual", message: "retry config" } }); + assert.equal(h.run("claimWaiter.claimId"), "manual"); + h.listeners.get("claim://cancelled")({ payload: { claimId: "manual", accountId: "a" } }); + assert.equal((await result).code, "cancelled"); +}); + +test("individual failed-account retry works during the bulk cooldown and only refreshes that account", async () => { + const calls = []; + const h = harness("main.js", async (_, { id }) => { calls.push(id); return { plans: [] }; }); + h.run("state.accounts = [{id:'a'}, {id:'b'}]; refreshClaim.cooldownUntil = Date.now() + 60000;"); + await h.run("actions.refreshClaim('b')"); + assert.deepEqual(calls, ["b"]); + await h.run("actions.refreshClaim()"); + assert.deepEqual(calls, ["b"]); +}); + +test("terminal submission error finishes a manual attempt so the account can be retried", async () => { + const calls = []; + const h = harness("main.js", async (cmd) => calls.push(cmd)); + const result = h.run("waitForClaimResult('a', 'manual')"); + await h.listeners.get("captcha://failed")({ payload: { claimId: "manual", message: "submit failed", terminal: true } }); + assert.equal((await result).code, "setup"); + assert.deepEqual(calls, ["claim_cancel"]); + assert.equal(h.run("claimFailures.a"), "submit failed"); +}); diff --git a/tests/request-queue.test.mjs b/tests/request-queue.test.mjs new file mode 100644 index 0000000..cd38ffa --- /dev/null +++ b/tests/request-queue.test.mjs @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequestQueue } from "../src/request-queue.js"; + +const turn = () => new Promise(setImmediate); + +test("twenty requests use at most three slots and a failure does not stall the queue", async () => { + const request = createRequestQueue(3); + let active = 0, peak = 0; + const started = [], releases = []; + const jobs = Array.from({ length: 20 }, (_, id) => request(String(id), async () => { + started.push(id); + peak = Math.max(peak, ++active); + await new Promise((resolve) => releases.push(resolve)); + active--; + if (id === 1) throw new Error("offline"); + return id; + })); + const all = Promise.allSettled(jobs); + await turn(); + assert.equal(started.length, 3); + while (started.length < 20 || active) { + releases.splice(0).forEach((release) => release()); + await turn(); + } + const results = await all; + assert.equal(peak, 3); + assert.equal(results.filter((r) => r.status === "fulfilled").length, 19); + assert.equal(results[1].reason.message, "offline"); +}); + +test("pending duplicates share work, but a completed or failed key can be retried", async () => { + const request = createRequestQueue(3); + let calls = 0, release; + const first = request("account", async () => { calls++; await new Promise((r) => { release = r; }); return 1; }); + const second = request("account", () => { throw new Error("duplicate"); }); + assert.equal(first, second); + await turn(); release(); + assert.equal(await first, 1); + assert.equal(await request("account", () => ++calls), 2); + await assert.rejects(request("account", () => { throw new Error("failed"); })); + assert.equal(await request("account", () => 3), 3); +}); + +test("interactive work and promoted duplicate clicks pass queued background work", async () => { + const request = createRequestQueue(1), started = []; + let release; + const running = request("running", () => new Promise((r) => { release = r; })); + const background = request("background", () => started.push("background")); + const promoted = request("promoted", () => started.push("promoted")); + request("promoted", () => assert.fail("duplicate work"), true); + const interactive = request("interactive", () => started.push("interactive"), true); + await turn(); release(); + await Promise.all([running, background, promoted, interactive]); + assert.deepEqual(started, ["promoted", "interactive", "background"]); +}); diff --git a/tests/ui-harness.mjs b/tests/ui-harness.mjs new file mode 100644 index 0000000..ae38b07 --- /dev/null +++ b/tests/ui-harness.mjs @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import vm from "node:vm"; +import { randomUUID } from "node:crypto"; +import { createRequestQueue } from "../src/request-queue.js"; + +export const turn = () => new Promise(setImmediate); + +export function harness(file, invoke, extra = {}) { + const listeners = new Map(), timers = new Map(), nodes = new Map(), toasts = []; + let timerId = 0; + const node = (id) => { + if (!nodes.has(id)) nodes.set(id, { hidden: true, textContent: "", className: "", focus() {}, remove() {}, + addEventListener(name, fn) { this[name] = fn; } }); + return nodes.get(id); + }; + const context = vm.createContext({ + console, URLSearchParams, crypto: { randomUUID }, createRequestQueue, + document: { getElementById: node, querySelector: node, addEventListener() {}, createElement: () => ({ remove() {} }), head: { appendChild() {} } }, + window: {}, location: { search: "?claimId=test-claim", reload() {} }, + invoke, listen: (name, fn) => listeners.set(name, fn), emit: async () => {}, + setTimeout: (fn, ms) => { timers.set(++timerId, { fn, ms }); return timerId; }, + clearTimeout: (id) => timers.delete(id), setInterval: () => ++timerId, clearInterval() {}, + t: (key, data) => key + (data ? JSON.stringify(data) : ""), init() {}, lang: () => "en", localeTag: () => "en-US", has: () => true, + stripErr: String, esc: (s) => String(s).replaceAll("<", "<"), ic: () => "", toast: (...args) => toasts.push(args), + installDelegation() {}, dismissSplash() {}, ...extra, + }); + let source = readFileSync(new URL(`../src/${file}`, import.meta.url), "utf8").replace(/^import .*;\r?\n/gm, ""); + // Exercise the actual handlers; startup polling is independently driven by the tests. + if (file === "main.js") source = source.slice(0, source.lastIndexOf("(async () => {")); + vm.runInContext(source, context, { filename: file }); + const run = (code) => vm.runInContext(code, context); + if (file === "main.js") run("render = () => {}; state = { accounts: [], auto_claim: false };"); + return { context, run, listeners, timers, nodes, toasts }; +}