Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ CLI 密码(export / import):优先环境变量 `ZSW_PASSWORD`(不出现

## 常见问题

### 刷新慢或领取时验证配置加载失败?

额度、套餐预览和资格刷新共用最多 3 个并行请求,手动操作优先于后台额度刷新。失败账号保留错误详情,可在账号行单独重试;批量资格刷新仍有 60 秒冷却,单账号重试不受该整轮冷却限制。

验证配置成功后在当前进程缓存 5 分钟,连接类故障最多重试一次。配置或组件加载失败时,手动验证窗口提供「重试」;批量领取会保留失败账号并继续其他账号。领取请求本身不会自动重发,人工验证仍需用户完成。

### macOS 弹出「麦克风 / 辅助功能 / 录屏」权限询问?

全部拒绝即可,不影响任何功能。
Expand Down
1 change: 1 addition & 0 deletions captcha.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<!-- Aliyun SDK popup 容器 + 触发按钮(无感通过时不可见;风控时显示给用户点) -->
<div id="cap-holder"></div>
<button id="cap-btn" type="button" hidden></button>
<button id="cap-retry" type="button" hidden></button>
<div class="cap-foot"></div>
</div>
<script type="module" src="/src/captcha.js"></script>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
150 changes: 138 additions & 12 deletions src-tauri/src/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -121,10 +122,11 @@ fn decrypt_credential(v: &str, secret: &str) -> Option<String> {
}

fn agent() -> ureq::Agent {
ureq::AgentBuilder::new()
static AGENT: OnceLock<ureq::Agent> = 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";
Expand Down Expand Up @@ -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,
Expand All @@ -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| {
Expand Down Expand Up @@ -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<CaptchaConfig, ConfigError>, mut pause: impl FnMut()) -> Result<CaptchaConfig, String> {
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<CaptchaConfig, String> {
let mut req = agent().get(CLIENT_CONFIGS_URL);
static CACHE: Mutex<ConfigCache> = 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<CaptchaConfig, ConfigError> {
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();
Expand Down Expand Up @@ -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<CaptchaConfig, ConfigError> {
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");
}
}
}
2 changes: 2 additions & 0 deletions src-tauri/src/i18n.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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", "活动已结束或套餐暂不可领取"),
Expand Down Expand Up @@ -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"),
Expand Down
Loading