From 9d91c3864c197acbf5168e811e90154048f1bd1a Mon Sep 17 00:00:00 2001 From: zbmain Date: Tue, 29 Sep 2026 23:05:42 +0800 Subject: [PATCH] feat: prepare v0.1.7 cross-platform release --- .github/RELEASE_TEMPLATE.md | 10 +- README.md | 8 +- docs/new-api.md | 6 +- docs/releasing.md | 9 +- package.json | 2 +- src-tauri/Cargo.lock | 35 +----- src-tauri/Cargo.toml | 7 +- src-tauri/src/desktop.rs | 9 +- src-tauri/src/engine.rs | 81 ++++++++++++- src-tauri/src/files.rs | 163 ++++++++++++++++++++++++++- src-tauri/src/new_api/vault.rs | 187 ++++++++++++++++++++++++------- src-tauri/src/new_api_desktop.rs | 9 +- src-tauri/src/paths.rs | 19 +++- src-tauri/src/update_desktop.rs | 183 ++++++++++++++++++++++++------ src-tauri/tauri.conf.json | 5 +- src/App.tsx | 24 ++-- src/NewApiDialog.tsx | 30 ++--- src/api.ts | 2 + src/components.tsx | 2 +- src/demo.ts | 12 +- src/styles.css | 40 +++++-- src/test/new-api.test.tsx | 23 +++- src/types.ts | 1 + 23 files changed, 682 insertions(+), 185 deletions(-) diff --git a/.github/RELEASE_TEMPLATE.md b/.github/RELEASE_TEMPLATE.md index 1878726..269b33b 100644 --- a/.github/RELEASE_TEMPLATE.md +++ b/.github/RELEASE_TEMPLATE.md @@ -4,10 +4,10 @@ ### 本次更新 -- Codex 模型配置支持经验证的 Responses 协议写入;复制模型后可直接测试并保存。 -- 设置页提供「自动更新」和「测试计划」:默认检查正式版;开启测试计划后可接收 RC,关闭自动检查后仍可手动检查正式版。 -- 更新提示显示在侧边栏版本号旁;检查只读取清单,下载和安装仍需用户确认。 -- 优化模型、主题与界面细节,并补充跨平台自动化检查。 +- 应用本地数据统一保存到用户目录的 `.power-switch`,包括模型、API Key、New API 登录会话与备份;所有凭据按当前设计明文保存。旧数据目录和系统凭据库不会自动读取或迁移,升级后需要重新添加模型并登录 New API。 +- 下载更新清单与安装包时优先使用 `ghfast.top`,加速源不可用时回退 GitHub 原地址;更新包继续验签。 +- 优化添加模型弹窗的固定标题栏、设置界面与交互细节。 +- 覆盖模型保存、New API 会话持久化和跨平台构建的自动化检查。 - Microsoft Store MSIX 打包流程已经准备好,但商店包须在产品名称预留、包标识配置及商店审核后另行提供;此处下载的是 GitHub 分发版。 ### 下载选择 @@ -27,7 +27,7 @@ ### 安装须知 - 当前安装包未使用开发者证书签名或 Apple 公证,系统可能提示未知发布者或阻止首次打开。请先确认下载来源并核对校验值。 -- Windows 需要 WebView2 Runtime;MSI 可引导安装,ZIP 需要预先安装。ZIP 仍将数据保存在当前用户应用目录,不能视为数据随身携带的便携版,也不保证注册 `power-switch://` 协议。 +- Windows 需要 WebView2 Runtime;MSI 可引导安装,ZIP 需要预先安装。ZIP 仍将数据保存在当前用户的 `.power-switch` 目录,不能视为数据随身携带的便携版,也不保证注册 `power-switch://` 协议。 - Linux AppImage 需赋予执行权限;部分发行版还需要 FUSE。DEB/RPM 会声明所需的系统依赖。 - 流水线先将本版发布为预发布;完成验证并提升为正式版后,才会进入 Latest 更新通道。已安装的正式版客户端届时可检查到本版。 - 检查更新只读取版本清单,下载安装需要在应用内再次确认。 diff --git a/README.md b/README.md index c7bfaa3..66edfb4 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,12 @@ WorkBuddy 写入成功后可以打开新建任务页,请在 WorkBuddy 中手 恢复前会再次备份当前配置,避免误操作后无法返回。 +## 本地数据目录 + +macOS、Linux 使用 `$HOME/.power-switch`,Windows 使用 `%USERPROFILE%\.power-switch`。设置页会显示实际路径。目录中的 `models.json` 保存模型、API Key 和应用设置;`new-api.json` 保存密钥创建恢复记录;`new-api/sessions/` 保存 New API 登录会话;`backups/` 保存应用配置前的原始快照。`skill-staging/` 只用于技能安装期间的临时文件,`.lock` 用于防止多个进程同时写入。 + +以上凭据和快照均为**明文**,请按敏感数据保护整个目录。应用只读写这个新目录,不会读取、迁移或删除旧应用数据目录与旧系统凭据库条目;升级后需要重新添加旧模型并登录 New API。应用到 Agent 时,仍会写入各 Agent 自己的配置文件。 + ## 设置配置位置 在“设置”中可以修改 WorkBuddy、Claude Code 的配置文件位置和 Codex 的配置目录。默认位置如下: @@ -81,7 +87,7 @@ Codex 使用 OpenAI Responses 协议。手动添加时,所选模型必须由 ## 安全说明 -- 模型和 API Key 只保存在本机。 +- 模型、API Key、New API 登录会话和备份均以明文保存在本机用户目录。 - API Key 以本地配置形式保存,请不要分享模型文件、备份文件或带密钥的分享链接。 - 保存模型不会自动修改 Agent;必须经过“应用到 Agent”并确认后才会写入。 - 删除模型库记录不会撤销服务商上的密钥,需要时请到服务商控制台操作。 diff --git a/docs/new-api.md b/docs/new-api.md index 72cd262..8256f96 100644 --- a/docs/new-api.md +++ b/docs/new-api.md @@ -36,10 +36,10 @@ Codex 只接受 OpenAI Responses。当前 New API 实例的 `/api/pricing` 对 ` ## 存储与恢复 -- macOS 登录会话只保存在系统钥匙串的 `com.powerswitch.desktop.new-api` 服务内,按实例地址隔离;Windows 使用系统凭证存储。其他平台暂不支持持久登录,绝不降级为明文会话文件。旧 Cookie 记录在验证身份后迁移到新存储格式,不按其中的版本号拒绝。 +- macOS、Windows、Linux 均将登录会话明文保存在用户目录的 `.power-switch/new-api/sessions/`。每个规范化实例地址对应一个以 SHA-256 命名的 JSON 文件,文件内记录格式版本、实例地址和会话;读取时校验实例。系统钥匙串及旧应用数据目录中的记录不再读取或迁移,升级后需要重新登录。 - 本机会话最长保留 30 天;新流程也受服务端会话到期时间约束。启动恢复时核对身份,Bearer 临近到期时使用仅限 `/api/user/auth` 路径的刷新 Cookie 续期。会话失效后重新登录,已创建的模型密钥独立有效。 -- API Key 沿用 `models.json` 的本机私有存储。管理会话、授权码和完整远端响应不写日志、分享链接或错误提示。 -- `new-api.json` 只保存账号 ID、实例、首次创建分组、令牌名称与 ID、创建前同名令牌的 ID,以及每个关联模型的模型 ID、协议和稳定本地 ID。Unix 文件权限为 `0600`。**请保留此文件**,它负责识别已有密钥并恢复未完成操作。 +- API Key 明文保存在 `.power-switch/models.json`,应用设置也在此文件中;配置快照明文保存在 `.power-switch/backups/`。这些文件和会话文件都包含敏感信息,复制或备份整个目录时应按凭据处理。管理会话、授权码和完整远端响应不写日志、分享链接或错误提示。 +- `.power-switch/new-api.json` 只保存账号 ID、实例、首次创建分组、令牌名称与 ID、创建前同名令牌的 ID,以及每个关联模型的模型 ID、协议和稳定本地 ID。Unix 目录权限为 `0700`、文件权限为 `0600`;Windows 使用仅当前用户和 SYSTEM 可访问的受保护 ACL。**请保留此文件**,它负责识别已有密钥并恢复未完成操作。 - 同一用户的不同令牌允许使用相同的姓名拼音名称。创建前记录已存在的同名令牌 ID,再写入 `submitted` 记录;恢复时排除旧 ID,核对账号、首次分组及无限制设置,已有绑定按令牌 ID 识别。旧版恢复记录可读,升级后保留为迁移来源。 - 崩溃、断网或响应丢失后先恢复查询,不自动重发无法确认结果的创建请求。可先点击 **重新检查并继续**;只有用户核对服务端列表并勾选允许重新创建时,才启动新的创建操作。 - 重复导入复用密钥和本地模型 ID;同一实例、同一账号的模型与协议共用一把密钥。其他分组的模型若不在共享密钥的 `/v1/models` 清单中,导入会停止并提示调整分组权限。用户主动更换失效密钥时,同步更新本机仍指向原接口的关联模型;已写入 Agent 的配置需重新预览应用。 diff --git a/docs/releasing.md b/docs/releasing.md index 0addc5f..0fd349b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -40,7 +40,7 @@ Store 版与 GitHub MSI/ZIP 版是两个分发渠道。Store 版使用 MSIX, 这些是公开包标识,不是签名私钥。Store 工作流在缺少任何值或使用 RC 标签时提前失败。仅在正式版标签已进入 `master` 历史后,手动运行 Actions 中的 **Microsoft Store MSIX**,输入例如 `v0.1.6`。工作流执行现有质量检查,分别产出 x64、ARM64 的 `store-windows-*` 工件。`MakeAppx` 生成的包未签名,只供 Partner Center 提交。应用版本 `0.1.6` 映射为 Store 包版本 `1.1.6.0`;映射固定为 `(major + 1).minor.patch.0`,确保 Store 要求的首段非零、末段为零,并保持跨版本递增。 -上传前检查两个包的清单身份与架构,使用 Windows App Certification Kit 检验。在 Partner Center 审核和重新签名完成后,通过 **Microsoft Store 页面**安装到干净的 Windows 11 x64、ARM64 机器,测试启动、模型添加与应用、`power-switch://` 导入、本地数据和商店升级。商店版不会检查或安装 GitHub MSI;设置中会显示商店更新说明。现有 GitHub 安装用户改装商店版时,还要核对模型与密钥能否从原 AppData 路径读取,因为 MSIX 可能虚拟化新建文件。确认产品页可用后再把 README 的 Windows 首选入口改为商店链接。 +上传前检查两个包的清单身份与架构,使用 Windows App Certification Kit 检验。在 Partner Center 审核和重新签名完成后,通过 **Microsoft Store 页面**安装到干净的 Windows 11 x64、ARM64 机器,测试启动、模型添加与应用、`power-switch://` 导入、本地数据和商店升级。商店版不会检查或安装 GitHub MSI;设置中会显示商店更新说明。现有 GitHub 安装用户改装商店版时,应确认模型与密钥从 `%USERPROFILE%\.power-switch` 读取;旧 AppData 目录不会被读取或迁移。还应在 MSIX 包中实测新目录的写入与 ACL,因为 MSIX 可能虚拟化新建文件。确认产品页可用后再把 README 的 Windows 首选入口改为商店链接。 维护者本机需要 Git SSH 推送权限。查看私有仓库 Actions、修改默认分支及管理 Release 时,还需 GitHub CLI 登录: @@ -54,7 +54,7 @@ gh repo edit winwin-inc/power-switch --default-branch master CI 使用 Node.js 22、pnpm 10.18.3、Rust 1.92.0。前端依赖采用 `--frozen-lockfile`,Rust 检查、测试和发布构建采用 `--locked`。 -Windows Installer 只比较 MSI `ProductVersion` 的前三段,[第四段不参与升级比较](https://learn.microsoft.com/en-us/windows/win32/msi/productversion)。已发布的 `v0.1.6-rc.4` 安装包设置为 `0.1.6.4`,因此正式版 `v0.1.6` 的 WiX 版本使用 `0.1.7.0`,确保 MSI 升级序列实际递增;应用本身仍显示 `0.1.6`。后续 Windows 版本必须使前三段高于 `0.1.7`,不能只增加第四段。发布前应在 Windows 上验证从上一版 MSI 升级。 +Windows Installer 只比较 MSI `ProductVersion` 的前三段,[第四段不参与升级比较](https://learn.microsoft.com/en-us/windows/win32/msi/productversion)。已发布的 `v0.1.6-rc.4` 安装包设置为 `0.1.6.4`,正式版 `v0.1.6` 使用 `0.1.7.0`;`v0.1.7` 的 WiX 版本使用 `0.1.8.0`,确保 MSI 升级序列实际递增,应用本身仍显示 `0.1.7`。后续 Windows 版本必须使前三段高于 `0.1.8`,不能只增加第四段。发布前应在 Windows 上验证从上一版 MSI 升级。 ## 准备一个版本 @@ -151,9 +151,10 @@ shasum -a 256 -c SHA256SUMS Windows 可执行 `Get-FileHash .\power-switch-v0.1.0-windows-x64.msi -Algorithm SHA256`,将结果与 `SHA256SUMS` 同名记录比较。 - macOS DMG 和 ZIP 中的应用均为通用二进制,构建时通过 `lipo` 校验 Intel 与 ARM64 架构。首版没有 Apple 开发者签名和公证;可能只有构建工具所需的临时签名。确认来源与校验值后,可在系统“隐私与安全性”查看允许打开的选项,不要关闭系统的全局安全检查。 -- Windows MSI 可引导安装 WebView2 并完成安装注册。ZIP 只包含应用可执行文件,需预装 WebView2;数据仍保存在用户应用目录,不保证注册 `power-switch://` 导入协议。系统可能显示未知发布者提示。 -- Linux AppImage 使用前需 `chmod +x`,部分发行版需要 FUSE;DEB/RPM 由系统包管理器安装。当前 New API 登录的系统凭证持久化仅实现 macOS/Windows,Linux 的此功能限制不因打包而改变。 +- Windows MSI 可引导安装 WebView2 并完成安装注册。ZIP 只包含应用可执行文件,需预装 WebView2;数据仍保存在 `%USERPROFILE%\.power-switch`,不保证注册 `power-switch://` 导入协议。系统可能显示未知发布者提示。 +- Linux AppImage 使用前需 `chmod +x`,部分发行版需要 FUSE;DEB/RPM 由系统包管理器安装。New API 登录会话在三个桌面系统中均明文保存在用户目录的 `.power-switch/new-api/sessions/`。 - 默认在启动时检查 GitHub 最新正式版;「测试计划」开启后还会检查已发布的 RC。关闭「自动更新」则跳过启动检查,设置页的「检查更新」仍可使用正式版通道。检查只读取清单,用户确认后才下载。安装后 macOS/Linux 可点击重启;Windows 安装器会自动关闭应用,安装完成后重新打开应用。 +- 更新清单与安装包优先通过 `https://ghfast.top/` 加速读取;加速源请求、解析或安装包验签失败时,客户端重试原始 GitHub Release 地址。发布清单仍记录原始 GitHub URL,更新包始终按内置公钥验签。 - 已安装的旧版客户端仍使用固定的 Latest 正式版地址,无法从远端获得新的通道设置;需要手动安装一次支持「测试计划」的版本,之后才可选择接收 RC。 ## 重跑、正式发布与故障处理 diff --git a/package.json b/package.json index d248745..14a8f2c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "power-switch", "private": true, - "version": "0.1.6", + "version": "0.1.7", "type": "module", "packageManager": "pnpm@10.18.3", "pnpm": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8fc8200..8419b53 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2200,20 +2200,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "log", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2976,12 +2962,11 @@ dependencies = [ [[package]] name = "power-switch" -version = "0.1.6" +version = "0.1.7" dependencies = [ "base64 0.22.1", "dirs", "fs2", - "keyring", "pinyin", "reqwest 0.12.28", "semver", @@ -3003,6 +2988,7 @@ dependencies = [ "toml_edit 0.22.27", "url", "uuid", + "windows-sys 0.61.2", "wiremock", ] @@ -3459,7 +3445,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.7.0", + "security-framework", ] [[package]] @@ -3487,7 +3473,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.7.0", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3597,19 +3583,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - [[package]] name = "security-framework" version = "3.7.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ddce6c6..522f19e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "power-switch" -version = "0.1.6" +version = "0.1.7" description = "Local model configuration switcher for coding agents" edition = "2021" rust-version = "1.92" @@ -52,11 +52,8 @@ tauri-plugin-process = { version = "2.3.1", optional = true } [dev-dependencies] wiremock = "0.6" -[target.'cfg(target_os = "macos")'.dependencies] -keyring = { version = "3", features = ["apple-native"] } - [target.'cfg(target_os = "windows")'.dependencies] -keyring = { version = "3", features = ["windows-native"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] } [profile.release] lto = "thin" diff --git a/src-tauri/src/desktop.rs b/src-tauri/src/desktop.rs index 1e2f478..a5cccb0 100644 --- a/src-tauri/src/desktop.rs +++ b/src-tauri/src/desktop.rs @@ -156,6 +156,11 @@ fn delete_model(state: Shared<'_>, id: String) -> AppResult<()> { fn save_settings(state: Shared<'_>, settings: Settings) -> AppResult<()> { with_engine(state, |e| e.settings(settings)) } +/// Update only the last checked New API address, keeping other settings intact. +#[tauri::command] +fn save_new_api_url(state: Shared<'_>, url: String) -> AppResult<()> { + with_engine(state, |e| e.new_api_url(url)) +} /// Prepare a model application for user review. #[tauri::command] fn preview_apply( @@ -287,8 +292,9 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .setup(|app| { let paths = Paths::discover().map_err(std::io::Error::other)?; - crate::new_api_desktop::setup(app.handle(), paths.data.clone()); + let data = paths.data.clone(); let engine = Engine::open(paths).map_err(std::io::Error::other)?; + crate::new_api_desktop::setup(app.handle(), data); app.manage(Mutex::new(engine)); app.manage(Mutex::new(VerifiedModels::default())); app.manage(Mutex::new(crate::skills::SkillManager::default())); @@ -316,6 +322,7 @@ pub fn run() { list_models, delete_model, save_settings, + save_new_api_url, preview_apply, apply_preview, cancel_preview, diff --git a/src-tauri/src/engine.rs b/src-tauri/src/engine.rs index 00b7b5d..c45a3c6 100644 --- a/src-tauri/src/engine.rs +++ b/src-tauri/src/engine.rs @@ -144,13 +144,31 @@ impl Engine { pub fn open(paths: Paths) -> AppResult { files::private_dir(&paths.data)?; files::private_dir(&paths.data.join("backups"))?; + files::private_dir(&paths.data.join("new-api"))?; + files::private_dir(&paths.data.join("new-api/sessions"))?; + let lock_path = paths.data.join(".lock"); + let lock_meta = fs::symlink_metadata(&lock_path); + match lock_meta { + Ok(meta) if files::unsafe_file_type(&meta) || !meta.is_file() => { + return Err("数据锁必须是普通文件".into()); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("无法检查数据锁:{e}")), + } let lock = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) - .open(paths.data.join(".lock")) + .open(lock_path) .map_err(|e| format!("打开数据锁失败:{e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + lock.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("设置数据锁权限失败:{e}"))?; + } lock.try_lock_exclusive() .map_err(|_| "另一个 power-switch 正在使用此数据目录")?; let engine = Self { @@ -278,10 +296,20 @@ impl Engine { } } let mut store = self.load()?; + let mut settings = settings; + settings.new_api_url = store.settings.new_api_url.clone(); store.settings = settings; self.save(&store) } + /// Save only the last verified New API instance without replacing concurrently edited settings. + pub fn new_api_url(&mut self, url: String) -> AppResult<()> { + let canonical = crate::new_api::instance_url(&url).map_err(|error| error.message)?; + let mut store = self.load()?; + store.settings.new_api_url = Some(canonical); + self.save(&store) + } + /// Prepare a credential-redacted, expiring preview with immutable in-memory write contents. pub fn preview_apply(&mut self, id: &str, agents: &[AgentKind]) -> AppResult { self.preview_apply_with_selection(id, agents, false) @@ -737,6 +765,57 @@ fn now() -> u64 { mod tests { use super::*; + /// A populated legacy directory is ignored when the new app-owned directory is empty. + #[test] + fn startup_ignores_legacy_data_and_preserves_narrow_preferences() { + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let legacy = temp.path().join("legacy").join("power-switch"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&legacy).unwrap(); + let old_bytes = serde_json::to_vec(&Store { + version: 1, + models: vec![], + settings: Settings::default(), + }) + .unwrap(); + fs::write(legacy.join("models.json"), &old_bytes).unwrap(); + let paths = Paths { + data: home.join(".power-switch"), + home, + workbuddy_env: None, + codex_env: None, + }; + let mut engine = Engine::open(paths.clone()).unwrap(); + assert!(engine.data().unwrap().models.is_empty()); + assert!(!paths.data.join("models.json").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for dir in ["", "backups", "new-api", "new-api/sessions"] { + assert_eq!( + fs::metadata(paths.data.join(dir)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + } + engine + .new_api_url("https://new-api.example.com/".into()) + .unwrap(); + engine.settings(Settings::default()).unwrap(); + drop(engine); + let reopened = Engine::open(paths).unwrap(); + assert_eq!( + reopened.current_settings().unwrap().new_api_url.as_deref(), + Some("https://new-api.example.com") + ); + assert_eq!(fs::read(legacy.join("models.json")).unwrap(), old_bytes); + } + /// Verify a second-file failure restores the first and journals the failure. #[test] fn transaction_rolls_back_on_second_write_failure() { diff --git a/src-tauri/src/files.rs b/src-tauri/src/files.rs index 7d6baa2..3d78d93 100644 --- a/src-tauri/src/files.rs +++ b/src-tauri/src/files.rs @@ -1,6 +1,8 @@ use crate::model::AppResult; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +#[cfg(windows)] +use std::os::windows::fs::MetadataExt; use std::{ fs, io::Write, @@ -21,7 +23,7 @@ impl Snapshot { pub fn read(path: &Path) -> AppResult { match fs::symlink_metadata(path) { Ok(meta) => { - if meta.file_type().is_symlink() || !meta.is_file() { + if unsafe_file_type(&meta) || !meta.is_file() { return Err(format!( "配置必须是普通文件(不接受符号链接):{}", path.display() @@ -74,25 +76,153 @@ pub fn fingerprint(bytes: Option<&[u8]>) -> String { format!("{:x}", hash.finalize()) } -/// Create app-owned storage directories with user-only Unix access. +/// Reject links and Windows reparse points before touching app-owned data. +pub(crate) fn unsafe_file_type(meta: &fs::Metadata) -> bool { + #[cfg(windows)] + { + meta.file_attributes() & 0x400 != 0 + } + #[cfg(not(windows))] + { + meta.file_type().is_symlink() + } +} + +/// Create app-owned storage directories with current-user-only access. pub fn private_dir(path: &Path) -> AppResult<()> { + match fs::symlink_metadata(path) { + Ok(meta) if unsafe_file_type(&meta) || !meta.is_dir() => { + return Err(format!("数据目录必须是普通目录:{}", path.display())); + } + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("无法检查数据目录:{e}")), + } fs::create_dir_all(path).map_err(|e| format!("创建目录 {} 失败:{e}", path.display()))?; + let meta = fs::symlink_metadata(path).map_err(|e| format!("无法检查数据目录:{e}"))?; + if unsafe_file_type(&meta) || !meta.is_dir() { + return Err(format!("数据目录必须是普通目录:{}", path.display())); + } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(path, fs::Permissions::from_mode(0o700)) .map_err(|e| format!("设置目录权限失败:{e}"))?; } + #[cfg(windows)] + private_windows_acl(path)?; Ok(()) } +#[cfg(windows)] +/// Protect a directory and its future children with an explicit user-and-SYSTEM DACL. +fn private_windows_acl(path: &Path) -> AppResult<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::{ + Foundation::{CloseHandle, LocalFree, HANDLE}, + Security::{ + Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + SetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT, + }, + GetSecurityDescriptorDacl, GetTokenInformation, TokenUser, DACL_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, TOKEN_QUERY, TOKEN_USER, + }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + + // A token SID avoids localized account names and handles domain users correctly. + let mut token: HANDLE = std::ptr::null_mut(); + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err("无法读取当前 Windows 用户身份".into()); + } + let sid = (|| -> AppResult { + let mut needed = 0; + unsafe { + GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed); + } + if needed == 0 { + return Err("无法获取 Windows 用户 SID".into()); + } + let mut buffer = vec![0usize; (needed as usize).div_ceil(std::mem::size_of::())]; + if unsafe { + GetTokenInformation( + token, + TokenUser, + buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) + } == 0 + { + return Err("无法读取 Windows 用户 SID".into()); + } + let user = unsafe { &*(buffer.as_ptr().cast::()) }; + let mut sid_text = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(user.User.Sid, &mut sid_text) } == 0 { + return Err("无法转换 Windows 用户 SID".into()); + } + let len = unsafe { (0..).take_while(|&i| *sid_text.add(i) != 0).count() }; + let result = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(sid_text, len) }); + unsafe { LocalFree(sid_text.cast()) }; + Ok(result) + })(); + unsafe { CloseHandle(token) }; + let sid = sid?; + let sddl = format!("D:P(A;OICI;FA;;;{sid})(A;OICI;FA;;;SY)"); + let wide = |value: &std::ffi::OsStr| value.encode_wide().chain(Some(0)).collect::>(); + let sddl_wide = wide(std::ffi::OsStr::new(&sddl)); + let path_wide = wide(path.as_os_str()); + let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl_wide.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err("无法创建 Windows 私有目录权限".into()); + } + let result = (|| -> AppResult<()> { + let mut present = 0; + let mut defaulted = 0; + let mut acl = std::ptr::null_mut(); + if unsafe { GetSecurityDescriptorDacl(descriptor, &mut present, &mut acl, &mut defaulted) } + == 0 + || present == 0 + || acl.is_null() + { + return Err("无法读取 Windows 私有目录权限".into()); + } + let status = unsafe { + SetNamedSecurityInfoW( + path_wide.as_ptr() as *mut u16, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + acl, + std::ptr::null(), + ) + }; + if status != 0 { + return Err(format!("无法设置 Windows 私有目录权限:{status}")); + } + Ok(()) + })(); + unsafe { LocalFree(descriptor.cast()) }; + result +} + /// Replace one file using a private, synced temporary file in the same directory. pub fn atomic_write(path: &Path, bytes: &[u8]) -> AppResult<()> { let parent = path.parent().ok_or("配置路径没有父目录")?; fs::create_dir_all(parent).map_err(|e| format!("创建配置目录失败:{e}"))?; // Recheck file type immediately before replacement to avoid clobbering symlinks. match fs::symlink_metadata(path) { - Ok(meta) if meta.file_type().is_symlink() || !meta.is_file() => { + Ok(meta) if unsafe_file_type(&meta) || !meta.is_file() => { return Err(format!("目标必须是普通文件:{}", path.display())); } Ok(_) => {} @@ -206,3 +336,30 @@ fn redact_value(value: &mut serde_json::Value) { _ => {} } } + +#[cfg(test)] +mod storage_tests { + use super::*; + + /// Preserve the destination when an atomic replacement targets a non-file. + #[test] + fn atomic_write_rejects_non_file_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("models.json"); + fs::create_dir(&target).unwrap(); + assert!(atomic_write(&target, b"secret").is_err()); + assert!(target.is_dir()); + } + + /// Refuse a redirected app directory before changing its permissions or writing data. + #[cfg(unix)] + #[test] + fn private_dir_rejects_symlink() { + let dir = tempfile::tempdir().unwrap(); + let outside = dir.path().join("outside"); + fs::create_dir(&outside).unwrap(); + let link = dir.path().join(".power-switch"); + std::os::unix::fs::symlink(&outside, &link).unwrap(); + assert!(private_dir(&link).is_err()); + } +} diff --git a/src-tauri/src/new_api/vault.rs b/src-tauri/src/new_api/vault.rs index 997a6ee..98fc31f 100644 --- a/src-tauri/src/new_api/vault.rs +++ b/src-tauri/src/new_api/vault.rs @@ -1,65 +1,176 @@ use super::{Error, Result}; +use crate::files::{self, Snapshot}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{fs, path::PathBuf}; -/// Keep login credentials out of the model library and ordinary configuration files. +/// Store one instance's serialized login session behind the connector's existing interface. pub trait Vault: Send + Sync { fn get(&self, account: &str) -> Result>; fn set(&self, account: &str, secret: &str) -> Result<()>; fn remove(&self, account: &str) -> Result<()>; } -pub struct SystemVault; +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SessionFile { + version: u32, + account: String, + session: serde_json::Value, +} + +pub struct FileVault { + root: PathBuf, +} -#[cfg(any(target_os = "macos", target_os = "windows"))] -impl SystemVault { - /// Use the canonical server URL as the account within an app-specific keychain service. - fn entry(account: &str) -> Result { - keyring::Entry::new("com.powerswitch.desktop.new-api", account) - .map_err(|_| Error::new("keychain", "无法打开系统钥匙串")) +impl FileVault { + /// Keep plaintext sessions in the app-owned private directory on every platform. + pub fn new(data_dir: PathBuf) -> Self { + Self { + root: data_dir.join("new-api").join("sessions"), + } + } + + /// Name each session by the SHA-256 of its canonical instance URL, never URL text. + fn path(&self, account: &str) -> PathBuf { + let digest = Sha256::digest(account.as_bytes()); + self.root.join(format!("{digest:x}.json")) + } + + /// Check all app-owned directory components before accessing a credential file. + fn check_root(&self) -> Result<()> { + let parent = self + .root + .parent() + .ok_or_else(|| Error::new("storage", "会话目录无效"))?; + let data = parent + .parent() + .ok_or_else(|| Error::new("storage", "数据目录无效"))?; + for path in [data, parent, self.root.as_path()] { + files::private_dir(path) + .map_err(|_| Error::new("storage", "无法保护 New API 会话目录"))?; + } + Ok(()) } } -#[cfg(any(target_os = "macos", target_os = "windows"))] -impl Vault for SystemVault { - /// Distinguish a missing login from a locked or inaccessible keychain. +impl Vault for FileVault { + /// Refuse damaged or mismatched records instead of silently using a different login. fn get(&self, account: &str) -> Result> { - match Self::entry(account)?.get_password() { - Ok(value) => Ok(Some(value)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(_) => Err(Error::new("keychain", "无法读取系统钥匙串,请解锁后重试")), + self.check_root()?; + let snapshot = Snapshot::read(&self.path(account)) + .map_err(|_| Error::new("storage", "无法安全读取 New API 登录会话"))?; + let Some(bytes) = snapshot.bytes else { + return Ok(None); + }; + let record: SessionFile = serde_json::from_slice(&bytes) + .map_err(|_| Error::new("storage", "New API 登录会话文件已损坏"))?; + if record.version != 1 || record.account != account { + return Err(Error::new( + "storage", + "New API 登录会话格式或实例地址不匹配", + )); } + serde_json::to_string(&record.session) + .map(Some) + .map_err(|_| Error::new("storage", "无法读取 New API 登录会话")) } - /// Persist the session without ever including keychain errors or secrets in UI output. + /// Atomically persist the existing serialized session as plaintext JSON. fn set(&self, account: &str, secret: &str) -> Result<()> { - Self::entry(account)? - .set_password(secret) - .map_err(|_| Error::new("keychain", "无法保存登录会话到系统钥匙串")) + self.check_root()?; + let session = serde_json::from_str(secret) + .map_err(|_| Error::new("storage", "New API 登录会话格式无效"))?; + let bytes = serde_json::to_vec_pretty(&SessionFile { + version: 1, + account: account.into(), + session, + }) + .map_err(|_| Error::new("storage", "无法序列化 New API 登录会话"))?; + files::atomic_write(&self.path(account), &bytes) + .map_err(|_| Error::new("storage", "无法保存 New API 登录会话")) } - /// Disconnect locally without rotating the account's shared management token. + /// Remove only a verified regular session file for the requested instance. fn remove(&self, account: &str) -> Result<()> { - match Self::entry(account)?.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), - Err(_) => Err(Error::new("keychain", "无法从系统钥匙串移除登录会话")), + self.check_root()?; + let path = self.path(account); + if Snapshot::read(&path) + .map_err(|_| Error::new("storage", "无法安全检查 New API 登录会话"))? + .bytes + .is_none() + { + return Ok(()); } + fs::remove_file(path).map_err(|_| Error::new("storage", "无法删除 New API 登录会话")) } } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] -impl Vault for SystemVault { - /// Fail explicitly on unsupported credential stores instead of writing plaintext sessions. - fn get(&self, _account: &str) -> Result> { - Ok(None) - } - /// New API persistent login currently supports macOS and Windows system credential stores. - fn set(&self, _account: &str, _secret: &str) -> Result<()> { - Err(Error::new( - "keychain", - "此平台尚未实现 New API 系统凭证存储", - )) +#[cfg(test)] +mod tests { + use super::*; + + /// Persist, reload and remove one plaintext session without affecting another instance. + #[test] + fn file_sessions_round_trip_and_isolate_accounts() { + let dir = tempfile::tempdir().unwrap(); + let vault = FileVault::new(dir.path().join(".power-switch")); + let account = "https://new-api.example.com"; + vault + .set(account, r#"{"cookie":"private-cookie"}"#) + .unwrap(); + let reloaded = FileVault::new(dir.path().join(".power-switch")); + assert_eq!( + reloaded.get(account).unwrap().as_deref(), + Some(r#"{"cookie":"private-cookie"}"#) + ); + assert_eq!(reloaded.get("https://other.example.com").unwrap(), None); + let raw = fs::read_to_string(reloaded.path(account)).unwrap(); + assert!(raw.contains("private-cookie")); + assert!(raw.contains("\"version\": 1")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(reloaded.path(account)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + reloaded.remove(account).unwrap(); + assert_eq!(reloaded.get(account).unwrap(), None); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&reloaded.root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } } - /// There is no persisted credential to remove on an unsupported platform. - fn remove(&self, _account: &str) -> Result<()> { - Ok(()) + + /// Fail closed for malformed records, mismatched accounts and symlinked session files. + #[test] + fn damaged_or_redirected_session_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let vault = FileVault::new(dir.path().join(".power-switch")); + let account = "https://new-api.example.com"; + vault.set(account, r#"{"cookie":"secret"}"#).unwrap(); + let path = vault.path(account); + fs::write(&path, "broken-json").unwrap(); + assert_eq!(vault.get(account).unwrap_err().code, "storage"); + fs::write(&path, r#"{"version":1,"account":"wrong","session":{}}"#).unwrap(); + assert_eq!(vault.get(account).unwrap_err().code, "storage"); + #[cfg(unix)] + { + fs::remove_file(&path).unwrap(); + std::os::unix::fs::symlink(dir.path().join("outside"), &path).unwrap(); + assert_eq!(vault.get(account).unwrap_err().code, "storage"); + assert_eq!(vault.set(account, "{}").unwrap_err().code, "storage"); + assert_eq!(vault.remove(account).unwrap_err().code, "storage"); + } } } diff --git a/src-tauri/src/new_api_desktop.rs b/src-tauri/src/new_api_desktop.rs index 2ffb03e..3913b87 100644 --- a/src-tauri/src/new_api_desktop.rs +++ b/src-tauri/src/new_api_desktop.rs @@ -1,7 +1,7 @@ use crate::{ engine::Engine, new_api::{ - self, vault::SystemVault, Catalog, Connection, Error, ImportRequest, ImportResult, + self, vault::FileVault, Catalog, Connection, Error, ImportRequest, ImportResult, LoginStatus, NewApi, Result, }, }; @@ -29,9 +29,12 @@ fn signal_cancel(app: &AppHandle, id: &str) { } } -/// Register the connector beside the existing model engine, using a separate metadata file. +/// Register the connector with its app-owned plaintext session directory. pub fn setup(app: &AppHandle, data: std::path::PathBuf) { - app.manage(AsyncMutex::new(NewApi::new(data, Box::new(SystemVault)))); + app.manage(AsyncMutex::new(NewApi::new( + data.clone(), + Box::new(FileVault::new(data)), + ))); app.manage(LoginCancels::default()); } diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs index 0d14b90..a06e0c5 100644 --- a/src-tauri/src/paths.rs +++ b/src-tauri/src/paths.rs @@ -17,6 +17,8 @@ pub struct Settings { pub auto_update: bool, #[serde(default)] pub receive_rc: bool, + #[serde(default)] + pub new_api_url: Option, } /// Preserve startup update checks for users whose saved settings predate these switches. @@ -34,6 +36,7 @@ impl Default for Settings { codex_dir: None, auto_update: automatic_updates_enabled(), receive_rc: false, + new_api_url: None, } } } @@ -62,11 +65,10 @@ pub struct Paths { impl Paths { /// Resolve native user directories without shell expansion or cwd fallbacks. pub fn discover() -> AppResult { + let home = dirs::home_dir().ok_or("无法获取系统用户目录")?; Ok(Self { - home: dirs::home_dir().ok_or("无法获取系统用户目录")?, - data: dirs::data_local_dir() - .ok_or("无法获取系统应用数据目录")? - .join("power-switch"), + data: home.join(".power-switch"), + home, workbuddy_env: nonempty_env("WORKBUDDY_DATA_DIR"), codex_env: nonempty_env("CODEX_HOME"), }) @@ -136,7 +138,14 @@ pub fn validate_absolute(path: &Path) -> AppResult<()> { #[cfg(test)] mod settings_tests { - use super::Settings; + use super::{Paths, Settings}; + + /// Resolve app-owned storage from the OS home directory, never the legacy AppData location. + #[test] + fn discovers_hidden_home_directory() { + let paths = Paths::discover().unwrap(); + assert_eq!(paths.data, paths.home.join(".power-switch")); + } /// Existing settings files keep automatic checks but do not opt in to RC updates. #[test] diff --git a/src-tauri/src/update_desktop.rs b/src-tauri/src/update_desktop.rs index bfb3f9e..271cfba 100644 --- a/src-tauri/src/update_desktop.rs +++ b/src-tauri/src/update_desktop.rs @@ -8,6 +8,9 @@ use url::Url; const RELEASES_API: &str = "https://api.github.com/repos/winwin-inc/power-switch/releases"; const RELEASE_ASSET_PREFIX: &str = "https://github.com/winwin-inc/power-switch/releases/download/"; +const STABLE_MANIFEST_URL: &str = + "https://github.com/winwin-inc/power-switch/releases/latest/download/latest.json"; +const GHFAST_PREFIX: &str = "https://ghfast.top/"; const UPDATE_PROGRESS_EVENT: &str = "app-update-progress"; #[derive(Clone, Deserialize)] @@ -106,6 +109,24 @@ fn manifest_url(release: &GithubRelease) -> AppResult { Url::parse(&manifest.browser_download_url).map_err(|_| "更新清单地址无效".into()) } +/// Proxy only this repository's HTTPS release assets through the download accelerator. +fn ghfast_url(original: &Url) -> AppResult { + let path = original.path(); + if original.scheme() != "https" + || original.host_str() != Some("github.com") + || original.port().is_some() + || !original.username().is_empty() + || original.password().is_some() + || original.query().is_some() + || original.fragment().is_some() + || !(path.starts_with("/winwin-inc/power-switch/releases/download/") + || path.starts_with("/winwin-inc/power-switch/releases/latest/download/")) + { + return Err("更新资源地址不是受支持的 GitHub Release 地址".into()); + } + Url::parse(&format!("{GHFAST_PREFIX}{original}")).map_err(|_| "GitHub 加速地址无效".into()) +} + /// Bound the complete GitHub release scan so pagination cannot leave update checks hanging. async fn github_releases() -> AppResult> { let client = reqwest::Client::builder() @@ -138,30 +159,85 @@ async fn github_releases() -> AppResult> { .map_err(|_| "获取 GitHub 发布列表超时,请稍后重试".to_string())? } -/// Let Tauri parse the signed manifest and compare it with this installed app version. +/// Retry malformed, unreachable, or invalid accelerated manifests from GitHub. +async fn check_manifest( + app: &AppHandle, + original: &Url, + selected_tag: Option<&str>, +) -> AppResult> { + let accelerated = ghfast_url(original)?; + let mut accelerated_error = None; + for (endpoint, timeout) in [ + (accelerated, Duration::from_secs(10)), + (original.clone(), Duration::from_secs(30)), + ] { + let result: AppResult> = async { + let update = app + .updater_builder() + .timeout(timeout) + .endpoints(vec![endpoint]) + .map_err(|error| format!("更新地址无效:{error}"))? + .build() + .map_err(|error| format!("无法初始化更新器:{error}"))? + .check() + .await + .map_err(|error| format!("检查更新失败:{error}"))?; + match update { + Some(update) if version_allowed(&update.version, selected_tag)? => { + ghfast_url(&update.download_url)?; + Ok(Some(update)) + } + Some(_) => Err("更新清单的版本不在当前通道".into()), + None => Ok(None), + } + } + .await; + match result { + Ok(update) => return Ok(update), + Err(error) => { + if let Some(first_error) = accelerated_error.take() { + return Err(format!( + "加速源和 GitHub 均无法检查更新:加速源:{first_error};GitHub:{error}" + )); + } + accelerated_error = Some(error); + } + } + } + Err("无法检查更新".into()) +} + +/// Let Tauri compare the selected release manifest with this installed app version. async fn find_update(app: &AppHandle, include_rc: bool) -> AppResult> { - let mut builder = app.updater_builder().timeout(Duration::from_secs(30)); - let mut selected_tag = None; - if include_rc { + let (manifest, selected_tag) = if include_rc { let releases = github_releases().await?; let latest = newest_release(&releases, true).ok_or("没有可用的正式版或 RC 发布")?; - selected_tag = Some(latest.tag_name.clone()); - builder = builder - .endpoints(vec![manifest_url(latest)?]) - .map_err(|error| format!("更新地址无效:{error}"))?; - } - let update = builder - .build() - .map_err(|error| format!("无法初始化更新器:{error}"))? - .check() + (manifest_url(latest)?, Some(latest.tag_name.clone())) + } else { + ( + Url::parse(STABLE_MANIFEST_URL).map_err(|_| "正式版更新地址无效")?, + None, + ) + }; + check_manifest(app, &manifest, selected_tag.as_deref()).await +} + +/// Download and verify a package while reporting progress for the current source. +async fn download_update(update: &Update, app: &AppHandle) -> AppResult> { + let mut downloaded = 0_u64; + update + .download( + |chunk, total| { + downloaded = downloaded.saturating_add(chunk as u64); + let percent = total + .filter(|size| *size > 0) + .map(|size| downloaded.saturating_mul(100).saturating_div(size).min(100) as u8); + let _ = app.emit(UPDATE_PROGRESS_EVENT, percent); + }, + || {}, + ) .await - .map_err(|error| format!("检查更新失败:{error}"))?; - match update { - Some(update) if version_allowed(&update.version, selected_tag.as_deref())? => { - Ok(Some(update)) - } - _ => Ok(None), - } + .map_err(|error| format!("下载安装失败:{error}")) } /// Check metadata only; package download is reserved for the separate confirmed command. @@ -193,26 +269,29 @@ pub async fn install_app_update( return Err("Microsoft Store 版本由商店管理更新".into()); } let include_rc = receive_rc(&state)?; - let update = find_update(&app, include_rc) + let mut update = find_update(&app, include_rc) .await? .ok_or("当前没有可用更新,请重新检查")?; if update.version != version { return Err("可用版本已变化,请重新检查后确认".into()); } - let mut downloaded = 0_u64; - let progress_app = app.clone(); + let mut accelerated = update.clone(); + accelerated.download_url = ghfast_url(&update.download_url)?; + accelerated.timeout = Some(Duration::from_secs(30)); + update.timeout = Some(Duration::from_secs(60)); + let bytes = match download_update(&accelerated, &app).await { + Ok(bytes) => bytes, + Err(accelerated_error) => { + let _ = app.emit(UPDATE_PROGRESS_EVENT, None::); + download_update(&update, &app).await.map_err(|original_error| { + format!( + "加速源和 GitHub 均无法下载更新:加速源:{accelerated_error};GitHub:{original_error}" + ) + })? + } + }; update - .download_and_install( - move |chunk, total| { - downloaded = downloaded.saturating_add(chunk as u64); - let percent = total - .filter(|size| *size > 0) - .map(|size| downloaded.saturating_mul(100).saturating_div(size).min(100) as u8); - let _ = progress_app.emit(UPDATE_PROGRESS_EVENT, percent); - }, - || {}, - ) - .await + .install(bytes) .map_err(|error| format!("下载安装失败:{error}")) } @@ -286,6 +365,42 @@ mod tests { assert!(manifest_url(&candidate).is_err()); } + /// Mirror only trusted release paths, including stable and versioned manifests. + #[test] + fn builds_trusted_ghfast_urls() { + let stable = Url::parse(STABLE_MANIFEST_URL).unwrap(); + assert_eq!( + ghfast_url(&stable).unwrap().as_str(), + format!("{GHFAST_PREFIX}{STABLE_MANIFEST_URL}") + ); + let config: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + let endpoints = config["plugins"]["updater"]["endpoints"] + .as_array() + .unwrap(); + assert_eq!( + endpoints[0].as_str(), + Some(ghfast_url(&stable).unwrap().as_str()) + ); + assert_eq!(endpoints[1].as_str(), Some(STABLE_MANIFEST_URL)); + let package = Url::parse( + "https://github.com/winwin-inc/power-switch/releases/download/v0.1.6/power-switch-v0.1.6-macos-universal.tar.gz", + ) + .unwrap(); + assert_eq!( + ghfast_url(&package).unwrap().as_str(), + format!("{GHFAST_PREFIX}{package}") + ); + assert!(ghfast_url(&Url::parse("https://example.com/update.tar.gz").unwrap()).is_err()); + assert!(ghfast_url( + &Url::parse( + "http://github.com/winwin-inc/power-switch/releases/download/v0.1.6/latest.json" + ) + .unwrap() + ) + .is_err()); + } + /// A copied RC manifest cannot leak through the stable endpoint or impersonate another tag. #[test] fn validates_the_manifest_channel() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a01db19..996cd4a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "power-switch", - "version": "0.1.6", + "version": "0.1.7", "identifier": "com.powerswitch.desktop", "build": { "beforeDevCommand": "pnpm dev:web", @@ -30,7 +30,7 @@ "targets": "all", "windows": { "wix": { - "version": "0.1.7.0" + "version": "0.1.8.0" } }, "shortDescription": "模型随心切换", @@ -48,6 +48,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDgzQkZFRDA2NTQ2REQ0MjkKUldRcDFHMVVCdTIvZytKUnNyWVJHS21OT1l4emE2NjhZSTdSMDdDV0ROTGNaOExya2dYM2FFUXYK", "endpoints": [ + "https://ghfast.top/https://github.com/winwin-inc/power-switch/releases/latest/download/latest.json", "https://github.com/winwin-inc/power-switch/releases/latest/download/latest.json" ], "windows": { "installMode": "passive" } diff --git a/src/App.tsx b/src/App.tsx index 85431f6..87a1dec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -364,10 +364,10 @@ export default function App() { .includes(query.toLowerCase()), ); const titles = { - models: ["模型库", "把合适的模型,交给合适的 Agent。"], - backups: ["模型配置备份", "每一次切换,都留有回去的路。"], - settings: ["设置", "让配置找到正确的位置。"], - skills: ["技能", "全局安装,为每个 Agent 连接合适的技能。"], + models: "模型库", + backups: "模型配置备份", + settings: "设置", + skills: "技能", }; return ( @@ -510,7 +510,7 @@ export default function App() {
- {titles[page][0]} + {titles[page]}
{!isDesktop && ( @@ -529,10 +529,7 @@ export default function App() {
-
-

{titles[page][0]}

-

{titles[page][1]}

-
+

{titles[page]}

{page === "models" && (
{data.dataDir} diff --git a/src/NewApiDialog.tsx b/src/NewApiDialog.tsx index 53647d5..6c62174 100644 --- a/src/NewApiDialog.tsx +++ b/src/NewApiDialog.tsx @@ -11,7 +11,7 @@ import { ShieldCheck, Unplug, } from "lucide-react"; -import { isDesktop } from "./api"; +import { api, isDesktop } from "./api"; import { Modal } from "./components"; import { defaultNewApiUrl, @@ -36,26 +36,19 @@ const agentProtocol: Record = { claude: "anthropic-messages", codex: "openai-responses", }; -const urlStorageKey = "power-switch.new-api-url"; - -/** Read only a non-secret instance preference; private-mode storage failures are harmless. */ -function savedUrl(): string { - try { - return localStorage.getItem(urlStorageKey) || defaultNewApiUrl; - } catch { - return defaultNewApiUrl; - } -} - /** Guide one native login and recoverable model import without exposing management credentials. */ export function NewApiDialog({ onClose, onAdded, + onPreferenceSaved, + savedBaseUrl, }: { onClose: () => void; onAdded: () => Promise; + onPreferenceSaved?: () => Promise; + savedBaseUrl?: string | null; }) { - const [baseUrl, setBaseUrl] = useState(savedUrl); + const [baseUrl, setBaseUrl] = useState(savedBaseUrl || defaultNewApiUrl); const [connection, setConnection] = useState(null); const [status, setStatus] = useState(null); const [catalog, setCatalog] = useState(null); @@ -206,7 +199,7 @@ export function NewApiDialog({ setError(null); } - /** Check the canonical panel address and remember only this non-secret preference. */ + /** Check the canonical panel address and persist this preference in the native store. */ async function check() { const turn = generation.current; const info = await newApi.check(baseUrl); @@ -216,11 +209,8 @@ export function NewApiDialog({ setBaseUrl(info.baseUrl); setStatus(next); if (next.error) setError(next.error); - try { - localStorage.setItem(urlStorageKey, info.baseUrl); - } catch { - /* Preferences are optional. */ - } + await api.newApiUrl(info.baseUrl); + await onPreferenceSaved?.(); } /** Start a separate IdP window and retain its opaque handle solely for cancellation. */ @@ -369,7 +359,7 @@ export function NewApiDialog({

{connected - ? "登录会话保存在系统钥匙串,API Key 单独保存在本机模型库。" + ? "登录会话与 API Key 均以明文保存在用户目录的 .power-switch 中。" : pending ? "请在独立登录窗口中完成扫码;授权完成后将自动返回。" : "首次需要完成扫码。会话过期后可重新登录,已有密钥仍独立有效。"} diff --git a/src/api.ts b/src/api.ts index 89beb73..120348e 100644 --- a/src/api.ts +++ b/src/api.ts @@ -40,6 +40,8 @@ export const api = { delete: (id: string) => call("delete_model", { id }), /** Persist validated appearance, path and update-channel settings. */ settings: (settings: Settings) => call("save_settings", { settings }), + /** Persist only the last verified New API instance address. */ + newApiUrl: (url: string) => call("save_new_api_url", { url }), /** Generate the second-confirmation preview. */ preview: (id: string, agents: AgentKind[], selectWorkbuddyModel: boolean) => call("preview_apply", { diff --git a/src/components.tsx b/src/components.tsx index a4e675b..6360054 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -82,7 +82,7 @@ export function Modal({

- {children} +
{children}
diff --git a/src/demo.ts b/src/demo.ts index d4d2720..7280bf9 100644 --- a/src/demo.ts +++ b/src/demo.ts @@ -39,6 +39,7 @@ let settings: Settings = { codexDir: null, autoUpdate: true, receiveRc: false, + newApiUrl: null, }; /** Keep the browser preview useful without pretending to have native file access. */ @@ -61,7 +62,7 @@ export async function demoCall( models, settings, backups: [], - dataDir: "桌面端系统应用数据目录", + dataDir: "用户目录/.power-switch", agents: [ { agent: "workbuddy", @@ -91,7 +92,14 @@ export async function demoCall( } if (command === "delete_all_backups") return 0; if (command === "save_settings") { - settings = structuredClone(args.settings as Settings); + settings = { + ...structuredClone(args.settings as Settings), + newApiUrl: settings.newApiUrl, + }; + return; + } + if (command === "save_new_api_url") { + settings.newApiUrl = args.url as string; return; } if (command === "cancel_preview") return; diff --git a/src/styles.css b/src/styles.css index 77d0536..8d39eb3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -273,7 +273,7 @@ nav { gap: 5px; } .version-number { - font-size: 9px; + font-size: 10px; } .version-update-trigger { width: 19px; @@ -393,11 +393,6 @@ h1 { letter-spacing: 1px; line-height: 1.25; } -.page-header p { - color: var(--muted); - font-size: 12px; - margin-top: 12px; -} .header-actions { display: flex; gap: 10px; @@ -860,6 +855,21 @@ h1 { font-size: 7px; letter-spacing: 1.5px; } +.page-bottom > span:last-child:hover svg { + color: var(--orange); + animation: workflow-sparkle 900ms ease-in-out 1; +} +@keyframes workflow-sparkle { + 35% { + opacity: 1; + transform: rotate(-12deg) scale(1.18); + filter: drop-shadow(0 0 3px var(--orange)); + } + 70% { + opacity: 0.7; + transform: rotate(8deg) scale(1.06); + } +} .notification { position: fixed; top: 18px; @@ -942,9 +952,11 @@ h1 { top: 50%; left: 50%; transform: translate(-50%, -50%); + display: flex; + flex-direction: column; width: min(560px, calc(100vw - 40px)); max-height: calc(100vh - 48px); - overflow: auto; + overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: 16px; @@ -957,11 +969,17 @@ h1 { } .modal-heading { display: flex; + flex-shrink: 0; align-items: flex-start; justify-content: space-between; gap: 15px; margin-bottom: 22px; } +.modal-body { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} .modal-heading h2 { font-size: 20px; font-weight: 600; @@ -1437,6 +1455,9 @@ textarea { .update-section-title { align-items: center; } +.update-section-title > svg { + align-self: flex-start; +} .update-section-title > .button { margin-left: auto; white-space: nowrap; @@ -1743,11 +1764,6 @@ textarea { .page-header h1 { font-size: 25px; } - .page-header p { - font-size: 10px; - max-width: 170px; - line-height: 1.8; - } .button { font-size: 10px; } diff --git a/src/test/new-api.test.tsx b/src/test/new-api.test.tsx index b11bd0a..ddb3b7e 100644 --- a/src/test/new-api.test.tsx +++ b/src/test/new-api.test.tsx @@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { NewApiDialog } from "../NewApiDialog"; +import { api } from "../api"; import { newApi, type NewApiImported, type NewApiStatus } from "../new-api-api"; -vi.mock("../api", () => ({ isDesktop: true })); +vi.mock("../api", () => ({ isDesktop: true, api: { newApiUrl: vi.fn() } })); vi.mock("../new-api-api", async (importOriginal) => { const original = await importOriginal(); return { @@ -54,7 +55,7 @@ const imported: NewApiImported = { beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); + vi.mocked(api.newApiUrl).mockResolvedValue(); vi.mocked(newApi.check).mockResolvedValue({ baseUrl, version: "v1.0.0-rc.40", @@ -84,6 +85,24 @@ beforeEach(() => { }); describe("New API connector", () => { + it("loads the saved instance address and writes verified URLs only to native settings", async () => { + const user = userEvent.setup(); + const savedBaseUrl = "https://saved.example.com"; + render( + , + ); + expect(screen.getByRole("textbox", { name: /实例地址/ })).toHaveValue( + savedBaseUrl, + ); + await screen.findByRole("option", { name: "chat-model" }); + await user.click(screen.getByRole("button", { name: "检查连接" })); + await waitFor(() => expect(api.newApiUrl).toHaveBeenCalledWith(baseUrl)); + expect(localStorage.getItem("power-switch.new-api-url")).toBeNull(); + }); it("imports with the displayed identity after native verification and keeps the secret masked", async () => { const user = userEvent.setup(); const onAdded = vi.fn().mockResolvedValue(undefined); diff --git a/src/types.ts b/src/types.ts index 2f41900..09aee0c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,6 +53,7 @@ export interface Settings { codexDir: string | null; autoUpdate: boolean; receiveRc: boolean; + newApiUrl?: string | null; } export interface AgentPath { agent: AgentKind;