diff --git a/.gitignore b/.gitignore index c1c01d4..e85863a 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ nul .zcode/ __pycache__/ +.tmp/ diff --git a/README.en.md b/README.en.md index 8b19365..e8201bf 100644 --- a/README.en.md +++ b/README.en.md @@ -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 diff --git a/README.md b/README.md index f03fc44..458ce99 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..157c751 --- /dev/null +++ b/ROADMAP.md @@ -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`。 diff --git a/package-lock.json b/package-lock.json index 013977d..92037a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zcode-switch", - "version": "1.3.2", + "version": "1.7.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zcode-switch", - "version": "1.3.2", + "version": "1.7.0-beta.1", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2.1.1" diff --git a/package.json b/package.json index 678e4f6..ba6a63a 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/dist.mjs b/scripts/dist.mjs index c4b6ab4..db31f0a 100644 --- a/scripts/dist.mjs +++ b/scripts/dist.mjs @@ -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) diff --git a/scripts/tauri.mjs b/scripts/tauri.mjs new file mode 100644 index 0000000..a6d4a07 --- /dev/null +++ b/scripts/tauri.mjs @@ -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); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7b0fb5..8f5df21 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { - 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 })); }; diff --git a/src-tauri/src/quota.rs b/src-tauri/src/quota.rs index 7f05dae..4966ad8 100644 --- a/src-tauri/src/quota.rs +++ b/src-tauri/src/quota.rs @@ -133,6 +133,25 @@ pub(crate) fn zcode_app_version() -> String { static CACHE: std::sync::OnceLock = 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", @@ -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") @@ -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 { - 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(); @@ -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 { + 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![Some("lite"), Some("start"), Some("start")]); + assert_eq!(quota.plans.iter().map(|p| p.remaining).collect::>(), 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, diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 108842f..6c78b2c 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -480,13 +480,34 @@ pub fn normalize_zcode_path(raw: &str) -> Option { 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()) { diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..d08a02e --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,5 @@ +{ + "bundle": { + "targets": ["app", "dmg"] + } +} diff --git a/src/main.js b/src/main.js index d1c2ffb..c3b64c9 100644 --- a/src/main.js +++ b/src/main.js @@ -645,8 +645,10 @@ function claimStripHtml(id) { `; } -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(); @@ -682,7 +684,7 @@ function planGroupHtml(p) { ${esc(label)} ${exp ? `${esc(t("q.validUntilShort", { date: exp.text }))}` : ""} - ${slotRowsHtml(p.items)} + ${slotRowsHtml(p.items, p.tier_code)} `; } @@ -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); } } }