diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..4a894be --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,66 @@ +name: Windows 构建与回归 +on: + push: + branches: [main, fix/windows-0.2] + workflow_dispatch: + inputs: + publish_release: + description: 发布已验证的 0.2.0 正式包 + type: boolean + default: false +permissions: + contents: read +jobs: + verify: + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + - name: 核心回归 + run: dotnet run --project tests/WinCodexBar.CoreTests -c Release + - name: Windows 窗口回归 + run: dotnet run --project tests/WinCodexBar.WindowsTests -c Release + - name: 发布三个架构 + shell: pwsh + run: | + foreach ($rid in @('win-x64', 'win-x86', 'win-arm64')) { + dotnet publish windows/CodexBarWin/CodexBarWin.csproj -c Release -r $rid --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o "artifacts/$rid" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } + - name: 打包与校验清单 + shell: pwsh + run: ./tools/Package-Windows.ps1 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: windows-validation-and-packages + path: | + artifacts/packages/ + tests/WinCodexBar.WindowsTests/bin/Release/net8.0-windows/screenshots/ + + release: + if: github.event_name == 'workflow_dispatch' && inputs.publish_release && github.ref == 'refs/heads/main' + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: windows-validation-and-packages + path: release-files + - name: 发布正式版 + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create v0.2.0 + release-files/artifacts/packages/*.zip + release-files/artifacts/packages/SHA256SUMS.txt + --target "$GITHUB_SHA" + --title "WinCodexBar 0.2.0 · Fluent 界面与自动更新" + --notes-file tools/Release-Notes-0.2.0.md + --latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 49f09f4..7d30886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +# 更新记录 + +## 0.2.0 — 2026-09-26 + +- 设置页统一 Fluent 图标与内置中英文字体,修复窄屏、高缩放下的卡片与页脚布局。 +- 缺少 5 小时窗口的账号只显示 7 天用量;平均值与托盘图标同步处理。 +- 新增只读重置卡次数、逐张到期时间,以及带时区的精确额度重置时间;不提供使用或兑换操作。 + + +- 修复托盘外部点击检测误关删除确认框、弹窗与主窗口开关竞争、右键触发操作和滚动区域点击穿透。 +- 修复小屏窗口超出工作区;新增跟随显示器 DPI、100%–300% 手动缩放、设置卡片响应式排列与滚动入口。 +- 新增额度圆环、剩余百分比、经典图标三种托盘样式;释放被替换的图标和账号行控件。 +- 新增自动/手动版本检查、SHA256 校验、覆盖更新与重启、文件替换失败回滚,以及首次升级的数据备份。 +- 添加 GPT-6 三个型号和价格预设;只读本地模型目录补充模型与推理强度;修复标准服务档位同步。 +- 优先显示 Radar 当前重置窗口,过期预测不再覆盖当前状态;网络失败标记缓存。 +- 保留关闭的唤醒策略;保存模型和模式后立即同步;限制刷新间隔,防止计时器整数溢出。 +- 本地 Token 扫描改为后台执行;工作台增加删除入口;单实例防止重复运行引发文件/网关冲突。 +- 增加独立临时数据目录下的核心回归、Windows 窗口与更新脚本验证,以及三架构构建流程。 + # 更新日志 ## 0.1.1 - 2026-06-21 diff --git a/README.en.md b/README.en.md index e659c23..9949aa3 100644 --- a/README.en.md +++ b/README.en.md @@ -1,171 +1,31 @@ # WinCodexBar -> A Windows tray workspace for Codex multi-account management, quota visibility, local token activity, and aggregate account routing. +[简体中文](./README.md) · [Download](https://github.com/windycn/WinCodexBar/releases/latest) -## About +A Windows tray workspace for Codex accounts, quotas, model settings, and local usage insights. -WinCodexBar is a Windows tray app built for Codex users who work with multiple OpenAI OAuth accounts. It combines account switching, quota tracking, token activity, session analysis, import/export, keep-awake controls, and aggregate routing in one lightweight desktop workflow. +Version **0.2.0** adds automatic display DPI adaptation, manual scaling from 100% to 300%, three tray icon styles, GPT-6 choices and local model catalog integration. It fixes popup/dialog conflicts, deletion confirmation, clipped hit targets, and disabled wake settings being re-enabled after restart. -## Why It Matters +Automatic update checks run shortly after startup and every six hours. Installation requires confirmation: the app downloads the matching architecture package, verifies SHA256, backs up account/settings files, then replaces program files and restarts. Replacement failures trigger a rollback attempt with retained backups and logs. -### Aggregate Mode Keeps Account Routing Continuous +Download the x64, x86, or arm64 ZIP, extract it to a separate writable folder, and launch `WinCodexBar.exe`. The .NET runtime is included. For the first upgrade from 0.1.x, exit the old version and replace its files manually; subsequent releases can use the new updater. -Aggregate mode starts a local account gateway and treats your OpenAI OAuth accounts as an account pool. New Codex instances can connect through this local gateway, so account selection and request routing are handled by WinCodexBar instead of repeatedly editing account configuration in each project. +Account/settings files remain in `%USERPROFILE%\.codexbar` (or beneath `CODEXBAR_HOME`). Version upgrades create backups under `.codexbar\backups`. Update staging, program backups and `result.txt` are under `%LOCALAPPDATA%\WinCodexBar\updates`. Session history is not deleted. Backups and account exports contain credentials and should remain private. -This is useful when you work across multiple projects or long-running sessions. You can inspect all account quotas in one place and continue with a healthier account when needed. Codex instances opened before aggregate mode is enabled usually need to be restarted or reopened before they use the local gateway. +Single-click the tray icon for quick actions; double-click for the dashboard. Appearance settings offer a quota ring, remaining percentage or classic app icon. Settings also provide manual update checks. Model availability depends on the account and Codex version; existing selections are preserved. -### Switching Accounts Does Not Mean Losing Project Memory +Token costs are estimates, not billing records. The public reset-window summary is attributed to [Codex Radar](https://codexradar.com/); it is not evidence that an individual account has reset. Codex Radar’s protected full API is not queried. -WinCodexBar switches OAuth identity and request routing. It does not clear your project folders, local session records, or Codex project context files. In practical terms, the account is the request identity, while the project session is local working memory; WinCodexBar changes the former and does not delete the latter. - -In manual mode, already-running Codex instances usually need to be restarted or reopened before they use the newly selected account. In aggregate mode, later routing decisions are centralized through the local gateway. - -### Quota And Token Activity Stay Visible - -The tray icon uses a ring indicator for recent 5-hour quota. The tray panel and dashboard show 5-hour quota, 7-day quota, health state, reset time, and local token activity for today, this week, this month, and all time. You can react before an account runs out of quota. - -## Screenshots - -### Tray Menu - -The compact tray panel shows the current account, quota, subscription type, and account pool state. It also gives quick access to account actions, the dashboard, and settings. - -

- WinCodexBar tray menu -

- -### Dashboard - -The dashboard includes the account list, token activity, session analysis, and cost estimates. Bar charts and heatmaps make daily, weekly, monthly, and all-time token usage easier to scan. - -

- WinCodexBar token activity dashboard -

- -### Settings - -Settings cover account mode, quota display, wake strategy, and model parameters. Wake strategy supports both system keep-awake and advanced anti-sleep behavior. - -

- WinCodexBar wake strategy settings -

- -## Problems It Solves - -When you use several OpenAI accounts with Codex, the friction usually comes from: - -- Not knowing how much 5-hour or 7-day quota remains. -- Editing local account configuration by hand. -- Interrupting work when switching accounts across multiple projects. -- Moving or backing up multiple OAuth accounts. -- Windows sleeping or locking the screen during a long session. -- Token activity and local session history being hard to inspect. - -WinCodexBar puts these controls into a tray menu, a dashboard, and a settings window designed for day-to-day Windows use. - -## Features - -### Tray Menu - -- Open a compact account panel from the system tray. -- Use a ring tray icon to show recent 5-hour quota status. -- Hover over the tray icon to inspect 5-hour and 7-day usage. -- Open the dashboard from the tray menu. -- Dismiss the tray panel by clicking outside it. - -### Account Management - -- Add OpenAI OAuth accounts. -- Capture browser OAuth callbacks automatically when possible. -- Paste the returned browser URL manually when automatic capture is unavailable. -- Import and export multiple accounts for backup or migration. -- Delete accounts with a confirmation step. -- Switch the active account and write the change to Codex configuration. - -### Manual And Aggregate Modes - -- Manual mode writes the selected account to local Codex configuration. Already-running Codex instances usually need to be restarted or reopened to use the new account. -- Aggregate mode starts a local account gateway so new Codex instances can route through a local endpoint. -- Aggregate mode is designed for multi-project, multi-account, and long-session workflows where repeatedly editing account config is disruptive. -- Codex instances that were already open before aggregate mode is enabled usually need to be restarted or reopened before they use the local gateway. - -### Usage And Quota - -- Show each account's subscription, health state, 5-hour quota, and 7-day quota. -- Choose between used quota and remaining quota display modes. -- Choose token number units: Chinese-style units or K/M/B. -- Use green, orange, and red status colors as remaining quota crosses warning thresholds. -- Show reset countdowns together with exact reset dates and times. - -### Dashboard - -- View account counts, current account, quota state, and health summary. -- Refresh or switch accounts from the account list. -- Inspect token activity with daily, weekly, monthly, and all-time views. -- Use bar charts and a calendar-style heatmap for token trends. -- Review local Codex sessions, including recent sessions and highest-token sessions. -- Estimate token cost in USD and CNY using editable model price presets. - -### Keep Awake - -- Keep Awake prevents Windows from sleeping or turning off the display. -- Advanced Keep Awake can gently move the mouse after an idle period. -- Configure idle threshold, trigger interval, jitter duration, movement strategy, and fullscreen pause behavior. -- Enable launch at Windows startup. - -### Settings - -- Account settings: manual mode and aggregate mode. -- Usage settings: quota display mode, token units, auto refresh, health thresholds, and pricing presets. -- Wake strategy: keep awake, advanced anti-sleep behavior, and startup launch. -- Model parameters: default model, review model, reasoning effort, and service tier. - -## Install - -Choose the package that matches your Windows device: - -- `WinCodexBar-0.1.1-win-x64.zip`: most 64-bit Intel / AMD Windows devices. -- `WinCodexBar-0.1.1-win-x86.zip`: older 32-bit Windows devices. -- `WinCodexBar-0.1.1-win-arm64.zip`: Windows on ARM devices. - -Extract the archive and run `WinCodexBar.exe`. After launch, the app appears in the system tray. - -## Build From Source - -Requires the .NET 8 SDK. - -```powershell -dotnet restore windows\CodexBarWin\CodexBarWin.csproj -dotnet build windows\CodexBarWin\CodexBarWin.csproj -c Release -``` - -Publish a self-contained single-file build: +Build using .NET 8 SDK: ```powershell -dotnet publish windows\CodexBarWin\CodexBarWin.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true +dotnet build windows/CodexBarWin/CodexBarWin.csproj -c Release +dotnet run --project tests/WinCodexBar.CoreTests -c Release +dotnet run --project tests/WinCodexBar.WindowsTests -c Release ``` -## Data And Privacy - -- Account data is stored locally by default. -- The app does not display access tokens, refresh tokens, or ID tokens in logs, UI text, or summaries. -- Exported account files are sensitive. Keep them private and do not upload them publicly. -- Token activity and session analysis are based on local Codex session files and are used only for local display and estimation. - -## Notes - -- Switching accounts does not delete project files or local session records, but already-running Codex instances may still use the old account. Restart Codex or open a new instance to ensure the new account is used. -- Aggregate mode requires Codex to use the local gateway endpoint. Codex instances opened before the mode switch usually do not join automatically. -- Cost statistics are local estimates based on token counts and price presets. They are not official billing data. -- Advanced Keep Awake simulates tiny mouse movement. Enable it only when it fits your workflow. -- If Windows security software blocks the single-file executable, verify the file source before allowing it. - -## Version - -Current version: `0.1.1` - -See [CHANGELOG.md](./CHANGELOG.md) for release notes. +Window tests require Windows and use isolated temporary data. Mixed-monitor hot-plug behavior and hardware-specific rendering still need device acceptance testing. -## License +[MIT License](./LICENSE) · [Third-party notices](./THIRD_PARTY_NOTICES.md) -WinCodexBar is licensed under the MIT License. See [LICENSE](./LICENSE). +Account quota windows are shown only when supplied by the service. Weekly-only accounts have no five-hour placeholder, and the tray indicator uses weekly usage. Reset-credit counts and individual expiration times are read-only; account details show exact reset dates, seconds, and local UTC offsets. There is no redemption action or endpoint. diff --git a/README.md b/README.md index cbad0cb..cffa96e 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,78 @@ # WinCodexBar -[English](./README.en.md) +[English](./README.en.md) · [下载最新版](https://github.com/windycn/WinCodexBar/releases/latest) · [更新记录](./CHANGELOG.md) -> 面向 Windows 的 Codex 托盘工作台:管理多个 OpenAI OAuth 账号,查看 5 小时 / 7 天额度,分析本地 Token 活动,并通过聚合模式让多账号使用更连续。 -> -> English: A Windows tray workspace for Codex multi-account management, quota visibility, local token activity, and aggregate account routing. +Windows 上的 Codex 托盘工作台:管理多账号、查看额度、切换模型、分析本地用量。 -## 项目简介 / About +## 0.2.0 有什么变化 -WinCodexBar 是一个专为 Windows Codex 用户准备的托盘工具。它把多账号管理、额度监控、Token 活动、会话分析、导入导出、保持唤醒和聚合路由放在一个轻量界面里,减少反复切换账号、修改配置和检查用量带来的中断。 +- **屏幕适配**:默认跟随当前显示器 DPI;也可选择 100%–300% 手动缩放。窗口限制在屏幕工作区内,窄屏设置卡片上下排列,超出内容可滚动。 +- **字体与图标**:随包提供中文无衬线字体与 Microsoft Fluent UI System Icons,减少系统字体缺失引起的回退和字形差异。窄屏导航自动收起为分类下拉框,设置页无横向滚动。 +- 重置卡只读展示剩余次数、各张到期时间;账号详情显示精确额度重置日期、时间与时区。不提供使用或兑换重置卡的功能。 +- 额度按账号实际提供的窗口显示;没有 5 小时额度时只显示 7 天额度,托盘图标同步使用 7 天数据。缺失数据与真实 0% 分开处理。 +- **托盘样式**:额度圆环、剩余百分比、经典应用图标。单击立即打开快捷面板,双击打开工作台;Esc 收起面板。 +- **交互修复**:删除确认不再被外部点击检测误关;滚动后的隐藏账号区域不再响应点击;打开设置、查看统计与关闭面板按顺序执行。 +- **更新更方便**:启动后和每 6 小时自动检查正式版,也可手动检查。用户确认后下载对应架构包、校验 SHA256、备份数据、覆盖程序并重启。覆盖失败尝试回滚,保留日志与旧程序备份。 +- **模型**:内置 GPT-6 Astra / Sol / Luna,并合并本机 Codex 模型缓存中的模型、推理强度与服务档位。支持手填模型 ID,升级保留已有选择。 +- **重置窗口**:读取 CodexRadar 公开摘要中的当前窗口状态,旧预测不会盖过已开启的窗口。显示来源,点击可查看网站;这不是个人账号已重置的证明。 -English: WinCodexBar is a Windows tray app for Codex users. It brings multi-account management, quota tracking, token activity, session analysis, import/export, keep-awake controls, and aggregate account routing into one lightweight desktop workflow. +## 下载与运行 -## 核心价值 +在 [Releases](https://github.com/windycn/WinCodexBar/releases/latest) 下载与你的系统匹配的 ZIP: -### 聚合模式让账号切换更连续 +| 安装包 | 适用设备 | +| --- | --- | +| `WinCodexBar-0.2.0-win-x64.zip` | Intel / AMD 64 位 Windows | +| `WinCodexBar-0.2.0-win-arm64.zip` | Windows on ARM | +| `WinCodexBar-0.2.0-win-x86.zip` | 32 位 Windows | -聚合模式会启动一个本地账号网关,把多个 OpenAI OAuth 账号作为账号池管理。新的 Codex 实例接入本地网关后,账号选择和请求路由交给 WinCodexBar 处理,不需要你频繁手动修改每个项目里的账号配置。 +解压到独立、可写的文件夹,运行 `WinCodexBar.exe`。发布包自带 .NET 运行时。不要直接在 ZIP 内运行;自动更新需要对程序目录有写入权限。 -这对多项目和长会话尤其有用:你可以在一个地方查看所有账号额度,选择更健康的账号继续工作。已经接入聚合网关的新 Codex 实例可以跟随路由策略使用账号;如果某个 Codex 实例是在开启聚合模式之前启动的,通常需要重启 Codex 或新开实例后才会接入本地网关。 +首次从 0.1.x 升级:先退出旧程序,再解压覆盖或运行新目录中的程序。旧版没有自动更新入口,升级到 0.2.0 后即可使用。程序只允许一个实例运行。 -### 切换账号不等于丢失项目会话 +## 日常使用 -WinCodexBar 切换的是 OAuth 身份和请求路由,不会主动清空你的项目目录、本地会话记录或 Codex 的项目上下文文件。你可以把账号理解为“请求身份”,把项目会话理解为“本地工作记忆”:WinCodexBar 改前者,不会删除后者。 +- **托盘**:查看当前账号的 5 小时 / 7 天额度、重置时间,刷新或切换账号。 +- **工作台**:添加、导入、导出、切换、删除账号;查看 Token 活动和本地会话分析。 +- **设置 → 外观与缩放**:自动 / 手动缩放、托盘样式,保存后生效。 +- **设置 → 唤醒与更新**:保持唤醒、高级防休眠、开机启动、自动检查与手动检查更新。 +- **设置 → 模型参数**:默认模型、Review 模型、推理强度和服务等级。模型实际可用性由账号与 Codex 版本决定。 +- **设置 → 账号设置**:手动模式 / 聚合模式。手动切换同步登录配置;已运行的 Codex 通常需要重启或新开实例。聚合模式把后续请求交给本地账号网关,正在运行的响应流不被中途切换。 -实际使用时,如果你在手动模式下切换账号,已运行的 Codex 实例通常需要重启或新开一次,才能使用新的账号配置;聚合模式则把后续账号路由集中到本地网关里处理。 +关闭工作台会收回托盘;彻底退出请使用托盘菜单的“退出”。高级防休眠会模拟轻微鼠标移动,可单独关闭。 -### 额度和用量一眼看清 +## 更新、数据与备份 -托盘图标用圆环显示近 5 小时额度状态;菜单和工作台展示 5 小时 / 7 天额度、健康状态、预计重置时间,以及今日、本周、本月和累计 Token 活动。你不用等到请求失败才发现账号已经接近额度耗尽。 +账号与设置保存在 `%USERPROFILE%\.codexbar`,不在程序目录;设置 `CODEXBAR_HOME` 时使用该目录下的 `.codexbar`。升级保留旧模型、价格和外观设置,补充缺失的新字段。 -## 界面预览 +- 每个新版本首次启动,在 `.codexbar\backups\before-版本-时间` 备份现有账号与设置。 +- 自动覆盖更新前,另行备份到 `.codexbar\backups\before-update-时间`。 +- 更新包及程序回滚备份保存在 `%LOCALAPPDATA%\WinCodexBar\updates\随机目录`,内有 `result.txt` 和 `previous-program`。 +- 覆盖失败会尝试恢复被修改文件;若目录权限或文件占用阻止恢复,可退出应用后从 `previous-program` 手动还原。自动回滚针对文件替换失败,不等同于检测新版本的全部运行问题。 +- 恢复账号或设置时,先退出程序,再将备份中的 JSON 放回 `.codexbar`。备份和导出文件含登录凭证,不要公开分享。 +- 更新与删除账号不会删除 `.codex\sessions` 或 `.codex\archived_sessions`。删除账号仅从 WinCodexBar 账号池移除,不代表撤销远端登录或清除其他客户端的登录状态。 -### 托盘菜单 +自动检查只读取本仓库公开 Release 元数据,不上传账号或会话。发现新版本会通知,**不会未经确认自动安装**。网络不可用时继续使用当前版本。 -轻量弹出菜单用于快速查看当前账号、额度、订阅类型和账号池状态,也可以直接切换模式、添加账号、打开工作台或进入设置。 +## 统计与信息来源 -

- WinCodexBar 托盘菜单 -

+Token 活动来自本地 Codex 会话文件;金额是按所选价格预设计算的估值,不是账单,也不能反推订阅额度。GPT-6 内置价格采用标准短上下文价格,不包含 Fast、长上下文、缓存写入等附加规则。 -### 工作台 +模型资料:[OpenAI 模型目录](https://developers.openai.com/api/docs/models)。重置窗口数据来自 [Codex 雷达 codexradar.com](https://codexradar.com/) 的公开摘要,三分钟缓存;完整 API 需站方授权,本程序不请求受保护的完整接口。 -工作台提供账号列表、Token 活动、会话分析和成本估算。柱状图和热力图用于快速观察今日、本周、本月和累计 Token 使用情况。 +## 构建与验证 -

- WinCodexBar 工作台 Token 活动 -

- -### 设置页 - -设置页集中配置账号模式、用量显示、唤醒策略和模型参数。唤醒策略支持系统保持唤醒和高级防休眠。 - -

- WinCodexBar 唤醒策略设置 -

- -## 解决什么问题 - -当你同时使用多个 OpenAI 账号时,常见问题通常是: - -- 不知道当前账号的 5 小时 / 7 天额度还剩多少。 -- 手动改配置容易出错,也不方便回到上一个账号。 -- 多项目同时使用 Codex 时,账号切换会打断工作流。 -- 多账号导入、备份和迁移不够顺手。 -- 长时间编码时 Windows 休眠或锁屏会打断节奏。 -- 本地会话 Token 用量、今日/本周/本月趋势不够直观。 - -WinCodexBar 把这些能力集中在托盘菜单、工作台和设置页里,适合长时间使用 Codex 的 Windows 用户。 - -## 主要功能 - -### 托盘菜单 - -- 单击托盘图标打开快捷菜单。 -- 双击可打开工作台。 -- 托盘图标使用近 5 小时额度圆环显示当前账号状态。 -- 鼠标悬停托盘图标可查看当前 5 小时 / 7 天额度。 -- 点击菜单外部自动收起。 - -### 多账号管理 - -- 添加 OpenAI OAuth 账号。 -- 支持浏览器授权回调自动捕获。 -- 自动捕获失败时,可手动粘贴浏览器地址栏中的回调 URL。 -- 导入 / 导出多账号文件,方便备份和迁移。 -- 删除账号前会进行二次确认。 -- 当前账号切换后会同步到 Codex 配置。 - -### 手动模式与聚合模式 - -- 手动模式:点击账号后写入当前账号配置,已运行的 Codex 实例通常需要重启后才会使用新账号。 -- 聚合模式:启动本地账号网关,让新的 Codex 实例通过本地地址进行账号路由。 -- 聚合模式适合多项目、多账号和长会话场景,可以减少反复编辑账号配置带来的中断。 -- 如果 Codex 是开启聚合模式之前已经打开的实例,通常需要重启 Codex 或新开实例后才会接入聚合路由。 - -### 额度显示 - -- 展示每个账号的订阅类型、健康状态、5 小时额度和 7 天额度。 -- 可在设置中选择显示“已用额度”或“剩余额度”。 -- 可在设置中选择 Token 数字单位:中文单位或 K/M/B。 -- 剩余额度低于 30% 显示橙色,低于 10% 显示红色。 -- 可查看预计重置时间,包含倒计时和具体日期时间。 - -### 工作台 - -- 汇总账号数量、当前账号、额度状态和健康状态。 -- 显示全部账号列表,支持刷新、切换和查看状态。 -- Token 活动页展示今日、本周、本月柱状图和累计热力图。 -- 会话分析页展示本地会话数量、活跃/归档情况、Token 最高会话和最近会话。 -- Token 成本可按模型价格预设估算美元和人民币金额。 - -### 防休眠 - -- 保持唤醒:调用 Windows 系统能力阻止休眠和息屏。 -- 高级防休眠:空闲后模拟轻微鼠标移动,可配置空闲阈值、触发间隔、抖动时间和移动策略。 -- 可设置全屏时暂停高级防休眠。 -- 支持随 Windows 开机启动。 - -### 设置 - -- 账号设置:切换手动 / 聚合模式。 -- 用量设置:切换额度显示方式、Token 单位、自动刷新间隔、健康阈值和价格预设。 -- 唤醒策略:配置保持唤醒、高级防休眠和开机启动。 -- 模型参数:配置默认模型、Review 模型、reasoning effort 和 service tier。 - -## 安装与运行 - -从发布包中选择与你的系统架构匹配的版本: - -- `WinCodexBar-0.1.1-win-x64.zip`:大多数 Intel / AMD 64 位 Windows 设备。 -- `WinCodexBar-0.1.1-win-x86.zip`:旧的 32 位 Windows 设备。 -- `WinCodexBar-0.1.1-win-arm64.zip`:Windows on ARM 设备。 - -解压后运行 `WinCodexBar.exe`。首次运行后会出现在系统托盘区域。 - -## 本地构建 - -需要 .NET 8 SDK。 +需要 .NET 8 SDK。Windows 可运行完整窗口回归;其他系统可交叉编译 Windows 程序并运行核心回归。 ```powershell -dotnet restore windows\CodexBarWin\CodexBarWin.csproj -dotnet build windows\CodexBarWin\CodexBarWin.csproj -c Release +dotnet build windows/CodexBarWin/CodexBarWin.csproj -c Release +dotnet run --project tests/WinCodexBar.CoreTests -c Release +dotnet run --project tests/WinCodexBar.WindowsTests -c Release +dotnet publish windows/CodexBarWin/CodexBarWin.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true ``` -发布单文件执行包示例: - -```powershell -dotnet publish windows\CodexBarWin\CodexBarWin.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -``` - -## 数据与隐私 - -- 账号数据默认保存在本机配置目录。 -- 应用不会在日志、界面或导出说明中显示 access token、refresh token 或 id token。 -- 导出的账号文件属于敏感数据,请妥善保存,不要公开上传。 -- Token 活动和会话分析来自本地 Codex 会话文件,仅用于本机展示和估算。 - -## 注意事项 - -- 切换账号不会删除项目文件或本地会话记录,但已运行的 Codex 实例可能仍使用旧账号;需要重启 Codex 或新开实例才能确保使用新账号。 -- 聚合模式需要 Codex 使用本地网关地址;切换前已经打开的 Codex 实例通常不会自动接入。 -- 金额统计是基于本地 Token 和价格预设的估算,不等于官方账单。 -- 高级防休眠会模拟轻微鼠标移动,请根据自己的使用场景谨慎开启。 -- 如果 Windows 安全软件拦截单文件执行包,请确认文件来源后再放行。 - -## 版本 - -当前版本:`0.1.1` - -更新内容见 [CHANGELOG.md](./CHANGELOG.md)。 +Windows 回归覆盖设置卡片、手动缩放、窗口开关、删除确认和真实 PowerShell 更新脚本。测试使用临时数据目录与虚构账号,不连接真实账号。多台实体显示器之间的热插拔、不同显卡与 Windows 主题仍建议在实际设备上验收。 ## License -本项目使用 MIT License。详见 [LICENSE](./LICENSE)。 +[MIT](./LICENSE) · [第三方组件声明](./THIRD_PARTY_NOTICES.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index b6c8285..e096001 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,5 +1,11 @@ -# Third-Party Notices +# 第三方组件声明 -WinCodexBar 使用 .NET、Windows Forms 与 Windows 平台能力构建。 +WinCodexBar 的发布包包含以下组件与资源: -本文件用于记录后续新增依赖时需要保留的许可证信息。当前 Windows 发布包不额外捆绑需要单独列明的第三方二进制组件。 +- **.NET / Windows Forms / Microsoft.Data.Sqlite / System.Memory**:Microsoft 与 .NET contributors,MIT License。自包含 Windows 包包含 .NET 运行时。https://github.com/dotnet/runtime +- **SQLitePCLRaw**:Eric Sink 与 contributors,Apache-2.0 License。https://github.com/ericsink/SQLitePCL.raw +- **SQLite**:Public Domain。https://sqlite.org/copyright.html +- **Microsoft Fluent UI System Icons**:Copyright (c) Microsoft Corporation,MIT License。图标字体随包嵌入,不依赖系统图标字体。来源:https://github.com/microsoft/fluentui-system-icons 。完整许可见 `windows/CodexBarWin/Assets/Fonts/FluentSystemIcons-LICENSE.txt`(发布包 `licenses/FluentSystemIcons-LICENSE.txt`)。 +- **Noto Sans SC**:The Noto Project Authors,SIL Open Font License 1.1。来源:https://github.com/google/fonts/tree/main/ofl/notosanssc 。本项目将字体实例化为固定字重并保留界面使用的字符范围;衍生字体家族更名为 WinCodexBar Sans。完整许可见 `windows/CodexBarWin/Assets/Fonts/NotoSansSC-OFL.txt`(发布包 `licenses/NotoSansSC-OFL.txt`)。 + +CodexRadar 为外部信息来源,界面保留其来源标注与链接。其服务并非本程序的一部分。 diff --git a/tests/WinCodexBar.CoreTests/Program.cs b/tests/WinCodexBar.CoreTests/Program.cs new file mode 100644 index 0000000..a26e135 --- /dev/null +++ b/tests/WinCodexBar.CoreTests/Program.cs @@ -0,0 +1,125 @@ +using CodexBarWin.Models; +using CodexBarWin.Services; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; + +var scratch = Path.Combine(Path.GetTempPath(), "wincodexbar-test-" + Guid.NewGuid().ToString("N")); +Environment.SetEnvironmentVariable("CODEXBAR_HOME", scratch); +Directory.CreateDirectory(scratch); +var passed = 0; +void Check(bool result, string name) { if (!result) throw new Exception(name); Console.WriteLine("PASS " + name); passed++; } +void Reject(Action action, string name) { try { action(); } catch (InvalidDataException) { Check(true, name); return; } throw new Exception("Expected rejection: " + name); } +try +{ + var settings = new CodexBarConfigStore(); + settings.Update(c => { c.KeepAwakeEnabled = false; c.AdvancedKeepAwakeEnabled = false; c.StartWithWindows = false; c.AutoCheckUpdates = false; c.UiScalePercent = 200; c.TrayIconStyle = "percent"; c.Global.DefaultModel = "custom-model"; }); + AppDataMigration.EnsureVersionBackup(); + AppDataMigration.EnsureVersionBackup(); + Check(Directory.GetDirectories(Path.Combine(CodexPaths.CodexBarRoot,"backups")).Length == 1, "upgrade backup runs once per version"); + settings.Load(); + Check(!settings.Config.KeepAwakeEnabled && !settings.Config.AdvancedKeepAwakeEnabled && !settings.Config.StartWithWindows && !settings.Config.AutoCheckUpdates, "false settings survive restart"); + Check(settings.Config.UiScalePercent == 200 && settings.Config.TrayIconStyle == "percent", "appearance survives restart"); + Check(settings.Config.Global.DefaultModel == "custom-model", "upgrade preserves custom model"); + Check(settings.Config.OpenAI.TokenPricePresets.ContainsKey("gpt-6-astra") && settings.Config.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion == 10, "GPT-6 presets merged"); + var clone = settings.Config.Clone(); clone.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion = 1; + Check(settings.Config.OpenAI.TokenPricePresets["gpt-6-sol"].OutputUsdPerMillion == 10, "draft pricing isolated"); + File.WriteAllText(CodexPaths.WindowsSettingsPath, "{\"KeepAwakeEnabled\":false,\"openai\":{\"auto_refresh_interval_seconds\":2147483647},\"ui_scale_percent\":-5}"); + settings.Load(); + Check(!settings.Config.KeepAwakeEnabled && settings.Config.OpenAI.AutoRefreshIntervalSeconds == 86400 && settings.Config.UiScalePercent == 0, "legacy settings and invalid ranges"); + File.WriteAllText(Path.Combine(CodexPaths.CodexRoot,"models_cache.json"), "{\"models\":[{\"slug\":\"gpt-future\",\"supported_reasoning_levels\":[{\"effort\":\"ultra\"}],\"service_tiers\":[{\"id\":\"priority\"}]}]}"); + Check(CodexModelCatalog.Models().Contains("gpt-future") && CodexModelCatalog.Efforts("gpt-future").Contains("ultra"), "local model catalog extends choices"); + Check(CodexModelCatalog.ConfigTier("gpt-future","standard") is null && CodexModelCatalog.ConfigTier("gpt-future","priority") == "fast", "service tier writes supported values only"); + var toml = "# settings\n[profiles.test]\nmodel = \"profile-model\"\n"; + var edited = TomlRootEditor.Set(toml,"model","\"gpt-6-sol\""); + Check(edited.IndexOf("gpt-6-sol", StringComparison.Ordinal) < edited.IndexOf("[profiles", StringComparison.Ordinal) && edited.Contains("profile-model"), "model inserted at root preserves profiles"); + var removed = TomlRootEditor.Set("service_tier = \"fast\"\n[profiles.test]\nservice_tier = \"priority\"\n","service_tier",null); + Check(!removed.Contains("fast") && removed.Contains("priority"), "remove root tier preserves profile tier"); + var multiline = "instructions = \"\"\"\n[example]\nmodel = example\n\"\"\"\n[profiles.test]\n"; + Check(TomlRootEditor.Set(multiline,"model","\"gpt-6-sol\"").StartsWith(multiline[..multiline.IndexOf("[profiles",StringComparison.Ordinal)]), "multiline instructions remain unchanged"); + var weekly = new TokenAccount { PrimaryUsedPercent = 95, PrimaryWindowAvailable = true }; + using var weeklyJson = JsonDocument.Parse("""{"rate_limit":{"primary_window":null,"secondary_window":{"used_percent":0,"limit_window_seconds":604800}}}"""); + OpenAIUsageService.ApplyUsagePayload(weekly, weeklyJson.RootElement); + Check(AccountUsageHelpers.Windows(weekly).Count == 1 && AccountUsageHelpers.Windows(weekly)[0].Label == "7d", "missing five-hour window hidden and stale data cleared"); + Check(AccountUsageHelpers.UsageText(weekly, UsageDisplayMode.Used) == "7d 0.0%", "real zero usage remains visible"); + Check(AccountUsageHelpers.ResetText(weekly).Contains(AccountUsageHelpers.ExactTime(weekly.PrimaryResetAt)), "reset summary uses calendar date"); + Check(!AccountUsageHelpers.ResetText(weekly).Contains("5h"), "missing five-hour reset hidden"); + using var primaryWeekly = JsonDocument.Parse("""{"rate_limits":{"primary":{"used_percent":42,"window_minutes":10080},"secondary":null}}"""); + OpenAIUsageService.ApplyUsagePayload(weekly, primaryWeekly.RootElement); + Check(AccountUsageHelpers.UsageText(weekly, UsageDisplayMode.Used) == "7d 42.0%", "weekly primary bucket labeled by window duration"); + var both = new TokenAccount { PrimaryWindowAvailable = true, SecondaryWindowAvailable = true, PrimaryUsedPercent = 10, SecondaryUsedPercent = 20 }; + Check(AccountUsageHelpers.AverageText(new[] { weekly, both }, UsageDisplayMode.Used) == "5h 10.0% · 7d 31.0%", "averages exclude missing windows"); + Check(AccountUsageHelpers.Windows(new TokenAccount()).Count == 0, "unknown quota is not zero usage"); + Check(AccountUsageHelpers.Windows(JsonSerializer.Deserialize(JsonSerializer.Serialize(weekly))!).Single().Label == "7d", "window availability survives restart"); + using var cards = JsonDocument.Parse("""{"available_count":3,"credits":[{"status":"available","expires_at":"2026-10-04T08:00:00Z"},{"status":"redeemed","expires_at":"2026-10-03T08:00:00Z"},{"status":"available","expires_at":null}]}"""); + Check(OpenAIUsageService.ApplyResetCreditPayload(weekly, cards.RootElement), "read-only reset credit data parsed"); + Check(weekly.ResetCreditsAvailable == 3 && weekly.ResetCreditDetails!.Count == 2, "credit count independent from capped detail list"); + Check(weekly.ResetCreditDetails![0].ExpiresAt == DateTimeOffset.Parse("2026-10-04T08:00:00Z") && weekly.ResetCreditDetails[1].ExpirationKnown, "expiry instant and explicit unlimited expiry preserved"); + var detailsText = AccountUsageHelpers.DetailsText(weekly, UsageDisplayMode.Used); + Check(detailsText.Contains("其余重置卡") && detailsText.Contains("无到期限制") && detailsText.Contains("2026-10-04"), "details show precise expiry and missing detail notice"); + using var onlyCount = JsonDocument.Parse("""{"available_count":4}"""); + OpenAIUsageService.ApplyResetCreditPayload(weekly, onlyCount.RootElement, details:false); + Check(weekly.ResetCreditsAvailable == 4 && weekly.ResetCreditDetailsStale, "changed count marks old expiry details as cached"); + using var noCards = JsonDocument.Parse("""{"available_count":0,"credits":[]}"""); + OpenAIUsageService.ApplyResetCreditPayload(weekly,noCards.RootElement); + Check(weekly.ResetCreditsAvailable == 0 && weekly.ResetCreditDetails!.Count == 0 && !weekly.ResetCreditDetailsStale, "zero credits clear stale expirations"); + Check(AccountUsageHelpers.ExactTime(DateTimeOffset.Parse("2026-10-04T08:01:02Z")).Contains(":02 "), "exact times include seconds and UTC offset"); + using var handler = new UsageFixtureHandler(); + var reader = new OpenAIUsageService(new HttpClient(handler)); + var fixtureAccount = new TokenAccount { AccessToken = "fixture-token", AccountId = "fixture-account" }; + Check(await reader.RefreshUsageAsync(fixtureAccount) == UsageRefreshOutcome.Updated && fixtureAccount.ResetCreditsAvailable == 2, "usage refresh reads reset credit endpoint"); + Check(handler.Requests.Any(r => r.EndsWith("/rate-limit-reset-credits")) && handler.OnlyGets, "reset credit integration sends GET requests only"); + var registry = new AccountRegistry(); + registry.UpsertAccount(new TokenAccount { AccountId = "fake-a", Email = "a@example.test" }, true); + registry.UpsertAccount(new TokenAccount { AccountId = "fake-b", Email = "b@example.test" }, false); + Check(registry.RemoveAccount("fake-a") && registry.ActiveAccountId == "fake-b", "delete active chooses remaining account"); + registry.Save(); registry.Load(); Check(registry.Accounts.Count == 1 && registry.Accounts[0].AccountId == "fake-b", "deleted account stays deleted after reload"); + Check(registry.RemoveAccount("fake-b") && registry.ActiveAccountId is null, "delete last account clears selection"); + Check(!registry.RemoveAccount("fake-b"), "repeat delete harmless"); + using var radar = JsonDocument.Parse("{\"window_open\":true,\"window\":{\"opened_at\":\"2026-09-26T08:07:13+08:00\"},\"prediction\":{\"level\":\"low\",\"updated_at\":\"2026-07-13T22:08:06+08:00\"}}"); + var prediction = CodexRadarService.Parse(radar.RootElement); + Check(prediction.WindowOpen == true && prediction.DisplayText.Contains("速蹬窗口开启") && !prediction.DisplayText.Contains("低"), "current window takes precedence over stale probability"); + using var closed = JsonDocument.Parse("{\"window_open\":false,\"prediction\":null}"); + Check(CodexRadarService.Parse(closed.RootElement).DisplayText.Contains("未开启"), "explicit closed window supported without prediction"); + Check(CodexRadarPrediction.Loading.DisplayText.Contains("获取中") && CodexRadarPrediction.Unavailable.DisplayText.Contains("暂不可用"), "radar state remains visible without data"); + using var empty = JsonDocument.Parse("{}"); Check(!CodexRadarService.Parse(empty.RootElement).IsAvailable, "missing radar data unavailable"); + string Release(string tag, string architecture = "x64", bool preview = false) => JsonSerializer.Serialize(new { + tag_name = tag, draft = false, prerelease = preview, + assets = new[] { new { name = $"WinCodexBar-{tag.TrimStart('v')}-win-{architecture}.zip", browser_download_url = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/WinCodexBar-{tag.TrimStart('v')}-win-{architecture}.zip" }, + new { name = "SHA256SUMS.txt", browser_download_url = $"https://github.com/windycn/WinCodexBar/releases/download/{tag}/SHA256SUMS.txt" } } + }); + foreach (var arch in new[] { "x86", "x64", "arm64" }) + Check(AppUpdateService.ParseRelease(Release("v0.3.0", arch), new Version(0,2,0,0), arch)?.AssetName.EndsWith(arch + ".zip") == true, "matching update architecture " + arch); + Check(AppUpdateService.ParseRelease(Release("v0.2.0"), new Version(0,2,0,0), "x64") is null, "same version not updated"); + Check(AppUpdateService.ParseRelease(Release("v0.3.0", preview:true), new Version(0,2,0), "x64") is null, "prerelease ignored"); + Reject(() => AppUpdateService.ParseRelease(Release("v0.3.0").Replace("https://github.com/", "https://example.test/"), new Version(0,2,0), "x64"), "foreign download rejected"); + Reject(() => AppUpdateService.ParseRelease(Release("v0.3.0"), new Version(0,2,0), "arm64"), "missing architecture rejected"); + var zip = Path.Combine(scratch, "test.zip"); + using (var archive = ZipFile.Open(zip, ZipArchiveMode.Create)) { using var writer = new StreamWriter(archive.CreateEntry("WinCodexBar.exe").Open()); writer.Write("fake binary"); } + var hash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(zip))); + AppUpdateService.VerifyChecksum(zip, "test.zip", hash + " test.zip"); Check(true, "matching checksum accepted"); + Reject(() => AppUpdateService.VerifyChecksum(zip, "test.zip", new string('0',64) + " test.zip"), "corrupt download rejected"); + AppUpdateService.ExtractPackage(zip, Path.Combine(scratch,"valid")); Check(File.Exists(Path.Combine(scratch,"valid","WinCodexBar.exe")), "valid update package extracted"); + var bad = Path.Combine(scratch,"bad.zip"); using (var archive = ZipFile.Open(bad, ZipArchiveMode.Create)) archive.CreateEntry("../escape"); + Reject(() => AppUpdateService.ExtractPackage(bad, Path.Combine(scratch,"bad")), "zip traversal rejected"); + var script = AppUpdateService.BuildInstallScript("C:\\O'Brien\\package", "C:\\app", "C:\\data", 1234); + Check(script.Contains("O''Brien") && script.Contains("previous-program") && script.Contains("before-update-"), "installer escapes paths and includes backups"); + Console.WriteLine($"{passed} checks passed"); +} +finally { Directory.Delete(scratch, true); } + +internal sealed class UsageFixtureHandler : HttpMessageHandler +{ + public List Requests { get; } = new(); + public bool OnlyGets { get; private set; } = true; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + OnlyGets &= request.Method == HttpMethod.Get; + var path = request.RequestUri!.AbsolutePath; Requests.Add(path); + var json = path.EndsWith("/rate-limit-reset-credits") + ? """{"available_count":2,"credits":[{"status":"available","expires_at":"2026-10-04T08:00:00Z"}]}""" + : path.EndsWith("/usage") ? """{"rate_limit":{"primary_window":null,"secondary_window":{"used_percent":42,"limit_window_seconds":604800}},"rate_limit_reset_credits":{"available_count":2}}""" + : "{}"; + return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK) { Content = new StringContent(json) }); + } +} diff --git a/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj b/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj new file mode 100644 index 0000000..d419149 --- /dev/null +++ b/tests/WinCodexBar.CoreTests/WinCodexBar.CoreTests.csproj @@ -0,0 +1,21 @@ + + Exenet8.0enableenable + + + + + + + + + + + + + + + + + + + diff --git a/tests/WinCodexBar.WindowsTests/Program.cs b/tests/WinCodexBar.WindowsTests/Program.cs new file mode 100644 index 0000000..7f91c9e --- /dev/null +++ b/tests/WinCodexBar.WindowsTests/Program.cs @@ -0,0 +1,212 @@ +using CodexBarWin.Models; +using CodexBarWin.Services; +using CodexBarWin.UI; +using System.Collections; +using System.Diagnostics; +using System.Reflection; +using System.Runtime.InteropServices; + +internal static class Program +{ + private static int _passed; + private static readonly BindingFlags Private = BindingFlags.Instance | BindingFlags.NonPublic; + private static void Check(bool condition, string label) { if (!condition) throw new Exception(label); Console.WriteLine("PASS " + label); _passed++; } + private static object? Field(object target, string name) => target.GetType().GetField(name, Private)!.GetValue(target); + private static object? Call(object target, string name, params object[] args) => target.GetType().GetMethod(name, Private)!.Invoke(target, args); + private static IEnumerable Descendants(Control c) => c.Controls.Cast().SelectMany(child => new[] { child }.Concat(Descendants(child))); + private static void Paint(Form form, string? file = null) + { + Application.DoEvents(); + using var bitmap = new Bitmap(form.Width, form.Height); + form.DrawToBitmap(bitmap, new Rectangle(Point.Empty, bitmap.Size)); + if (file is not null) bitmap.Save(Path.Combine(AppContext.BaseDirectory, "screenshots", file)); + } + [STAThread] + private static void Main() + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + var home = Path.Combine(Path.GetTempPath(), "WinCodexBar UI ' " + Guid.NewGuid().ToString("N")); + Environment.SetEnvironmentVariable("CODEXBAR_HOME", home); + Directory.CreateDirectory(home); + Directory.CreateDirectory(Path.Combine(AppContext.BaseDirectory,"screenshots")); + try + { + var store = new CodexBarConfigStore(); + var wake = new KeepAwakeService(store); // 不 Load,不改变机器唤醒状态。 + foreach (var scale in new[] {100,150,200,250}) + { + AppAppearance.ScalePercent = scale; + using var settings = new SettingsForm(store, wake, () => {}, () => {}); + settings.Show(); + if (scale == 100) ProbeFont(settings.Font); + Console.WriteLine($"Text font: {settings.Font.Name}; family: {settings.Font.FontFamily.Name}; style: {settings.Font.Style}; size: {settings.Font.Size}"); + foreach (var size in new[] {new Size(800,600),new Size(1366,768),new Size(1920,1080)}) + { + settings.Size = size; settings.ApplyAppearance(); + var pageType = typeof(SettingsForm).GetNestedType("Page", BindingFlags.NonPublic)!; + foreach (var page in Enum.GetValues(pageType)) + { + Call(settings,"SelectPage",page!); Paint(settings,$"settings-{scale}-{page}.png"); + Check(!((ScrollableControl)settings.Controls[0]).HorizontalScroll.Visible, $"settings has no horizontal overflow {scale}% {page}"); + foreach (var button in Descendants(settings).OfType