Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ nul
.zcode/

__pycache__/
.tmp/
9 changes: 7 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,18 @@ The embedded pages (such as the login page) are rendered by the system WebView,

## Build

Install Node.js and the Rust toolchain (`cargo` and `rustc`) first. macOS also needs Xcode Command Line Tools (`xcode-select --install`). If Cargo is missing, follow the [official Rust installation guide](https://rust-lang.org/tools/install/), run `source "$HOME/.cargo/env"`, and check `cargo --version`.

```bash
npm install
npm run tauri dev # development (HMR)
npm run tauri build # NSIS installer
npm run tauri build # macOS: APP + DMG; Windows: NSIS
npm run dist # copy installers into release/
```

Windows-first (path detection / process management / tray are all Win32 semantics).
macOS artifacts are under `src-tauri/target/release/bundle/macos/` and `src-tauri/target/release/bundle/dmg/`. Builds use the host architecture. Install ZCode before use; the app detects standard Applications locations, and settings accept either a `.app` path or its internal executable.

The project CLI detects Cargo in the user directory. Mac packaging skips Finder window customization to avoid depending on Finder automation permissions.

## License

Expand Down
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@ CLI 密码(export / import):优先环境变量 `ZSW_PASSWORD`(不出现

## 构建

先安装 Node.js 和 Rust 工具链(需要 `cargo`、`rustc`)。macOS 还需要 Xcode Command Line Tools,使用 `xcode-select --install` 安装。

若出现 `cargo metadata ... No such file or directory`,说明 Cargo 未安装或不在 PATH。按 [Rust 官方安装说明](https://rust-lang.org/tools/install/) 安装后,执行 `source "$HOME/.cargo/env"`,再用 `cargo --version` 检查。

```bash
npm install
npm run tauri dev # 开发(HMR)
npm run tauri build # NSIS 安装包
npm run tauri build # macOS:APP + DMG;Windows:NSIS
npm run dist # 将 macOS DMG 或 Windows 安装包、便携版复制到 release/
```

Windows 优先(路径探测 / 进程管理 / 托盘均为 Win32 语义)。
macOS 构建产物在 `src-tauri/target/release/bundle/macos/` 和 `src-tauri/target/release/bundle/dmg/`。Intel Mac 构建 x64 版本,Apple 芯片 Mac 构建 ARM64 版本。使用前需安装 ZCode;默认探测系统或用户 Applications 目录,也可以在设置中填写 `.app` 或其内部可执行文件路径。

项目构建入口会识别用户目录下的 Cargo;Mac 打包自动跳过 Finder 窗口美化脚本,避免依赖 Finder 自动化权限。

## License

Expand Down
22 changes: 22 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 项目进度

## 进行中

- Windows 和 Apple 芯片 Mac 尚未完成本次改动的运行回归;Intel Mac 的具体业务测试项未逐项记录。

## 最近完成

- 2026-10-04 12:08:Lite 套餐界面隐藏每月工具调用额度,保留 5 小时和每周用量;过滤仅作用于 Lite 的月度时长项,其他套餐和额度展示保持原样。
- 2026-10-04 11:33:修复新版 BigModel 账号因登录提供方判断而漏查 Start 的问题;使用已有 Start 凭据查询并与付费套餐合并,保留无效凭据回退及接口部分失败时的付费额度展示。
- 2026-10-04 09:12:完成 macOS 构建适配:Mac 专用 APP/DMG 配置、Cargo 路径识别、非交互 DMG 打包、`.app` 路径支持、客户端真实版本读取及 DMG 分发。使用入口见 [README.md](README.md)。

## 最近验证

- 2026-10-04 12:10:用户确认套餐合并及 Lite 月工具额度隐藏均正常;提交前 JavaScript 语法、国际化和补丁空白检查通过。本次提交沿用已通过的 Rust 回归、真实账号查询、Mac 构建及 DMG 校验证据。
- 2026-10-04 12:08:直接执行实际渲染函数,确认 Lite 月工具额度隐藏,5 小时、每周及其他套餐额度保留,空输入正常;前端构建、国际化及补丁空白检查通过。`npm run dist` 成功生成新版 Intel APP/DMG,`hdiutil verify` 校验通过。
- 2026-10-04 11:33:新增回归用例在修改前因缺少 Start 通道失败,修复后全部 5 项 Rust 测试通过;覆盖 Lite 与多个 Start 合并、仅 Start、接口失败和无效凭据回退。国际化及补丁空白检查通过。
- 2026-10-04 11:33:`npm run dist` 成功生成修复版 Intel APP/DMG,安装包校验通过;新程序只读查询已保存账号,付费账号返回 Lite 与有效 Start 套餐,非付费账号仅返回 Start,未修改账号数据。
- 2026-10-04 10:50:用户确认当前 macOS 13.7.8 Intel 环境可正常使用;提交前重新检查国际化键、Node 脚本语法和补丁空白,均通过;生成产物已被 Git 忽略。
- 2026-10-04 09:12:在 macOS 13.7.8 Intel 上运行 `npm run dist`(包含 `npm run tauri build`)成功,生成 APP 和 x64 DMG;`hdiutil verify` 校验通过,打包临时磁盘已全部卸载。
- 2026-10-04 09:12:`cargo test --release --lib --locked --manifest-path src-tauri/Cargo.toml` 通过 1 项路径回归测试;隔离目录中的 CLI `state` 启动成功并识别本机 ZCode 路径;脚本语法及 `git diff --check` 通过。未验证 GUI 交互与真实账号业务流程。
- 2026-10-04 08:32:前端 `npm run build`、`npm run check:i18n`、分发脚本语法检查及 `git diff --check` 通过;系统 PlistBuddy 成功读取已安装 ZCode 版本 `3.14.4`。
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"tauri": "tauri",
"tauri": "node scripts/tauri.mjs",
"dist": "node scripts/dist.mjs",
"check:i18n": "node scripts/check-i18n-keys.mjs"
},
Expand Down
16 changes: 16 additions & 0 deletions scripts/dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,22 @@ try {
fail("构建失败(若是 os error 32:旧实例还在托盘驻留锁住了 exe,退出后重试)");
}

if (process.platform === "darwin") {
const bundleDir = join(root, "src-tauri/target/release/bundle/dmg");
if (!existsSync(bundleDir)) fail(`找不到打包目录:${bundleDir}`);
const packages = readdirSync(bundleDir).filter((f) => f.endsWith(".dmg") && f.includes(`_${ver}_`));
if (!packages.length) fail(`没有当前版本 ${ver} 的 DMG 安装包`);
const outDir = join(root, "release");
mkdirSync(outDir, { recursive: true });
for (const name of packages) {
const destination = join(outDir, name);
copyFileSync(join(bundleDir, name), destination);
console.log(`[dist] ✓ 安装包 ${name} (${mb(destination)} MB)`);
}
console.log(`[dist] 完成 → ${outDir}`);
process.exit(0);
}

const bundleDir = join(root, "src-tauri/target/release/bundle/nsis");
if (!existsSync(bundleDir)) fail(`找不到打包目录:${bundleDir}`);
const setups = readdirSync(bundleDir)
Expand Down
21 changes: 21 additions & 0 deletions scripts/tauri.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
const args = process.argv.slice(2);
const env = { ...process.env };
const cargoBin = join(env.CARGO_HOME || join(homedir(), ".cargo"), "bin");
if (existsSync(join(cargoBin, process.platform === "win32" ? "cargo.exe" : "cargo"))) {
env.PATH = [env.PATH, cargoBin].filter(Boolean).join(delimiter);
}
// Tauri uses CI=true to skip Finder automation when creating a DMG.
if (process.platform === "darwin" && ["build", "bundle"].includes(args[0])) {
env.CI = "true";
}
const cli = join(dirname(require.resolve("@tauri-apps/cli/package.json")), "tauri.js");
const result = spawnSync(process.execPath, [cli, ...args], { env, stdio: "inherit" });
if (result.error) console.error(result.error.message);
process.exit(result.status ?? 1);
9 changes: 4 additions & 5 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1082,11 +1082,10 @@ async fn import_sealed(app: AppHandle, files: Vec<(String, Value)>, password: St

#[tauri::command]
async fn pick_zcode_path(app: AppHandle) -> Result<serde_json::Value, String> {
let picked = app
.dialog()
.file()
.add_filter(&i18n::tr("dialog.exe"), &["exe"])
.blocking_pick_file();
let dialog = app.dialog().file();
#[cfg(windows)]
let dialog = dialog.add_filter(&i18n::tr("dialog.exe"), &["exe"]);
let picked = dialog.blocking_pick_file();
let Some(fp) = picked else {
return Ok(json!({ "picked": false }));
};
Expand Down
117 changes: 105 additions & 12 deletions src-tauri/src/quota.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,25 @@ pub(crate) fn zcode_app_version() -> String {
static CACHE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
CACHE
.get_or_init(|| {
#[cfg(target_os = "macos")]
{
let (path, _) = crate::store::effective_zcode_path(&crate::store::Paths::detect());
let executable = std::path::Path::new(&path);
if let Some(contents) = executable.parent().and_then(|p| p.parent()) {
let plist = contents.join("Info.plist");
if let Ok(out) = no_window("/usr/libexec/PlistBuddy")
.args(["-c", "Print :CFBundleShortVersionString"])
.arg(plist)
.output()
{
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
if out.status.success() && !version.is_empty() {
return normalize_version(&version);
}
}
}
}
#[cfg(windows)]
for hive in [
r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
r"HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
Expand Down Expand Up @@ -168,6 +187,7 @@ pub(crate) fn zcode_app_version() -> String {
.clone()
}

#[cfg(windows)]
fn is_zcode_display_name(name: &str) -> bool {
let l = name.to_lowercase();
l.contains("zcode") && !l.contains("switch")
Expand Down Expand Up @@ -548,18 +568,10 @@ pub(crate) fn is_no_plan_message(msg: &str) -> bool {
}

pub(crate) fn zai_billing_token(creds: &Value, config: Option<&Value>, secret: &str) -> Option<String> {
let jwt = safe_decrypt(creds.get("zcodejwttoken").and_then(|v| v.as_str()), secret);
let active = safe_decrypt(
creds.get("oauth:active_provider").and_then(|v| v.as_str()),
secret,
);
let use_jwt = if active.as_deref() != Some("bigmodel") {
jwt.is_some()
} else {
false
};
if use_jwt {
return jwt;
if let Some(jwt) = safe_decrypt(creds.get("zcodejwttoken").and_then(|v| v.as_str()), secret)
.filter(|token| !token.trim().is_empty())
{
return Some(jwt);
}
let providers = config?.get("provider")?.as_object()?;
let mut ordered: Vec<(&String, &Value)> = providers.iter().collect();
Expand Down Expand Up @@ -656,6 +668,87 @@ pub(crate) fn pick_channels(creds: &Value, config: Option<&Value>, secret: &str,
chans
}

#[cfg(test)]
mod multi_plan_tests {
use super::*;
use serde_json::json;

const PAID_KEY: &str = "synthetic-coding-plan-key-for-tests";
const START_TOKEN: &str = "synthetic-start-plan-token-for-tests";

fn paid_credentials() -> Value {
json!({
"oauth:active_provider": "bigmodel",
"zcodejwttoken": START_TOKEN,
"account-provider:bigmodel-coding-plan:api-key": PAID_KEY
})
}

fn fetch_plans(url: &str, token: &str) -> Result<Value, String> {
if url == QUOTA_LIMIT_URL {
assert_eq!(token, PAID_KEY);
Ok(json!({"code": 200, "data": {"level": "lite", "limits": [
{"type": "TIME_LIMIT", "unit": 5, "usage": 100.0, "currentValue": 7.0, "remaining": 93.0}
]}}))
} else if url == SUBSCRIPTION_URL {
assert_eq!(token, PAID_KEY);
Ok(json!({"code": 200, "data": []}))
} else {
assert!(url.starts_with(BILLING_BALANCE_URL));
assert_eq!(token, START_TOKEN);
Ok(json!({"code": 0, "data": {"plans": [
{"plan_id": "zcode-start-plan", "name": "ZCode Start Plan", "status": "active"},
{"plan_id": "zcode-start-plan-trust", "name": "ZCode Trust Build", "status": "active"}
], "balances": [
{"plan_id": "zcode-start-plan", "total_units": 1000.0, "used_units": 100.0, "remaining_units": 900.0},
{"plan_id": "zcode-start-plan-trust", "total_units": 2000.0, "used_units": 0.0, "remaining_units": 2000.0}
]}}))
}
}

#[test]
fn bigmodel_new_config_queries_paid_and_start_plans() {
let channels = pick_channels(&paid_credentials(), None, "test-secret", true);
assert_eq!(channels, vec![Channel::Monitor(PAID_KEY.into()), Channel::ZaiBilling(START_TOKEN.into())]);
let quota = query_channels_via(&channels, &fetch_plans).unwrap();
assert_eq!(quota.plans.len(), 3);
assert_eq!(quota.plans.iter().map(|p| p.tier_code.as_deref()).collect::<Vec<_>>(), vec![Some("lite"), Some("start"), Some("start")]);
assert_eq!(quota.plans.iter().map(|p| p.remaining).collect::<Vec<_>>(), vec![Some(93.0), Some(900.0), Some(2000.0)]);
assert_eq!(quota.plan_tier.as_deref(), Some("Lite"));
assert_eq!(quota.total, Some(100.0));
}

#[test]
fn start_only_account_does_not_gain_a_paid_plan() {
let credentials = json!({"oauth:active_provider": "bigmodel", "zcodejwttoken": START_TOKEN});
let channels = pick_channels(&credentials, None, "test-secret", true);
assert_eq!(channels, vec![Channel::ZaiBilling(START_TOKEN.into())]);
let quota = query_channels_via(&channels, &fetch_plans).unwrap();
assert_eq!(quota.plans.len(), 2);
assert!(quota.plans.iter().all(|p| p.tier_code.as_deref() == Some("start")));
assert!(pick_channels(&json!({}), None, "test-secret", true).is_empty());
}

#[test]
fn billing_failure_keeps_paid_quota() {
let channels = pick_channels(&paid_credentials(), None, "test-secret", true);
let quota = query_channels_via(&channels, &|url, token| {
if url.starts_with(BILLING_BALANCE_URL) { Err("billing unavailable".into()) }
else { fetch_plans(url, token) }
}).unwrap();
assert_eq!(quota.plans.len(), 1);
assert_eq!(quota.plan_tier.as_deref(), Some("Lite"));
}

#[test]
fn billing_token_falls_back_when_jwt_is_unusable() {
let config = json!({"provider": {"builtin:start-plan": {"enabled": true, "options": {"apiKey": START_TOKEN}}}});
for credentials in [json!({}), json!({"zcodejwttoken": ""}), json!({"zcodejwttoken": "enc:v1:invalid"})] {
assert_eq!(zai_billing_token(&credentials, Some(&config), "test-secret"), Some(START_TOKEN.into()));
}
}
}

fn no_plan_overview() -> QuotaOverview {
QuotaOverview {
plan_tier: None,
Expand Down
23 changes: 22 additions & 1 deletion src-tauri/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -480,13 +480,34 @@ pub fn normalize_zcode_path(raw: &str) -> Option<String> {
t = t[1..t.len() - 1].trim();
}
}
(!t.is_empty()).then(|| t.to_string())
if t.is_empty() {
return None;
}
#[cfg(target_os = "macos")]
if t.ends_with(".app") {
return Some(PathBuf::from(t).join("Contents/MacOS/ZCode").to_string_lossy().into_owned());
}
Some(t.to_string())
}

pub fn effective_zcode_path(paths: &Paths) -> (String, bool) {
effective_zcode_path_in(paths, &client_path_candidates(std::env::consts::OS))
}

#[cfg(all(test, target_os = "macos"))]
mod macos_path_tests {
use super::normalize_zcode_path;

#[test]
fn accepts_app_bundles_and_executables() {
let executable = "/Applications/ZCode.app/Contents/MacOS/ZCode";
assert_eq!(normalize_zcode_path(" \"/Applications/ZCode.app\" "), Some(executable.into()));
assert_eq!(normalize_zcode_path(executable), Some(executable.into()));
assert_eq!(normalize_zcode_path(" "), None);
assert_eq!(normalize_zcode_path("\"\""), None);
}
}

fn effective_zcode_path_in(paths: &Paths, candidates: &[String]) -> (String, bool) {
let s = load_settings(paths);
if let Some(p) = s.zcode_path.as_ref().filter(|p| PathBuf::from(p).exists()) {
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/tauri.macos.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"bundle": {
"targets": ["app", "dmg"]
}
}
10 changes: 6 additions & 4 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -645,8 +645,10 @@ function claimStripHtml(id) {
</div>`;
}

function slotRowsHtml(items) {
const list = items || [];
function slotRowsHtml(items, tierCode) {
const list = (items || []).filter((it) => !(
tierCode === "lite" && itemKind(it) === "duration" && it.window === "monthly"
));
const isWin = (it) => itemKind(it) !== "raw";
const wins = list.filter(isWin);
const best = new Map();
Expand Down Expand Up @@ -682,7 +684,7 @@ function planGroupHtml(p) {
<span class="pg-name" title="${esc(label)}">${esc(label)}</span>
${exp ? `<span class="pg-exp${exp.warn ? " warn-line" : ""}" title="${esc(t("q.validUntil", { date: exp.text }))}">${esc(t("q.validUntilShort", { date: exp.text }))}</span>` : ""}
</div>
${slotRowsHtml(p.items)}
${slotRowsHtml(p.items, p.tier_code)}
</div>`;
}

Expand All @@ -705,7 +707,7 @@ function acctQuotaSlot(id) {
if (wins.length) {
inner = wins.map((it) => winRowHtml(it, " mini")).join("");
} else {
inner = slotRowsHtml(items);
inner = slotRowsHtml(items, plans[0]?.tier_code);
}
}
}
Expand Down