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 };
+}