diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..be1ce3d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +说明具体问题和改动后的行为。 + +验证:列出实际运行的构建、回归测试和原生界面检查;说明尚需实机验收的部分。 + +发布范围:说明是否影响 Beta、正式或旧更新线路。涉及 Beta 发布时,填写 build、标签、PKG SHA256 和发布记录;正式线路变更需要维护者明确决定。 diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 00bbaf1..ce5e7c7 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -1,13 +1,14 @@ -# This workflow will build a Swift project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift - -name: Swift +name: macOS checks on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: checks-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: @@ -16,12 +17,17 @@ jobs: steps: - uses: actions/checkout@v5 - - name: Build - run: swift build -v - - name: Run tests (if present) - run: | - if [ -d Tests ] && find Tests -name "*.swift" -print -quit | grep -q .; then - swift test -v - else - echo "No test files found. Skipping swift test." - fi + with: + fetch-depth: 0 + - name: Check whitespace + run: git diff --check origin/main...HEAD + - name: Build release + run: swift build -c release + - name: Verify stable and Beta update contracts + run: bash packaging/pkg/test_updates.sh + - name: Verify native UI controllers + run: python3 packaging/pkg/test_ui.py + - name: Verify actual startup sequence + run: python3 packaging/pkg/test_startup.py + - name: Verify published Beta70 source baseline + run: python3 scripts/verify_release_source.py docs/releases/2.9.10-beta.70.json --ref v2.9.10-beta.70 diff --git a/.gitignore b/.gitignore index 7d09900..d1e7357 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,20 @@ *.dmg *.zip /v*/ + +# Generated applications, packages and developer-machine state +*.app/ +*.pkg +/build/ +.swiftpm/ +.wrangler/ +.learnings/ +__pycache__/ +*.py[cod] +.claude/settings.local.json + +# Private feedback correspondence stays on the developer's machine +/feedback-consent-emails.md + +# Offline experiments: keep source available; exclude generated data +/analysis/thermal_ml/artifacts/ diff --git a/.wrangler/cache/cf.json b/.wrangler/cache/cf.json deleted file mode 100644 index 2dc83e0..0000000 --- a/.wrangler/cache/cf.json +++ /dev/null @@ -1 +0,0 @@ -{"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":3,"clientQuicRtt":0,"colo":"NRT","asn":16509,"asOrganization":"Amazon Data Services Japan","country":"JP","isEUCountry":false,"city":"Tokyo","continent":"AS","region":"Tokyo","regionCode":"13","timezone":"Asia/Tokyo","longitude":"139.69171","latitude":"35.6895","postalCode":"101-8656","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"mQbDAsX3g+VfmAnJb0+8iFF08pNbfwEWzMTJYBd8M4U=","tlsClientCiphersSha1":"kXrN3VEKDdzz2cPKTQaKzpxVTxQ=","tlsClientExtensionsSha1":"1eY97BUYYO8vDaTfHQywB1pcNdM=","tlsClientExtensionsSha1Le":"u4wtEMFQBY18l3BzHAvORm+KGRw=","tlsExportedAuthenticator":{"clientHandshake":"0dfabbfd3d4f2add420af85cf885a9e7392e59fb2352ea6506b5f283139fe7fa474b0b38b9c6286ab5d998d717c3255f","serverHandshake":"07eafe13dfd51000e154bba751d6faa44d3aa9bd134a09b42d0097a0668cde95164367a30cfba0efcb73e82543ba1706","clientFinished":"49d53dc6dfc958763534b663ef495f737956b761b45f402df3a2b409878ab0592d4480ae07f01df98e2da27f1bd80f2b","serverFinished":"09a6b9d8f8e27b7faaaf460621bea2217bf2045067b7716ebcacac25ed5f8ee687041c682c31b52e943ec4302183f486"},"tlsClientHelloLength":"1603","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":"","certRFC9440":"","certRFC9440TooLarge":false,"certChainRFC9440":"","certChainRFC9440TooLarge":false},"verifiedBotCategory":"","edgeL4":{"deliveryRate":1443189},"botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}} \ No newline at end of file diff --git a/.wrangler/cache/wrangler-account.json b/.wrangler/cache/wrangler-account.json deleted file mode 100644 index a8e481c..0000000 --- a/.wrangler/cache/wrangler-account.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "account": { - "id": "6af3ba3f14eda02e1dcf2c31dabe1300", - "name": "Puremilkchun@foxmail.com's Account" - } -} \ No newline at end of file diff --git a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite b/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite deleted file mode 100644 index 0b58f0d..0000000 Binary files a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite and /dev/null differ diff --git a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm b/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm deleted file mode 100644 index 18c10e6..0000000 Binary files a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm and /dev/null differ diff --git a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal b/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal deleted file mode 100644 index 2c3b346..0000000 Binary files a/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal and /dev/null differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite deleted file mode 100644 index 0b58f0d..0000000 Binary files a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite and /dev/null differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite-shm b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite-shm deleted file mode 100644 index 80e9ea9..0000000 Binary files a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite-shm and /dev/null differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite-wal b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite-wal deleted file mode 100644 index 89e82c8..0000000 Binary files a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/340b65296ce069dac6da318b738a3d2bd17795a792c3c0658061eb51a7e6b082.sqlite-wal and /dev/null differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite deleted file mode 100644 index 0b58f0d..0000000 Binary files a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite and /dev/null differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite-shm b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite-shm deleted file mode 100644 index 6b2095f..0000000 Binary files a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite-shm and /dev/null differ diff --git a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite-wal b/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite-wal deleted file mode 100644 index 8e73a1b..0000000 Binary files a/.wrangler/state/v3/kv/miniflare-KVNamespaceObject/metadata.sqlite-wal and /dev/null differ diff --git a/CLAUDE.md b/CLAUDE.md index a4865e6..eb3052f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,10 @@ iFanControl 是一个面向 Apple Silicon Mac 的原生菜单栏风扇控制工 ## App 架构红线 +- **UI意图保护**:主菜单“告诉开发者”下的邀请文本框是维护者明确保留的设计,用于鼓励反馈;点击进入独立窗口而不直接编辑是预期行为,不得当重复/假输入移除。 +- **启动呈现边界**:等待面板必须先于硬件探测显示,按真实步骤更新,到菜单/控制循环初始化后收起;无假百分比/最低等待。更新回执仍按运行build校验,显示在面板收起之后。首次引导与无风扇提示仍保留;不要为缩短等待误改原硬件/遥测/安全逻辑。 +- **状态时间语义**:recordedAt是最近一次状态记录写入时间,普通相同签名不会重写,force路径可强制记录;不能与“正常”并列呈现为过去才正常,不能为了UI时间刷新改控温/记录频率。68显示当前状态/配置模式,记录时间在明确标注的tooltip中。 + - 菜单一次性构建 + 原地更新(`buildMenuOnce` + `updateDynamicMenuItems`),不要每 2 秒重建 NSMenu - `BackgroundHardwareReader` 在后台线程执行硬件 I/O,NSLock 保护缓存 - `FanManager` 是 `@MainActor`,硬件调用**必须**在后台线程 @@ -24,17 +28,18 @@ iFanControl 是一个面向 Apple Silicon Mac 的原生菜单栏风扇控制工 - ZIP 包 app bundle 里**绝不能包含 config.json**(2026-04-27 教训:误带旧 config 导致误判无风扇) - 开机自启动用 `SMAppService.mainApp.register()`,旧 LaunchAgent plist 首次启动时自动清理 -## 构建与替换 +## 构建与Git约定 + +遵循 `CONTRIBUTING.md`。main维持正式代码,codex/3.0-beta保存Beta开发基线;日常直接小步提交/推送开发分支,较大修改按需用任务分支和PR;禁止强推/删除长期分支,不要求构建/PR作为保存代码门槛。Beta标签与发布记录对应真实源码输入。构建workflow已停用、只保留手动触发,不得为了保存Git历史运行构建/测试/打包;用户明确要求本阶段只建立历史、追踪和回退。不能用Git推送触发部署。 + +App元数据来源为 `packaging/app/Info.plist`,不要修改本机生成的App来代替源码版本提交。 ```bash -swift build -c release -cp .build/release/MacFanControl iFanControl.app/Contents/MacOS/iFanControl -cp iFanControl.app/Contents/MacOS/iFanControl /Applications/iFanControl.app/Contents/MacOS/iFanControl -codesign --force --sign - /Applications/iFanControl.app -open /Applications/iFanControl.app +python3 scripts/build_app.py +python3 packaging/pkg/build.py --app build/iFanControl.app --tool kentsmc ``` -部署前先确认用户实际运行路径:`/Applications/iFanControl.app/`,不是开发目录(CODE_AUDIT.md#3) +产物位于build/与packaging/pkg/output/,不追踪二进制App/签名/Cloudflare缓存。本机历史文件保留;实验与反馈邮件不要混入公开提交。禁止自动替换用户实际安装的 `/Applications/iFanControl.app`;安装体验通过现有PKG路线验收。 ## 发布纪律 @@ -44,6 +49,12 @@ open /Applications/iFanControl.app 不要只推 GitHub 代码就认为用户能收到更新。 +### Beta 独立发布(2026-10-12 用户确认) + +开发者手动本地安装 2.9.10/build62 作为种子版本,先只部署 Beta 线路。Beta 迭代只改 `update-manifest-beta.json`、独立含 build 的 PKG 与该 feed 的 no-store 规则;不要同步推进正式 manifest、`iFanControl-macOS.pkg`、`/download`、官网版本/时间线或正式 GitHub Release。正式/旧线路保持 2.9.9/build52,直到用户决定正式发布。 + +`output/release-candidate/channel-preview/` 包含待正式发布的 build62 文件,**不能整体部署为 Beta 测试线路**。首次 Beta 部署使用 `output/beta-bootstrap-62/site/`,以线上 Production 资产清单逐一校验后构建;后端 functions 保持原源码,保留与线上不同的本地文件,避免将无关工作顺带发布。后续 Beta build 必须大于 62;正式发布时 build 必须大于此前所有 Beta build。 + ## 官网部署 ```bash @@ -64,7 +75,13 @@ npx wrangler pages deploy ./ --project-name ifan-stats --no-bundle --skip-cachin 数据库结构、表设计等细节见 `ifan-stats/` 目录内代码。 -## 多语言 +## 3.0 UI 设计约束(2026-10-13) + +用户要求先全面梳理再改善。简洁、优雅、高效涵盖进入、等待、反馈、失败恢复和真实完成;现状与方案见 `docs/development/3.0-ui-audit.md`。保留PKG的系统完成勾与打开说明,不用自定义conclusion覆盖它,不在root安装脚本中自动启动GUI。检查/下载/校验/打开Installer不能提前宣称安装成功。 + +菜单一次构建原地刷新、即时展示偏好、转速显式应用、曲线关闭保存/预设切换通知都有现有理由。UI重构应明确这些语义,不能把有意的差异当bug一并改掉。安全窗口95℃与代码/历史110℃的差异应按真实规则统一说明,不能改控温来迁就文案。第一轮64/65行为验收通过但视觉被用户否定;第二轮已应用原生设计skill并发布Beta66,原从65→66验收。66记录见 `docs/development/3.0-ui-polish.md`。Beta67曾进一步精简主菜单并修复提示白边/放大勾,信息栏设置维持66原样;新记录见 `docs/development/3.0-ui-menu-notice.md`。Beta68恢复反馈邀请框并说明状态记录时间,记录见 `docs/development/3.0-ui-feedback-intent.md`;原58个非Beta-feed资产及后端保持,正式build52线路继续保留;Beta69放大帮助侧栏导航并收紧选择区域、添加官网按钮,记录见 `docs/development/3.0-ui-help-sidebar.md`;保留59个非Beta-feed资产和后端,正式/旧/download仍52,当前Beta70增加真实启动阶段面板,菜单/控制循环初始化后收起,再显示更新回执/首次引导;保留60个旧非Beta-feed资产,正式/旧/download仍52,记录见 `docs/development/3.0-ui-startup.md`,下一build须大于70。 + +## 多语言约定 中 / 英存储在 `UserDefaults("ifancontrol.ui.language")`,切换后需重启。三个模块各自定义 `currentLanguage`(main.swift / SensorCatalog.swift / FanCurveWindow.swift)。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..37ebee2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Git历史与回退约定 + +本阶段只建立源码历史、远端备份和回退点。提交或推送不触发构建、测试、打包或部署;GitHub构建workflow已停用,源码中的触发方式也只保留手动入口。现有构建/测试工具保留供将来明确需要时使用,不是保存代码的前置条件。 + +## 分支与检查点 + +- `main`:正式版本代码,目前为2.9.9/build52。正式发布另行决定。 +- `codex/3.0-beta`:当前开发代码。已保存Beta70完整基线,后续可以直接小步提交和推送。 +- `codex/<任务名>`:较大实验或需要隔离的修改,可按需创建;PR也是可选的审阅入口。 +- 两条长期分支只防止强推和删除,没有必须构建或PR的门槛。 +- 发布检查点用注释标签,例如 `v2.9.10-beta.70`。v*标签禁止更新/删除,已发布检查点不覆盖。 + +Beta70源码基线提交为 `03e3d1d`;对应标签和 `docs/releases/2.9.10-beta.70.json` 保存已发布包的源码输入与哈希。它是发布后补录的基线,未伪造62~69提交,也不声称原包中嵌入了此Git提交。 + +## 日常保存 + +```sh +git status --short +git diff +# 明确选择本次文件,例如: +git add Sources/MacFanControl/main.swift +git diff --cached +git commit -m "fix: describe the concrete change" +git push +``` + +按能独立理解的改动提交,常用feat/fix/docs/chore前缀。不混入其他尚未完成的工作,不使用未经查看的整目录提交。构建、安装、替换用户App和云端发布与Git保存分开;本阶段不执行这些操作。 + +## 查看与回退 + +```sh +git log --oneline --decorate -12 +git show v2.9.10-beta.70 +``` + +已推送的错误修改用 `git revert <提交>` 生成反向提交,再推送,保留发生问题和修复的完整历史。它只改变源码,不会自动降级已安装的软件。 + +需要查看完整旧状态时,先提交或暂存当前改动,再从标签创建恢复分支: + +```sh +git switch -c codex/recovery-beta70 v2.9.10-beta.70 +``` + +不对共享分支使用reset --hard加force push,不删除现有历史,也不重写发布标签。回退前先确认需要撤销的范围;这里提供操作方法,不代表已执行产品回退。 + +## 文件范围 + +追踪Sources、源码元数据 `packaging/app/Info.plist`、图标/现有工具输入、脚本、研发文档和截图证据。生成App/PKG/签名、Cloudflare/Xcode缓存、用户配置、反馈邮件与实验数据不入库。 + +原本已追踪的App/缓存仅取消Git追踪,现有磁盘文件和旧Git历史保留。analysis、HTML试稿及旧公告经本机.git/info/exclude留在工作区;以后需要维护时单独审阅加入。研发截图保存在docs/development/assets;完整旧构建诊断仍在忽略的packaging/pkg/output。 diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index d21cbbc..f57fccf 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -3,7 +3,7 @@ 关联时间戳记忆:`/Users/puremilk/Documents/mac fancontrol/macfan-control-v2/PROJECT_TIMELINE.md` 读取建议:先读本文件,再读时间戳记忆,避免遗漏最新变更。 -更新时间:2026-10-05 +更新时间:2026-10-13 ## 1. 项目当前基线 @@ -20,7 +20,40 @@ iFanControl 是一个面向 Apple Silicon 带风扇机型的风扇控制工具 - 开机自启动(SMAppService 原生登录项 API,系统设置中可直接管理) - 信息栏展示设置(完整/简洁/迷你三种模式,弹窗设置,存 `ifancontrol.ui.display_mode`,切换即时生效) -当前版本:`2.9.9` / build 52 +当前正式版本:`2.9.9` / build 52 + +开发者种子版本:`2.9.10` / build 62(由 52 递增 10),用户手动本地安装。独立 Beta 线路已部署;正式渠道仍为 `2.9.9` / build52,未正式发布 build62。 + +当前Beta版本:`2.9.10 Beta` / build70,Production `6066e693-f572-417a-b5b9-cd784c371078`。维护者可从69在线升级;下一build须大于70。正式/旧线路和官网下载维持2.9.9/build52,最终核验见 output/beta-70/verification.json。 + +UI意图保留项:主菜单“告诉开发者”下的文本框外观有意用于鼓励反馈,虽不直接输入、与标题打开同一反馈窗口,也必须保留,不能按“重复/假输入”删掉。68恢复原文案/圆角/留白,点击区域使用透明原生按钮,先结束菜单追踪再打开独立反馈窗口。中英文实际控件点击已验证正文获得焦点。 + +顶部明确显示“控制正常 · 自动/手动”,记录时间放在提示文字中并命名“最近一次状态记录”。ControlStatusStore的recordedAt是状态记录写入时间;正常mode/detail签名未变化时不逐次重写,启动/交还系统等force路径例外。不能把它当实时采样/最后正常检查时间,也不能为让时间新鲜而改写原控温/记录规则。 + +70增加启动面板:在硬件识别前显示非激活原生窗口与独立线程等待指示器,依真实执行阶段更新文案,菜单/控制循环和后台任务准备好后收起。“已更新至…”与首次语言/无风扇/温度源提示延到收起后。初始化本身的探测、硬件顺序、控温及安全逻辑不变,没有假百分比或最低展示计时。 + +Release、原生控制器、真实updater回归通过;新增实际AppDelegate隔离回归覆盖普通/6秒同步等待/无风扇/解锁失败/未来回执/首次语言六种场景,断言等待面板先于探测、菜单/计时器完整初始化后收起、之后回执和引导。实际中文浅色、英文深色、阶段切换和完成勾已查看。测试硬件/命令/网络为替身,状态/配置/日志隔离;没有真实安装/控温、替换用户应用或发送反馈。源文件保护见 output/beta-70/protected-invariants.json。 + +70PKG SHA256 `b446cf03a71c2d3e3652c715f702d7ad14ce4f4c52cc4fe5f53a709090b5cf7a`。Beta单独发布保留60个非Beta-feed旧资产及后端/绑定,官网和稳定/旧manifest为52;以最终verification记录为准。当前说明见 `docs/development/3.0-ui-startup.md`。69帮助侧栏/官网、68反馈意图及更早UI交付记录作为历史保留。自动预下载/设备适配尚未混入,正式发布另行决定。真实安装后的启动等待体验由维护者通过69→70验收。 + +### Git机制(2026-10-13) + +Beta70基线在 `codex/3.0-beta`,对应注释标签 `v2.9.10-beta.70`;main继续保留正式build52。长期分支只禁止强推/删除;没有构建/PR门槛。用户明确缩小范围为Git历史、追踪、回退,已停用自动构建,不再重跑CI;原在途任务在取消请求前已结束,目前无运行任务。v*版本标签通过仓库ruleset禁止更新/删除。规则见CONTRIBUTING.md,发布输入/包/部署证据见docs/releases/2.9.10-beta.70.json。 + +Info.plist提交来源为packaging/app/Info.plist,root icon.png和kentsmc作为固定输入;scripts/build_app.py生成build/iFanControl.app并记录提交/是否有改动/哈希。生成App、签名、PKG、Cloudflare状态停止追踪但磁盘文件不删除;历史Git不改写。实验源码/HTML/旧公告仍在本机exclude,反馈邮件全局ignore;原生证据图纳入docs/development/assets。 + +现有构建/回归脚本保留供以后明确需要时使用,本阶段不再运行。GitHub构建workflow已停用,源码触发方式仅手动;推送不会触发构建、测试、打包、部署或正式Release。反向提交git revert用于共享历史回退,标签恢复分支用于检查旧状态,不使用reset/force覆盖历史。70是发布后补录源码基线,未伪造62~69提交。 + +### 2.9.10 / build 62 筹备 + +- 简介大 App 图标同次开窗累计点击十次解锁开发者模式;未解锁时关窗清零,解锁状态跨启动/升级保留。 +- 独立开发者页中单独勾选 Beta,默认仍为正式线路;退出开发者模式会关闭 Beta 并隐藏入口,仅改变后续更新路线,不自动降级。 +- Beta 使用 `update-manifest-beta.json`,与正式版共用现有 PKG 提示、下载、SHA256、系统安装器和正常退出流程;检查/下载中切换路线会忽略旧结果。 +- 正式和 Beta 共用递增 build;Beta 身份用独立 Info.plist/manifest 字段,保持系统数字版本字段及既有统计格式。 +- 未改变硬件、控温、曲线、配置、遥测、菜单轮询和安装脚本。首次发布仅增加隐藏入口及更新通道支持,不混入 3.0 UI/硬件/预下载改动。 +- 种子 PKG 与待正式发布预览:`packaging/pkg/output/release-candidate/`;不要整体部署预览为 Beta 测试。Beta 单独发布只改 Beta feed、独立 PKG 与其缓存规则,正式/旧线路、官网下载/时间线及正式 GitHub Release 保持 build52。 +- 已上线 Beta 基线:2.9.10/build62 种子包独立副本,Production `cb249cea-6cb3-4703-84b2-477f13454a28`;53 个旧静态资产哈希、7 个函数源码及运行绑定未变。部署/核验记录在 `packaging/pkg/output/beta-bootstrap-62/`。 +- 构建、通道/校验测试、真实 updater 隔离夹具通过,原生窗口验证十次点击/隐藏/退出及深浅色;实际在线 PKG 安装升级仍需发布前验证。 ### build 52 说明 diff --git a/PROJECT_TIMELINE.md b/PROJECT_TIMELINE.md index 06dfe42..d8de523 100644 --- a/PROJECT_TIMELINE.md +++ b/PROJECT_TIMELINE.md @@ -3,6 +3,110 @@ 关联固定记忆:`/Users/puremilk/Documents/mac fancontrol/macfan-control-v2/PROJECT_MEMORY.md` 读取建议:先读固定记忆,再读本文件,用本文件补足最近发生的变化。 +## 2026-10-13 + +### 2026-10-13 — Git开发与发布机制 + +- 用户授权规范Git。将Beta70完整源码/测试/研发记录保存到codex/3.0-beta并建立注释标签;main和正式线路保持52,基线PR为Draft。 +- App元数据移到packaging/app/Info.plist,新增从源码生成App的脚本及输入哈希校验;发布记录补录70的公网包/源码/部署身份,明确是发布后记录而非包内已有Git提交。 +- 停止追踪生成App/签名与Cloudflare缓存,保留磁盘文件和旧历史;实验/邮件仍本机保留。69张已检查的原生截图迁为研发文档证据,图片字节不改。 +- 最初扩展到CI与构建,随后用户明确只需Git历史/追踪/回退。按最新要求停止继续构建/测试,请求取消在途CI(请求前任务已结束)并停用workflow,源码只保留手动触发;长期分支去掉构建/PR门槛,只禁止强推/删除。 +- 干净检出03e3d1d的App组装、更新/UI/启动回归和PKG构建通过。远端构建/更新检查通过后暴露反馈测试固定100ms等待的时序问题,改成有界等待真实发送完成;未改产品代码或已推送70标签。版本标签增加v*更新/删除规则保护。不再等待或重跑CI,不以构建作为本次Git机制完成条件。PR #5保留为可选草稿审阅入口。规则见CONTRIBUTING.md。 + + +### 2026-10-13 — 真实启动等待面板与回执顺序,Beta70 + +- 用户观察启动无持续反馈。原日志证实69硬件profile约7.7秒且随后仍有读取/解锁。新增非激活轻量面板,先显示再安排初始化,原生独立线程指示器覆盖同步探测,按硬件/偏好/菜单与控制阶段说明。 +- 菜单、风扇解锁调用、控制循环及后台任务安排好即收起;更新完成回执及首次引导在其后,退出时停止并收起。首次引导之前控制循环已准备。保持现有探测/硬件顺序/控温安全/更新校验与PKG,不造百分比或固定等待,不以初始化完成声明硬件可控。 +- Release、控制器、真实updater回归及新增实际AppDelegate隔离六场景通过;实际中文浅色/英文深色、三个阶段、启动后英文完成勾已查看。新夹具无真实硬件/网络,独立配置/偏好/日志。用户实际安装应用与进程未操作。 +- Beta70 SHA256 `b446cf03a71c2d3e3652c715f702d7ad14ce4f4c52cc4fe5f53a709090b5cf7a`;Production `6066e693-f572-417a-b5b9-cd784c371078`。只Beta feed/独立70包,保留60个旧非Beta-feed资产及后端/绑定;公网逐字节、三feed JSON/no-store、正式/旧/download52核验见 output/beta-70/verification.json。 +- 记录见 `docs/development/3.0-ui-startup.md`;新增test_startup.py/swift可重复验证。未安装/真实控温/发反馈、正式Release或提交/推送;夹具进程/偏好已清理。维护者69→70体验,下一build大于70。 + + +### 2026-10-13 — 帮助侧栏比例与官网入口,Beta69 + +- 按截图反馈,导航文字12→14点、侧栏176→164点、选中背景152×30→140×28点;简介页原生“访问官网”位于检查更新/GitHub之间,打开项目官方域名。 +- Release/原生控制器回归通过;实际中文浅色、英文深色、支持页和十次图标解锁后的开发者页均查看,官网按钮记录完整URL、HEAD200。修改仅HelpWindowController;其他源码/PKG脚本、反馈邀请、状态语义与完成勾保持。 +- Beta69 PKG SHA256 `0e5f10be77debae51e87a1ea04495a65a97572887312e466e8a7981727800fb8`;Production `79022cbb-d7e0-4e84-842a-2b140f38bb21`。仅Beta feed/新69PKG,保留59个旧非Beta-feed资产及相同后端/绑定;公网逐字节/SHA和正式/旧/download52验收见 output/beta-69/verification.json。 +- 记录见 `docs/development/3.0-ui-help-sidebar.md`。没有安装/真实控温、替换用户应用、发送反馈、正式Release、提交/推送;原生夹具进程/偏好已清理。维护者可从68在线更新69验收,下一build大于69。 + + +### 2026-10-13 — 恢复反馈邀请设计与正确解释状态时间,Beta68 + +- 维护者明确邀请文本框用于鼓励反馈,不能因与标题同向或不可直接编辑删除。68恢复原文案/外观与点击进入独立窗口,透明原生按钮负责完整点击区域;中英文控件点击后反馈正文获得焦点。 +- 核实recordedAt普通相同mode/detail签名不重写、force路径可强制写。顶部改“控制正常 + 自动/手动”,时间只在“最近一次状态记录”提示中;未改变记录频率或控温规则。 +- Release/原生控制器回归通过,菜单夹具用12分钟前记录检验状态文案/邀请框/全部命令/菜单原地刷新;实际中文浅色、英文深色手动模式和进入反馈窗口已看。 +- 只改MenuBarManager呈现与反馈点击路径,其他代码/PKG脚本保持。68包SHA256 aca31c48e7e2b1ed820748d031ab74c065dd2d935765e4d2179366c1fcf89ed2;Production9b2ca0fa-eb39-417d-a8f5-1d25fcb6b665,保护58个非Beta-feed资产/后端/绑定。正式/旧feed及官网下载仍52,最终线上核验见 output/beta-68/verification.json。 +- 设计意图已提升到CLAUDE.md和固定记忆,原审视中“精简重复反馈入口”的建议已撤回;记录见 docs/development/3.0-ui-feedback-intent.md。未安装/控温/发送真实反馈/替换用户应用,未正式发布、提交或推送。 + + +### 2026-10-13 — 主菜单与完成提示修正,Beta67 + +- 维护者确认“下拉框繁杂”指主菜单。信息栏设置简化试稿撤回,原设置精确恢复66。 +- 主菜单状态卡收为一行;低频设置集中子菜单,硬件/当前温度源信息进入对应子菜单;常用操作与全部selector保留,重复标题/分隔线移除,同一菜单原地刷新保持。 +- 完成提示采用单一圆角填充及原生阴影,勾24→36点;0.24秒描边、减少动态效果和非阻断自动消失保持。实际中文浅色/英文深色、手动菜单、子菜单与深浅色提示已看;原生RGBA四角alpha0、深色边缘亮像素0。 +- Release、控制器回归通过;最终菜单夹具运行全部13个操作/手动可见性/选中态/同一菜单对象断言后展示。保护记录证实只改菜单呈现与InteractionNotice,其他控温/配置/更新/PKG行为保持。 +- Beta67 PKG SHA256 `18fba494216e4e4ebb85223ea4ea9f5d9f250cfe64a257706083f59daf836e92`;Production `62d8bf39-9351-4f9f-8d1b-89e91ec69651`。公网重下载/SHA相同,原57个非Beta-feed资产和后端/绑定保持;正式/旧feed及HEAD官网下载仍52。 +- 记录见 `docs/development/3.0-ui-menu-notice.md`。没有真实安装/控温、替换用户应用、发送反馈、正式Release或提交/推送;人工从66在线升级67体验,下一build大于67。 + + +### 2026-10-13 — 原生UI第二轮与Beta66 + +- 用户认为第一轮视觉不如原版;安装/阅读redesign-existing-projects、macos-app-design及原生参考,按菜单栏工具重新设计层级和密度,不将测试通过视为审美结论。 +- 显示设置横向精简、曲线数值按需展开并保留无效输入可见、调速/功耗读数分级、700×500帮助只布局活动页、支持/反馈主次动作调整、完成勾0.24秒描边且尊重减少动态效果。详见 `docs/development/3.0-ui-polish.md`。 +- Release/原生控制器/真实updater隔离回归通过;中文浅色、英文深色主要窗口与分档/无数据/反馈成功/下载大小未知/打开失败重试/取消/回执勾实际查看。原生九/十次隐藏解锁及Beta独立选择核对。 +- 对照65保护硬件/遥测/控温/Config/更新协议和传输/反馈传输/生命周期/PKG脚本。没有替换用户应用、真实安装/控温或发送反馈;系统Installer完成页未重新复演。 +- Beta66 PKG SHA256 `202343b95ef5938d1f3508a05d05843a92d03da3efd6f90db5c9427803c24057`;Production `91662a64-a4c5-41ca-9a1a-7b1109678715`,前一65部署保留。原56个非Beta-feed资产、后端bundle/绑定不变,线上PKG重下载匹配;正式/旧manifest及HEAD `/download` 保持52。 +- 用户从65在线更新66验收原设置与真实安装收尾,随后评价视觉与日常操作。下一build大于66;未创建正式Release、提交/推送或整理旧文件。 + + +### 2026-10-13 02:21 — 四组UI改善完成自主验收,交付人工验收 + +- 完成更新状态/取消/重试及真实安装版本确认、原生菜单与展示预览、曲线数值与真实保存反馈、帮助支持/反馈/功率/首次说明四组工作,细项见 `docs/development/3.0-ui-delivery.md`。 +- 沿用已核实的设计理由:菜单一次构建/原地刷新、曲线关闭保存与预设即时通知、RPM显式应用、SHA256唯一校验、110℃保护规则、正常退出恢复系统控风与原生PKG完成勾。 +- Release构建、真实updater夹具、实际原生控制器回归通过;原生中文浅色/英文深色、键盘控制点/边界/无效输入、保存失败/重试、九/十次解锁、下载/取消/失败恢复与轻量完成提示已查看。修正自绘曲线越界遮挡、英文赞赏文字截断、发送旧状态与重开延时关闭问题。 +- 64本地验收起点 SHA256 `7b5bc24472446ed4fcecf2288d15b488dc70599063cfef3896f657adcd7019b8`;65线上目标 `d4778e53e8420faf1e359c9176cf9094ec9c114e49709cf515fcd23061d393bd`。两者签名前执行代码相同,仅build/签名不同;源码/构建记录在各 output/beta-* 目录。 +- Production `61e8903f-d65f-48fd-b6ad-15dfbbf52431` 成功;仅Beta feed/独立65PKG新增变化,55个非Beta-feed旧资产和后端/绑定不变。公网65包逐字节/SHA核对通过,Beta65/正式52/旧52 JSON/no-store,HEAD `/download` 仍52。 +- 实际Installer查看65欢迎/目的宗卷/安装类型,未安装、未替换用户应用、未发真实反馈、未运行真实控温。人工需先本地安装64,再Beta升级65,验收PKG系统完成动画、重启、设置及设备控制。测试应用进程/偏好清理,用户原运行进程保留;未提交/推送源码或创建正式Release。 + + +### 2026-10-12至13日 — 3.0 UI 全流程审视 + +- 用户确认已通过Beta更新到63,要求先梳理全部UI,再改善“简洁、优雅、高效”;明确PKG流程及系统完成勾是设计基准。 +- 新增 `docs/development/3.0-ui-audit.md`,盘点菜单、设置/曲线/功率/反馈、五个帮助页面、首次启动、更新状态、安装/失败/卸载等入口,注明当前设计理由和观察/源码证据边界。 +- 隔离原生探针使用独立偏好/配置与模拟遥测,不启动AppDelegate或控温;CUA查看中文浅色、代表性深色及英文深色,观察应用反馈、空输入验证、原生菜单和十次解锁。截图在 `packaging/pkg/output/ui-audit-63/`。 +- Installer实际查看63包的介绍/目的宗卷/安装类型,未点击安装,预览已关闭。保留原生完成勾的依据来自用户体验说明及未自定义conclusion的包配置,不宣称本轮复演了完成动画。 +- 发现更新下载/校验期间无可见状态、信息栏组合缺少最终预览、曲线自动保存缺少完整完成反馈、支持动作/卸载层级及英文标题截断;核对安全文案95℃与明确改为110℃的历史,UI说明改进不能反向改控温规则。 +- 整理分批方案与验收:优先完整更新状态反馈,随后菜单/展示、曲线、帮助/支持;产品源码/安装脚本经hash核对保持Beta63原样,无新构建或线上发布。 + +## 2026-10-12 + +### 2026-10-12 — 2.9.10 Beta/build63 在线升级联调发布 + +- 用户要求发布更高 Beta build 测试62→63更新线路;本次只增加 Info.plist Beta身份并递增build,重新生成ad-hoc签名,签名前可执行代码与62一致,资源/控温/配置/安装脚本未改。 +- PKG `packaging/pkg/output/beta-63/iFanControl-2.9.10.pkg`,SHA256 `fd8c59d08c02f1f3a6dcd6c05f8bc2de662d61786b6617bb28b946dec80a7391`;包展开/签名/脚本检查及更新测试通过。 +- 独立原生帮助窗口实际截图查看“版本2.9.10 Beta (63)”,只使用隔离偏好域,未运行AppDelegate/硬件控制;测试窗口已关闭、测试偏好已清理。 +- 只上传Beta manifest和独立63 PKG,复用上一Production的完全相同后端bundle/routes;保留原54个非Beta-feed资产、D1/环境/兼容设置和旧62包。 +- Production `a0b0d0c8-ba6c-4102-bf91-53b43b6fccf4` 成功,线上Beta63包及三份manifest重新下载匹配;Beta标识/JSON/no-store正确,正式/旧manifest及 `/download` 保持52。 +- 未创建正式Release、提交/推送源码或操作用户安装;实际下载安装、重启及配置/Beta偏好保留待用户执行。 + +### 2026-10-12 — 仅部署 Beta 线路,正式渠道保留 build52 + +- 用户确认手动安装 build62 种子包,再独立迭代 Beta,安全后才正式发布。 +- 新增 `update-manifest-beta.json`、独立 build62 PKG 与 Beta feed 的 no-store 规则;不改正式 manifest/PKG、旧 manifest/双 ZIP、官网下载入口、页面、时间线或正式 GitHub Release。 +- 使用旧 Production 资产清单构建隔离 staging,新部署保留原 53 个资产哈希、7 个后端函数源码、D1/环境及兼容设置;保留与线上不同的本地 schema 修改。 +- Wrangler 连接失败后,经已授权 Cloudflare API 提交同一编译 bundle/资产清单,Production `cb249cea-6cb3-4703-84b2-477f13454a28` 成功。 +- 线上 Beta build62 PKG 与本地种子包 SHA256 一致;正式/旧 manifest 仍为 build52,正式 PKG 与旧版 ZIP 哈希不变。实际在线安装升级待大于 62 的新 Beta build 联调。 + +### 2026-10-12 — 3.0 筹备与 2.9.10/build62 候选版 + +- 完整梳理当前 App 源码、更新/打包链路与关键提交,记录设计约束、资料差异和分阶段整理建议。 +- 用户确认先完成隐藏 Beta 更新通道:简介大图标同次开窗十次解锁,开发者模式持久保存,单独勾选 Beta;关闭/退出仅切后续更新路线。 +- 候选版本设为 2.9.10/build62(52 加 10),保持正式用户原有界面与更新体验;硬件、控温、曲线、配置、遥测、菜单轮询和 PKG 安装脚本未改动。 +- 新增更新偏好与 Beta feed 支持,复用正式 PKG 流程,避免通道切换后旧请求结果提示或安装;加入通道隔离、损坏包、真实更新服务夹具及 CI 检查。 +- 生成候选 PKG 与两份 native manifest 的独立待发布预览,legacy 文件及线上正式入口未改动;初始 Beta feed 使用同一正式包的独立副本。 +- 完成原生帮助窗口十次点击/默认隐藏/退出和深浅色检查。候选包仅在开发目录构建,尚未安装、发布或推送给用户。 + ## 2026-10-05 ### 2026-10-05 — v2.9.9 / build 52(安装与更新通道修正) diff --git a/README.md b/README.md index 545c9b5..c3940e2 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,10 @@ iFanControl 会默认发送定期的匿名用户量统计数据,用于改进 ├── Sources/ │ ├── FanCurveEditor/ │ └── MacFanControl/ -├── iFanControl.app/ # App bundle 模板资源 +├── packaging/app/ # 提交的 App 元数据 +├── packaging/pkg/ # PKG 构建和回归测试 +├── scripts/ # App 构建、发布源码校验 +├── docs/development/ # 研发记录和原生界面证据 ├── install.sh ├── diagnose.sh ├── uninstall.sh @@ -115,16 +118,15 @@ iFanControl 会默认发送定期的匿名用户量统计数据,用于改进 本项目使用 Swift Package Manager: ```bash -swift build -swift run +python3 scripts/build_app.py ``` -CI 位于 `.github/workflows/swift.yml`,默认会对 `main` 执行构建检查。 +生成的 App 位于 `build/iFanControl.app`,构建不会启动或安装它。构建workflow已停用,只保留手动入口,Git推送不会自动构建或发布。历史保存、版本标签和回退方法见 [Git约定](CONTRIBUTING.md)。 ## 更新机制 - 手动检查:`关于 iFanControl... -> 检查更新` -- 自动检查:启动后延迟检查,并带 24 小时节流 +- 自动检查:启动后延迟检查;启用时直接读取更新线路,不设置24小时节流 - 2.9.9 起:独立的 `update-manifest-pkg.json` 通道,直接下载并校验 PKG,打开系统安装器后退出 App - 旧版通道:`update-manifest.json` 永久停在 2.9.9,提供兼容 ZIP;不会向旧版推送后续版本 - 更新失败时:会直接引导到 GitHub Releases 手动下载 diff --git a/README_EN.md b/README_EN.md index 579c1f5..34390df 100644 --- a/README_EN.md +++ b/README_EN.md @@ -67,7 +67,9 @@ iFanControl sends periodic anonymous user-count stats by default to improve prod ├── Sources/ │ ├── FanCurveEditor/ │ └── MacFanControl/ -├── iFanControl.app/ +├── packaging/app/ # Tracked App metadata +├── packaging/pkg/ # Installer and regressions +├── scripts/ # Build and provenance helpers ├── install.sh ├── diagnose.sh ├── uninstall.sh @@ -79,11 +81,10 @@ iFanControl sends periodic anonymous user-count stats by default to improve prod ## Development ```bash -swift build -swift run +python3 scripts/build_app.py ``` -CI is defined in `.github/workflows/swift.yml`. +The build workflow is disabled and keeps a manual-only trigger. Git pushes do not build or deploy the app. ## Update Flow @@ -109,3 +110,7 @@ If the script came from a downloaded ZIP, drag `uninstall.sh` into Terminal and ## License [MIT](LICENSE) + +## Git and release provenance + +Run `python3 scripts/build_app.py` to assemble an ad-hoc signed `build/iFanControl.app` from tracked source, `packaging/app/Info.plist`, and `icon.png`. It does not launch or install the application. See [development and release conventions](CONTRIBUTING.md) for history, rollback branches, immutable Beta tags, and package/source provenance. diff --git a/Sources/FanCurveEditor/FanCurveView.swift b/Sources/FanCurveEditor/FanCurveView.swift index a9ee81a..89fbdb4 100644 --- a/Sources/FanCurveEditor/FanCurveView.swift +++ b/Sources/FanCurveEditor/FanCurveView.swift @@ -22,6 +22,7 @@ public struct FanPoint: Codable { // 风扇曲线视图 class FanCurveView: NSView { + var onCurveChanged: (([FanPoint]) -> Void)? var fanCurve: [FanPoint] = [] { didSet { setNeedsDisplay(bounds) @@ -61,10 +62,14 @@ class FanCurveView: NSView { super.draw(dirtyRect) guard let context = NSGraphicsContext.current?.cgContext else { return } + // Keep custom drawing inside the chart even when AppKit coalesces a larger dirty region. + context.saveGState() + context.clip(to: bounds) + defer { context.restoreGState() } // 绘制背景 NSColor.windowBackgroundColor.setFill() - dirtyRect.fill() + dirtyRect.intersection(bounds).fill() // 绘制坐标轴 drawAxes(context: context) @@ -83,15 +88,31 @@ class FanCurveView: NSView { } private func drawAxes(context: CGContext) { + let axisAttributes: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: NSColor.secondaryLabelColor] + let english = UserDefaults.standard.string(forKey: "ifancontrol.ui.language") == "en" + (english ? "Target RPM" : "目标转速 RPM").draw(at: NSPoint(x: padding, y: bounds.height - 20), withAttributes: axisAttributes) + (english ? "Temperature °C" : "温度 ℃").draw(at: NSPoint(x: bounds.width - padding - 110, y: 4), withAttributes: axisAttributes) + let width = bounds.width - 2 * padding let height = bounds.height - 2 * padding + // Quiet guides make numeric relationships readable without competing with the curve. + context.saveGState() + context.setStrokeColor(NSColor.separatorColor.withAlphaComponent(0.3).cgColor) + context.setLineWidth(0.5) + context.setLineDash(phase: 0, lengths: [3, 4]) + for fraction in [0.25, 0.5, 0.75] { + let y = padding + height * fraction + context.move(to: CGPoint(x: padding, y: y)); context.addLine(to: CGPoint(x: padding + width, y: y)); context.strokePath() + } + context.restoreGState() + // X轴(温度) let xAxisPath = CGMutablePath() xAxisPath.move(to: CGPoint(x: padding, y: padding)) xAxisPath.addLine(to: CGPoint(x: padding + width, y: padding)) context.addPath(xAxisPath) - context.setStrokeColor(NSColor.gray.cgColor) + context.setStrokeColor(NSColor.separatorColor.cgColor) context.setLineWidth(1) context.strokePath() @@ -105,7 +126,7 @@ class FanCurveView: NSView { // 绘制标签 let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.systemFont(ofSize: 10), - .foregroundColor: NSColor.gray + .foregroundColor: NSColor.secondaryLabelColor ] // 温度标签 @@ -194,7 +215,7 @@ class FanCurveView: NSView { let y = yPosition(forRPM: point.rpm, height: height) let circlePath = CGMutablePath() - circlePath.addArc(center: CGPoint(x: x, y: y), radius: 6, startAngle: 0, endAngle: .pi * 2, clockwise: false) + circlePath.addArc(center: CGPoint(x: x, y: y), radius: selectedPointIndex == index ? 6 : 4.5, startAngle: 0, endAngle: .pi * 2, clockwise: false) context.addPath(circlePath) @@ -301,9 +322,20 @@ class FanCurveView: NSView { fanCurve[index].rpm = max(0, min(maxRPM, newRPM)) } + onCurveChanged?(fanCurve) setNeedsDisplay(bounds) } + func updatePoint(_ index: Int, temperature: Double, rpm: Int) { + guard fanCurve.indices.contains(index), temperature.isFinite else { return } + if index == 0 { fanCurve[index].temperature = minTemp } + else if index == fanCurve.count - 1 { fanCurve[index].temperature = maxTemp } + else { fanCurve[index].temperature = max(fanCurve[index-1].temperature + 1, min(fanCurve[index+1].temperature - 1, temperature)) } + fanCurve[index].rpm = max(0, min(maxRPM, rpm)) + onCurveChanged?(fanCurve) + setNeedsDisplay(bounds) + } + override func mouseUp(with event: NSEvent) { selectedPointIndex = nil setNeedsDisplay(bounds) diff --git a/Sources/FanCurveEditor/FanCurveWindow.swift b/Sources/FanCurveEditor/FanCurveWindow.swift index 09d2f62..5741e64 100644 --- a/Sources/FanCurveEditor/FanCurveWindow.swift +++ b/Sources/FanCurveEditor/FanCurveWindow.swift @@ -250,7 +250,7 @@ public class FanCurveWindowController: NSWindowController { // MARK: - 新窗口控制器(混合架构:AppKit 布局 + AppKit 预设按钮) @MainActor -public class FanCurvePresetWindowController: NSWindowController { +public class FanCurvePresetWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate { private let initialFanCurve: [FanPoint] private let maxRPM: Int private let initialPresetNames: [String]? @@ -263,6 +263,15 @@ public class FanCurvePresetWindowController: NSWindowController { private var presetNames: [String] private var presets: [[FanPoint]] private var activePresetIndex: Int + private let editStatus = NSTextField(labelWithString: "") + private let activeLabel = NSTextField(labelWithString: "") + private var originalFieldText: [ObjectIdentifier: String] = [:] + private var hasInvalidPointInput = false + private var pointFields: [(temperature: NSTextField, rpm: NSTextField)] = [] + private var presetButtons: [NSButton] = [] + private let pointStack = NSStackView() + private var numericToggle: NSButton? + private var showNumericEditor = UserDefaults.standard.bool(forKey: "ifancontrol.curve.numeric_editor") private var isYAxisReversed = UserDefaults.standard.bool(forKey: fanCurveYAxisReversedKey) public init(fanCurve: [FanPoint], maxRPM: Int, @@ -292,25 +301,21 @@ public class FanCurvePresetWindowController: NSWindowController { self.currentFanCurve = self.presets[self.activePresetIndex] let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 800, height: 530), + contentRect: NSRect(x: 0, y: 0, width: showNumericEditor ? 900 : 720, height: 450), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false ) - window.title = fanCurveL10n("风扇曲线编辑器 v33", "Fan Curve Editor v33") + window.title = fanCurveL10n("风扇曲线", "Fan Curve") window.center() super.init(window: window) setupUI() - // 窗口关闭时自动保存 - NotificationCenter.default.addObserver( - self, - selector: #selector(windowWillClose), - name: NSWindow.willCloseNotification, - object: window - ) + window.delegate = self + window.minSize = NSSize(width: 720, height: 470) + } required init?(coder: NSCoder) { @@ -319,89 +324,143 @@ public class FanCurvePresetWindowController: NSWindowController { private func setupUI() { guard let contentView = window?.contentView else { return } - - // 1. 曲线视图 - fanCurveView.fanCurve = currentFanCurve - fanCurveView.maxRPM = maxRPM - fanCurveView.isYAxisReversed = isYAxisReversed - fanCurveView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(fanCurveView) - - // 2. 底部:预设按钮 + 分隔符 + 操作按钮(一行) - // 左侧:预设按钮 - let presetStack = NSStackView() - presetStack.spacing = 6 + let root = NSStackView(); root.orientation = .vertical; root.alignment = .leading; root.spacing = 12 + root.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(root) + activeLabel.font = .systemFont(ofSize: 12, weight: .medium) + activeLabel.textColor = .secondaryLabelColor + activeLabel.lineBreakMode = .byTruncatingTail + root.addArrangedSubview(activeLabel) + activeLabel.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + let presetRow = NSStackView(); presetRow.spacing = 4 for i in 0..<3 { - let btn = NSButton(title: presetNames[i], target: self, action: #selector(presetButtonClicked(_:))) - btn.bezelStyle = .recessed - btn.setButtonType(.pushOnPushOff) - btn.tag = i - btn.state = (i == activePresetIndex) ? .on : .off - // 双击重命名 + let button = NSButton(title: presetNames[i], target: self, action: #selector(presetButtonClicked(_:))) + button.controlSize = .small; button.font = .systemFont(ofSize: 12) + button.bezelStyle = .recessed; button.setButtonType(.pushOnPushOff); button.tag = i + button.state = i == activePresetIndex ? .on : .off + button.widthAnchor.constraint(equalToConstant: 106).isActive = true + button.cell?.lineBreakMode = .byTruncatingTail + button.toolTip = presetNames[i] let doubleClick = NSClickGestureRecognizer(target: self, action: #selector(presetDoubleClicked(_:))) - doubleClick.numberOfClicksRequired = 2 - btn.addGestureRecognizer(doubleClick) - presetStack.addArrangedSubview(btn) + doubleClick.numberOfClicksRequired = 2; button.addGestureRecognizer(doubleClick) + presetButtons.append(button); presetRow.addArrangedSubview(button) } - - // 分隔符 - let separator = NSTextField(labelWithString: "│") - separator.textColor = .separatorColor - separator.font = NSFont.systemFont(ofSize: 16) - - // Y 轴方向:只影响曲线编辑器的显示和拖拽坐标换算,不修改曲线数据。 - let yAxisLabel = NSTextField(labelWithString: fanCurveL10n("Y 轴", "Y Axis")) - let yAxisSeparator = NSTextField(labelWithString: "│") - yAxisSeparator.textColor = .separatorColor - yAxisSeparator.font = NSFont.systemFont(ofSize: 16) - let yAxisDirectionControl = NSSegmentedControl( - labels: [ - fanCurveL10n("正向", "Normal"), - fanCurveL10n("反向", "Reversed") - ], - trackingMode: .selectOne, - target: self, - action: #selector(yAxisDirectionChanged(_:)) - ) - yAxisDirectionControl.selectedSegment = isYAxisReversed ? 1 : 0 - let yAxisStack = NSStackView(views: [yAxisLabel, yAxisSeparator, yAxisDirectionControl]) - yAxisStack.spacing = 4 - yAxisStack.alignment = .centerY - - // 右侧:操作按钮 - let resetButton = NSButton(title: fanCurveL10n("重置", "Reset"), target: self, action: #selector(resetCurve)) - let closeButton = NSButton(title: fanCurveL10n("关闭", "Close"), target: self, action: #selector(closeWindow)) - - let actionStack = NSStackView(views: [resetButton, closeButton]) - actionStack.spacing = 12 - - // 整行 - let bottomStack = NSStackView(views: [presetStack, separator, yAxisStack, actionStack]) - bottomStack.spacing = 12 - bottomStack.alignment = .centerY - bottomStack.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(bottomStack) - - // 提示标签 - let hintLabel = NSTextField(labelWithString: fanCurveL10n("双击预设名称可重命名 · 关闭窗口自动保存", "Double-click preset name to rename · Saved on close")) - hintLabel.font = NSFont.systemFont(ofSize: 11) - hintLabel.textColor = .secondaryLabelColor - hintLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(hintLabel) - + let rename = NSButton(title: fanCurveL10n("重命名…", "Rename…"), target: self, action: #selector(renameActivePreset)) + rename.controlSize = .small; rename.font = .systemFont(ofSize: 12) + presetRow.addArrangedSubview(rename); presetRow.addArrangedSubview(NSView()) + let numeric = NSButton(checkboxWithTitle: fanCurveL10n("数值编辑", "Edit values"), target: self, action: #selector(toggleNumericEditor)) + numeric.state = showNumericEditor ? .on : .off; numeric.controlSize = .small; numeric.font = .systemFont(ofSize: 12) + numeric.keyEquivalent = "i"; numeric.keyEquivalentModifierMask = [.command, .option] + numeric.toolTip = fanCurveL10n("显示精确数值输入 · ⌘⌥I", "Show precise value fields · ⌘⌥I") + numericToggle = numeric; presetRow.addArrangedSubview(numeric); root.addArrangedSubview(presetRow) + presetRow.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + fanCurveView.fanCurve = currentFanCurve; fanCurveView.maxRPM = maxRPM; fanCurveView.isYAxisReversed = isYAxisReversed + fanCurveView.setAccessibilityElement(true); fanCurveView.setAccessibilityRole(.image) + fanCurveView.setAccessibilityLabel(fanCurveL10n("温度与目标转速曲线,可在控制点输入框编辑数值", "Temperature and target RPM curve. Edit values in the control point fields.")) + fanCurveView.onCurveChanged = { [weak self] curve in + guard let self else { return } + self.currentFanCurve = curve; self.syncPointFields(); self.markEdited() + } + pointStack.orientation = .vertical; pointStack.alignment = .leading; pointStack.spacing = 6 + pointStack.widthAnchor.constraint(equalToConstant: 162).isActive = true + let graphRow = NSStackView(views: [fanCurveView, pointStack]); graphRow.spacing = 20; graphRow.alignment = .top + root.addArrangedSubview(graphRow) + graphRow.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + fanCurveView.heightAnchor.constraint(equalToConstant: 280).isActive = true + graphRow.heightAnchor.constraint(equalToConstant: 280).isActive = true + rebuildPointFields() + pointStack.isHidden = !showNumericEditor + let direction = NSSegmentedControl(labels: [fanCurveL10n("正向", "Normal"), fanCurveL10n("反向", "Reversed")], trackingMode: .selectOne, target: self, action: #selector(yAxisDirectionChanged(_:))) + direction.controlSize = .small; direction.font = .systemFont(ofSize: 11) + direction.selectedSegment = isYAxisReversed ? 1 : 0 + let directionLabel = NSTextField(labelWithString: fanCurveL10n("坐标显示", "Axis Direction")) + let reset = NSButton(title: fanCurveL10n("重置", "Reset"), target: self, action: #selector(resetCurve)) + let close = NSButton(title: fanCurveL10n("保存并关闭", "Save and Close"), target: self, action: #selector(closeWindow)) + reset.toolTip = fanCurveL10n("重置当前预设", "Reset current preset") + reset.bezelStyle = .rounded; reset.controlSize = .small + directionLabel.font = .systemFont(ofSize: 11); directionLabel.textColor = .secondaryLabelColor + close.bezelStyle = .rounded; close.keyEquivalent = "\r" + let actions = NSStackView(views: [directionLabel, direction, NSView(), reset, close]); actions.spacing = 10 + root.addArrangedSubview(actions); actions.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + editStatus.font = .systemFont(ofSize: 11); editStatus.textColor = .secondaryLabelColor + editStatus.stringValue = fanCurveL10n("拖动控制点或输入数值 · 关闭窗口自动保存", "Drag a point or enter values · Saved when you close this window") + root.addArrangedSubview(editStatus) NSLayoutConstraint.activate([ - fanCurveView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - fanCurveView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - fanCurveView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - fanCurveView.bottomAnchor.constraint(equalTo: bottomStack.topAnchor, constant: -16), + root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), root.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16) + ]) + refreshPresetLabels() + } - bottomStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - bottomStack.bottomAnchor.constraint(equalTo: hintLabel.topAnchor, constant: -4), + @objc private func toggleNumericEditor(_ sender: NSButton) { + showNumericEditor = sender.state == .on + UserDefaults.standard.set(showNumericEditor, forKey: "ifancontrol.curve.numeric_editor") + pointStack.isHidden = !showNumericEditor + guard let window else { return } + var frame = window.frame; frame.size.width = showNumericEditor ? 900 : 720 + window.setFrame(frame, display: true, animate: !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion) + } - hintLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - hintLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8) - ]) + private func rebuildPointFields() { + for view in pointStack.arrangedSubviews { pointStack.removeArrangedSubview(view); view.removeFromSuperview() } + pointFields.removeAll(); originalFieldText.removeAll() + let title = NSTextField(labelWithString: fanCurveL10n("控制点 · ℃ / RPM", "Points · °C / RPM")) + title.font = .systemFont(ofSize: 11, weight: .medium) + title.textColor = .secondaryLabelColor; pointStack.addArrangedSubview(title) + for (index, point) in fanCurveView.fanCurve.enumerated() { + let temperature = NSTextField(string: String(format: "%.1f", point.temperature)) + let rpm = NSTextField(string: "\(point.rpm)") + temperature.tag = index; rpm.tag = index + temperature.isEditable = index > 0 && index < fanCurveView.fanCurve.count - 1 + for field in [temperature, rpm] { field.controlSize = .small; field.target = self; field.action = #selector(pointEdited(_:)); field.delegate = self; field.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular); field.widthAnchor.constraint(equalToConstant: 62).isActive = true } + temperature.setAccessibilityLabel(fanCurveL10n("控制点\(index+1)温度,摄氏度", "Point \(index+1) temperature, Celsius")) + rpm.setAccessibilityLabel(fanCurveL10n("控制点\(index+1)目标转速,RPM", "Point \(index+1) target speed, RPM")) + pointStack.addArrangedSubview(NSStackView(views: [NSTextField(labelWithString: "\(index+1)"), temperature, rpm])) + pointFields.append((temperature, rpm)) + originalFieldText[ObjectIdentifier(temperature)] = temperature.stringValue + originalFieldText[ObjectIdentifier(rpm)] = rpm.stringValue + } + } + private func syncPointFields() { + if pointFields.count != fanCurveView.fanCurve.count { rebuildPointFields(); return } + for (index, point) in fanCurveView.fanCurve.enumerated() { + pointFields[index].temperature.stringValue = String(format: "%.1f", point.temperature) + pointFields[index].rpm.stringValue = "\(point.rpm)" + originalFieldText[ObjectIdentifier(pointFields[index].temperature)] = pointFields[index].temperature.stringValue + originalFieldText[ObjectIdentifier(pointFields[index].rpm)] = pointFields[index].rpm.stringValue + } + } + public func controlTextDidEndEditing(_ notification: Notification) { + if let field = notification.object as? NSTextField { pointEdited(field) } + } + @objc private func pointEdited(_ sender: NSTextField) { + guard pointFields.indices.contains(sender.tag) else { return } + let valid = pointFields.allSatisfy { fields in + guard let temperature = Double(fields.temperature.stringValue), temperature.isFinite, Int(fields.rpm.stringValue) != nil else { return false } + return true + } + guard valid else { + hasInvalidPointInput = true + editStatus.stringValue = fanCurveL10n("请输入有效的温度和整数转速,原曲线仍保留。", "Enter a valid temperature and whole-number RPM. Your curve is retained.") + editStatus.textColor = .systemRed; return + } + hasInvalidPointInput = false + guard originalFieldText[ObjectIdentifier(sender)] != sender.stringValue else { return } + let point = fanCurveView.fanCurve[sender.tag] + let temperature = sender === pointFields[sender.tag].temperature ? Double(sender.stringValue)! : point.temperature + let rpm = sender === pointFields[sender.tag].rpm ? Int(sender.stringValue)! : point.rpm + fanCurveView.updatePoint(sender.tag, temperature: temperature, rpm: rpm) + } + private func markEdited() { + hasInvalidPointInput = false + editStatus.stringValue = fanCurveL10n("有修改 · 关闭窗口时保存", "Changes pending · Saved when you close") + editStatus.textColor = .secondaryLabelColor + } + private func refreshPresetLabels() { + activeLabel.toolTip = presetNames[activePresetIndex] + activeLabel.stringValue = fanCurveL10n("当前预设:\(presetNames[activePresetIndex])", "Current preset: \(presetNames[activePresetIndex])") + for (index, button) in presetButtons.enumerated() { button.title = presetNames[index]; button.toolTip = presetNames[index]; button.state = index == activePresetIndex ? .on : .off } } + @objc private func renameActivePreset() { renamePreset(index: activePresetIndex) } @objc private func yAxisDirectionChanged(_ sender: NSSegmentedControl) { let reversed = sender.selectedSegment == 1 @@ -424,8 +483,11 @@ public class FanCurvePresetWindowController: NSWindowController { } @objc private func presetDoubleClicked(_ sender: NSClickGestureRecognizer) { - guard let btn = sender.view as? NSButton else { return } - let index = btn.tag + guard let button = sender.view as? NSButton else { return } + renamePreset(index: button.tag) + } + + private func renamePreset(index: Int) { let alert = NSAlert() alert.messageText = fanCurveL10n("重命名预设", "Rename Preset") alert.informativeText = fanCurveL10n("输入新名称:", "Enter new name:") @@ -440,7 +502,8 @@ public class FanCurvePresetWindowController: NSWindowController { let newName = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) if !newName.isEmpty { presetNames[index] = newName - btn.title = newName + refreshPresetLabels() + markEdited() } } } @@ -453,6 +516,7 @@ public class FanCurvePresetWindowController: NSWindowController { activePresetIndex = newIndex currentFanCurve = presets[newIndex] fanCurveView.fanCurve = currentFanCurve + syncPointFields(); refreshPresetLabels(); markEdited() // 立即应用新曲线 NotificationCenter.default.post(name: NSNotification.Name("FanCurveDidSwitch"), object: nil, userInfo: ["curve": currentFanCurve]) } @@ -462,22 +526,28 @@ public class FanCurvePresetWindowController: NSWindowController { presets[activePresetIndex] = defaultCurve currentFanCurve = defaultCurve fanCurveView.fanCurve = defaultCurve + syncPointFields(); markEdited() // 立即应用 NotificationCenter.default.post(name: NSNotification.Name("FanCurveDidSwitch"), object: nil, userInfo: ["curve": defaultCurve]) } @objc private func closeWindow() { - window?.close() + window?.performClose(nil) } - @objc private func windowWillClose(_ notification: Notification) { - // 关闭时自动保存当前编辑状态 + public func windowShouldClose(_ sender: NSWindow) -> Bool { + sender.makeFirstResponder(nil) + guard !hasInvalidPointInput else { + numericToggle?.state = .on + if let numericToggle { toggleNumericEditor(numericToggle) } + return false + } currentFanCurve = fanCurveView.fanCurve presets[activePresetIndex] = currentFanCurve - handleSave(curve: currentFanCurve, names: presetNames, presets: presets, activeIndex: activePresetIndex) + return handleSave(curve: currentFanCurve, names: presetNames, presets: presets, activeIndex: activePresetIndex) } - private func handleSave(curve: [FanPoint], names: [String], presets: [[FanPoint]], activeIndex: Int) { + private func handleSave(curve: [FanPoint], names: [String], presets: [[FanPoint]], activeIndex: Int) -> Bool { let configDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("MacFanControl") try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) let configFile = configDir.appendingPathComponent("config.json") @@ -522,7 +592,7 @@ public class FanCurvePresetWindowController: NSWindowController { activeCurvePreset: activeIndex ) let data = try JSONEncoder().encode(config) - try data.write(to: configFile) + try data.write(to: configFile, options: .atomic) NotificationCenter.default.post(name: NSNotification.Name("FanCurveDidSave"), object: nil, userInfo: [ "curve": curve, @@ -530,8 +600,15 @@ public class FanCurvePresetWindowController: NSWindowController { "curvePresets": presets, "activeCurvePreset": activeIndex ]) + return true } catch { + let alert = NSAlert() + alert.messageText = fanCurveL10n("保存未完成", "Save Not Completed") + alert.informativeText = fanCurveL10n("无法写入配置,编辑内容仍保留在窗口中。\n", "Could not write settings. Your edits are retained in this window.\n") + error.localizedDescription + alert.addButton(withTitle: fanCurveL10n("返回编辑", "Back to Editing")) + alert.runModal() NSLog("iFanControl: Failed to save fan curve: \(error.localizedDescription)") + return false } } } diff --git a/Sources/MacFanControl/InteractionNotice.swift b/Sources/MacFanControl/InteractionNotice.swift new file mode 100644 index 0000000..48e3a0e --- /dev/null +++ b/Sources/MacFanControl/InteractionNotice.swift @@ -0,0 +1,92 @@ +import AppKit + +@MainActor private final class CompletionMarkView: NSView { + static let size: CGFloat = 36 + private let check = CAShapeLayer() + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + let ring = CAShapeLayer() + ring.frame = CGRect(x: 0, y: 0, width: Self.size, height: Self.size) + ring.path = CGPath(ellipseIn: CGRect(x: 1.5, y: 1.5, width: 33, height: 33), transform: nil) + ring.fillColor = NSColor.systemGreen.withAlphaComponent(0.1).cgColor + ring.strokeColor = NSColor.systemGreen.cgColor; ring.lineWidth = 2 + layer?.addSublayer(ring) + let path = CGMutablePath() + path.move(to: CGPoint(x: 9.75, y: 18)); path.addLine(to: CGPoint(x: 15.3, y: 12.75)); path.addLine(to: CGPoint(x: 26.25, y: 23.55)) + check.path = path; check.fillColor = nil; check.strokeColor = NSColor.systemGreen.cgColor + check.frame = CGRect(x: 0, y: 0, width: Self.size, height: Self.size) + check.lineWidth = 3; check.lineCap = .round; check.lineJoin = .round + layer?.addSublayer(check) + setAccessibilityElement(true); setAccessibilityLabel("✓") + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func animate() { + guard !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion else { return } + let reveal = CABasicAnimation(keyPath: "strokeEnd") + reveal.fromValue = 0; reveal.toValue = 1; reveal.duration = 0.24 + reveal.timingFunction = CAMediaTimingFunction(name: .easeOut) + check.add(reveal, forKey: "completion") + } +} + +/// One rounded fill owns the entire surface. Popover material adds its own bright +/// edge on some appearances, so this brief acknowledgement uses a system-color +/// surface with transparent corners and the native window shadow instead. +@MainActor private final class NoticeBackgroundView: NSView { + override var isOpaque: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + NSBezierPath(roundedRect: bounds, xRadius: 16, yRadius: 16).fill() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } +} + +/// A short, non-modal acknowledgement, emitted only after the corresponding operation succeeds. +@MainActor final class InteractionNotice { + static let shared = InteractionNotice() + private var panel: NSPanel? + private var hideWork: DispatchWorkItem? + + func show(_ message: String, symbol: String = "checkmark.circle.fill") { + hideWork?.cancel(); panel?.orderOut(nil) + let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 360, height: 80), + styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) + panel.level = .floating; panel.isOpaque = false; panel.backgroundColor = .clear + panel.hasShadow = true + let surface = NoticeBackgroundView(frame: panel.contentView!.bounds) + let icon: NSView + if symbol.hasPrefix("checkmark") { icon = CompletionMarkView(frame: .zero) } + else { + let image = NSImageView(image: NSImage(systemSymbolName: symbol, accessibilityDescription: nil)!) + image.contentTintColor = .controlAccentColor; icon = image + } + let label = NSTextField(wrappingLabelWithString: message) + label.font = .systemFont(ofSize: 14, weight: .medium) + let row = NSStackView(views: [icon, label]); row.spacing = 12; row.translatesAutoresizingMaskIntoConstraints = false + surface.addSubview(row); panel.contentView = surface + let iconSize: CGFloat = icon is CompletionMarkView ? CompletionMarkView.size : 24 + NSLayoutConstraint.activate([ + icon.widthAnchor.constraint(equalToConstant: iconSize), icon.heightAnchor.constraint(equalToConstant: iconSize), + row.leadingAnchor.constraint(equalTo: surface.leadingAnchor, constant: 20), + row.trailingAnchor.constraint(equalTo: surface.trailingAnchor, constant: -20), + row.centerYAnchor.constraint(equalTo: surface.centerYAnchor) + ]) + panel.center(); panel.orderFrontRegardless(); self.panel = panel + (icon as? CompletionMarkView)?.animate() + NSAccessibility.post(element: label, notification: .announcementRequested, userInfo: [.announcement: message, .priority: NSAccessibilityPriorityLevel.medium.rawValue]) + let work = DispatchWorkItem { [weak panel] in + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { panel?.orderOut(nil) } + else { NSAnimationContext.runAnimationGroup({ $0.duration = 0.2; panel?.animator().alphaValue = 0 }, completionHandler: { [weak panel] in Task { @MainActor in panel?.orderOut(nil) } }) } + } + hideWork = work + DispatchQueue.main.asyncAfter(deadline: .now() + 1.8, execute: work) + } +} diff --git a/Sources/MacFanControl/MiniDisplayFormatter.swift b/Sources/MacFanControl/MiniDisplayFormatter.swift new file mode 100644 index 0000000..ca078c7 --- /dev/null +++ b/Sources/MacFanControl/MiniDisplayFormatter.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Shared by the menu-bar renderer and its settings preview, including selected source order. +enum MiniDisplayFormatter { + static func line(values: [String], units: [String], style: String, mode: String) -> (primary: String, accent: String) { + let numbers = values.joined(separator: "|") + let withUnits = units.joined(separator: "|") + switch style { + case "value": return (numbers, "") + case "unit": return (withUnits, "") + case "mode": return (numbers + "|", mode) + default: return (withUnits + "|", mode) + } + } +} diff --git a/Sources/MacFanControl/NativeInterface.swift b/Sources/MacFanControl/NativeInterface.swift new file mode 100644 index 0000000..18d27d6 --- /dev/null +++ b/Sources/MacFanControl/NativeInterface.swift @@ -0,0 +1,35 @@ +import AppKit + +/// A shared scale for this menu-bar utility. Navigation inherits native materials; +/// content uses quiet system colors and compact, readable controls. +@MainActor enum NativeInterface { + static func label(_ text: String, size: CGFloat = 13, weight: NSFont.Weight = .regular, + color: NSColor = .labelColor, wrapping: Bool = false) -> NSTextField { + let field = wrapping ? NSTextField(wrappingLabelWithString: text) : NSTextField(labelWithString: text) + field.font = .systemFont(ofSize: size, weight: weight) + field.textColor = color + return field + } + + static func button(_ text: String, target: AnyObject?, action: Selector?, small: Bool = false) -> NSButton { + let button = NSButton(title: text, target: target, action: action) + button.bezelStyle = .rounded + button.controlSize = small ? .small : .regular + button.font = .systemFont(ofSize: small ? 12 : 13) + return button + } + + static func rpm(_ value: Int) -> NSAttributedString { + measurement("\(value)", unit: "RPM") + } + + static func measurement(_ value: String, unit: String) -> NSAttributedString { + let text = NSMutableAttributedString(string: value, attributes: [ + .font: NSFont.monospacedDigitSystemFont(ofSize: 34, weight: .medium), .foregroundColor: NSColor.labelColor + ]) + text.append(NSAttributedString(string: " " + unit, attributes: [ + .font: NSFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: NSColor.secondaryLabelColor + ])) + return text + } +} diff --git a/Sources/MacFanControl/StartupProgressWindow.swift b/Sources/MacFanControl/StartupProgressWindow.swift new file mode 100644 index 0000000..d51d7ea --- /dev/null +++ b/Sources/MacFanControl/StartupProgressWindow.swift @@ -0,0 +1,121 @@ +import AppKit + +@MainActor private final class StartupSurfaceView: NSView { + override var isOpaque: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + NSColor.windowBackgroundColor.setFill() + NSBezierPath(roundedRect: bounds, xRadius: 18, yRadius: 18).fill() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } +} + +/// Launch feedback stays visible until the existing initialization returns. +/// Native threaded animation keeps the indicator moving during synchronous probes. +@MainActor final class StartupProgressWindowController: NSWindowController { + enum Stage { + case hardware, preferences, controls + + @MainActor var message: String { + switch self { + case .hardware: return StartupProgressWindowController.text("正在识别温度与风扇…", "Detecting temperatures and fans…") + case .preferences: return StartupProgressWindowController.text("正在恢复你的设置…", "Restoring your settings…") + case .controls: return StartupProgressWindowController.text("正在准备菜单栏与风扇控制…", "Preparing menu bar and fan control…") + } + } + } + + private let stageField = NSTextField(wrappingLabelWithString: "") + private let progress = NSProgressIndicator() + + init() { + let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 380, height: 156), + styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) + panel.level = .floating + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.setAccessibilityLabel(Self.text("iFanControl 正在启动", "Starting iFanControl")) + super.init(window: panel) + + let surface = StartupSurfaceView(frame: panel.contentView!.bounds) + panel.contentView = surface + let icon = NSImageView(image: NSApp.applicationIconImage) + icon.imageScaling = .scaleProportionallyUpOrDown + icon.widthAnchor.constraint(equalToConstant: 48).isActive = true + icon.heightAnchor.constraint(equalToConstant: 48).isActive = true + let title = NativeInterface.label(Self.text("正在启动 iFanControl", "Starting iFanControl"), size: 17, weight: .semibold) + stageField.font = .systemFont(ofSize: 12) + stageField.textColor = .secondaryLabelColor + stageField.identifier = NSUserInterfaceItemIdentifier("startup-stage") + let text = NSStackView(views: [title, stageField]) + text.orientation = .vertical + text.alignment = .leading + text.spacing = 6 + let header = NSStackView(views: [icon, text]) + header.alignment = .centerY + header.spacing = 16 + + progress.style = .spinning + progress.controlSize = .small + progress.isIndeterminate = true + progress.usesThreadedAnimation = true + progress.setAccessibilityLabel(Self.text("正在启动", "Starting")) + progress.widthAnchor.constraint(equalToConstant: 16).isActive = true + progress.heightAnchor.constraint(equalToConstant: 16).isActive = true + let hint = NativeInterface.label(Self.text("准备完成后,可在菜单栏使用。", "Available in your menu bar when ready."), size: 12, color: .secondaryLabelColor) + let footer = NSStackView(views: [progress, hint]) + footer.alignment = .centerY + footer.spacing = 8 + let stack = NSStackView(views: [header, footer]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 20 + stack.translatesAutoresizingMaskIntoConstraints = false + surface.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: surface.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: surface.trailingAnchor, constant: -24), + stack.centerYAnchor.constraint(equalTo: surface.centerYAnchor), + header.widthAnchor.constraint(equalTo: stack.widthAnchor), + stageField.widthAnchor.constraint(equalTo: text.widthAnchor) + ]) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private static func text(_ zh: String, _ en: String) -> String { + let language = UserDefaults.standard.string(forKey: "ifancontrol.ui.language") + ?? (Locale.preferredLanguages.first?.hasPrefix("en") == true ? "en" : "zh") + return language == "en" ? en : zh + } + + func present() { + setStage(.hardware) + window?.center() + window?.orderFrontRegardless() + progress.startAnimation(nil) + window?.displayIfNeeded() + NSAccessibility.post(element: stageField, notification: .announcementRequested, + userInfo: [.announcement: Self.text("iFanControl 正在启动", "Starting iFanControl"), + .priority: NSAccessibilityPriorityLevel.medium.rawValue]) + } + + func setStage(_ stage: Stage) { + stageField.stringValue = stage.message + // Flush the label before entering the existing synchronous startup step. + window?.contentView?.layoutSubtreeIfNeeded() + window?.contentView?.needsDisplay = true + window?.displayIfNeeded() + } + + func dismiss() { + progress.stopAnimation(nil) + window?.orderOut(nil) + } +} diff --git a/Sources/MacFanControl/UpdateManifest.swift b/Sources/MacFanControl/UpdateManifest.swift index fff7178..161c6ce 100644 --- a/Sources/MacFanControl/UpdateManifest.swift +++ b/Sources/MacFanControl/UpdateManifest.swift @@ -2,7 +2,7 @@ import Foundation import CryptoKit // This endpoint is separate from the frozen pre-2.9.9 ZIP update channel. -struct UpdateManifest: Decodable { +struct UpdateManifest: Decodable, Sendable { static let channelURL = URL(string: "https://ifan-59w.pages.dev/update-manifest-pkg.json")! let latestVersion: String @@ -11,15 +11,35 @@ struct UpdateManifest: Decodable { let notes: String? let mandatory: Bool? let assets: Assets? + let releaseChannel: String? + let releaseURL: String? + + var versionDisplay: String { + releaseChannel == "beta" ? "\(latestVersion) Beta" : latestVersion + } + + // A Beta feed may point to a promoted stable package. Stable never accepts Beta metadata. + func isCompatible(with channel: UpdateChannel) -> Bool { + releaseChannel == nil || releaseChannel == "stable" || (channel == .beta && releaseChannel == "beta") + } + + var betaReleaseURL: URL? { + guard let releaseURL, let url = URL(string: releaseURL), + url.scheme == "https", url.host == "github.com", + url.path.hasPrefix("/PureMilkchun/iFanControl/releases/") else { return nil } + return url + } enum CodingKeys: String, CodingKey { case latestVersion = "latest_version" case latestBuild = "latest_build" case publishedAt = "published_at" case notes, mandatory, assets + case releaseChannel = "release_channel" + case releaseURL = "release_url" } - struct Assets: Decodable { + struct Assets: Decodable, Sendable { let packageURL: String? let packageSHA256: String? diff --git a/Sources/MacFanControl/UpdatePackageDownloader.swift b/Sources/MacFanControl/UpdatePackageDownloader.swift new file mode 100644 index 0000000..25eb5ff --- /dev/null +++ b/Sources/MacFanControl/UpdatePackageDownloader.swift @@ -0,0 +1,80 @@ +import Foundation + +/// One owned transfer. Cancellation closes the connection, rather than merely hiding its UI. +final class UpdatePackageDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendable { + private let lock = NSLock() + private let configuration: URLSessionConfiguration + private var session: URLSession? + private var task: URLSessionDataTask? + private var continuation: CheckedContinuation<(Data, URLResponse), Error>? + private var response: URLResponse? + private var bytes = Data() + private var cancelled = false + private var progress: (@Sendable (Int64, Int64) -> Void)? + + init(configuration: URLSessionConfiguration = .default) { self.configuration = configuration } + + func download(_ url: URL, progress: @escaping @Sendable (Int64, Int64) -> Void) async throws -> (Data, URLResponse) { + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + lock.lock() + if cancelled { + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + self.continuation = continuation + self.progress = progress + let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) + self.session = session + let task = session.dataTask(with: url) + self.task = task + lock.unlock() + task.resume() + } + } onCancel: { self.cancel() } + } + + func cancel() { + lock.lock() + cancelled = true + let task = task + lock.unlock() + task?.cancel() + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) -> Void) { + lock.lock(); self.response = response; lock.unlock() + completionHandler(.allow) + } + + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + lock.lock() + bytes.append(data) + let received = Int64(bytes.count) + let encoding = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Encoding") + let total = (encoding == nil || encoding == "identity") ? (response?.expectedContentLength ?? -1) : -1 + let progress = progress + lock.unlock() + progress?(received, total) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + lock.lock() + let continuation = continuation + self.continuation = nil + let response = response + let data = bytes + let cancelled = cancelled + self.task = nil + self.session = nil + self.progress = nil + lock.unlock() + session.finishTasksAndInvalidate() + if cancelled { continuation?.resume(throwing: CancellationError()) } + else if let error { continuation?.resume(throwing: error) } + else if let response { continuation?.resume(returning: (data, response)) } + else { continuation?.resume(throwing: URLError(.badServerResponse)) } + } +} diff --git a/Sources/MacFanControl/UpdatePreferences.swift b/Sources/MacFanControl/UpdatePreferences.swift new file mode 100644 index 0000000..2f1bffa --- /dev/null +++ b/Sources/MacFanControl/UpdatePreferences.swift @@ -0,0 +1,73 @@ +import Foundation + +enum UpdateChannel: String { + case stable + case beta + + var manifestURL: URL { + switch self { + case .stable: return UpdateManifest.channelURL + case .beta: return URL(string: "https://ifan-59w.pages.dev/update-manifest-beta.json")! + } + } +} + +struct UpdateSelection: Equatable { + let channel: UpdateChannel + let revision: UInt +} + +/// Preferences are separate from fan configuration and default to the existing stable route. +@MainActor +final class UpdatePreferences { + static let channelDidChange = Notification.Name("iFanControlUpdateChannelDidChange") + static let shared = UpdatePreferences(defaults: .standard) + private let defaults: UserDefaults + private let developerModeKey = "ifancontrol.update.developer_mode_enabled" + private let betaEnabledKey = "ifancontrol.update.beta_enabled" + private var revision: UInt = 0 + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + var developerModeEnabled: Bool { defaults.bool(forKey: developerModeKey) } + var betaEnabled: Bool { developerModeEnabled && defaults.bool(forKey: betaEnabledKey) } + var channel: UpdateChannel { betaEnabled ? .beta : .stable } + var selection: UpdateSelection { UpdateSelection(channel: channel, revision: revision) } + + func unlockDeveloperMode() { + guard !developerModeEnabled else { return } + // Unlocking reveals the controls; it never subscribes to Beta by itself. + defaults.set(false, forKey: betaEnabledKey) + defaults.set(true, forKey: developerModeKey) + } + + func setBetaEnabled(_ enabled: Bool) { + let oldChannel = channel + defaults.set(enabled && developerModeEnabled, forKey: betaEnabledKey) + if channel != oldChannel { + revision &+= 1 + NotificationCenter.default.post(name: Self.channelDidChange, object: nil) + } + } + + func exitDeveloperMode() { + setBetaEnabled(false) + defaults.set(false, forKey: developerModeKey) + } +} + +/// One counter per help window; ordinary individual clicks count, with no timing requirement. +struct DeveloperModeUnlockCounter { + private(set) var clickCount = 0 + + mutating func registerClick() -> Bool { + clickCount += 1 + guard clickCount == 10 else { return false } + reset() + return true + } + + mutating func reset() { clickCount = 0 } +} diff --git a/Sources/MacFanControl/UpdateProgressWindow.swift b/Sources/MacFanControl/UpdateProgressWindow.swift new file mode 100644 index 0000000..7e509f6 --- /dev/null +++ b/Sources/MacFanControl/UpdateProgressWindow.swift @@ -0,0 +1,99 @@ +import AppKit + +@MainActor +final class UpdateProgressWindowController: NSWindowController, NSWindowDelegate { + private let titleField = NSTextField(labelWithString: "") + private let symbolView = NSImageView() + private let detailField = NSTextField(wrappingLabelWithString: "") + private let progress = NSProgressIndicator() + private let amountField = NSTextField(labelWithString: "") + private let primaryButton = NSButton() + private let secondaryButton = NSButton() + private var primaryAction: (() -> Void)? + private var secondaryAction: (() -> Void)? + private var cancelAction: (() -> Void)? + + init() { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 420, height: 220), + styleMask: [.titled, .closable], backing: .buffered, defer: false) + window.title = Self.text("软件更新", "Software Update") + window.center() + super.init(window: window) + window.delegate = self + titleField.font = .systemFont(ofSize: 16, weight: .semibold) + detailField.font = .systemFont(ofSize: 12) + detailField.textColor = .secondaryLabelColor + amountField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) + amountField.textColor = .secondaryLabelColor + progress.style = .bar + progress.minValue = 0; progress.maxValue = 1 + primaryButton.bezelStyle = .rounded + primaryButton.target = self; primaryButton.action = #selector(primary) + secondaryButton.bezelStyle = .rounded + secondaryButton.target = self; secondaryButton.action = #selector(secondary) + primaryButton.font = .systemFont(ofSize: 13) + secondaryButton.font = .systemFont(ofSize: 12) + let buttons = NSStackView(views: [secondaryButton, NSView(), primaryButton]) + buttons.spacing = 10 + let header = NSStackView(views: [symbolView, titleField]); header.spacing = 10 + symbolView.widthAnchor.constraint(equalToConstant: 22).isActive = true + symbolView.heightAnchor.constraint(equalToConstant: 22).isActive = true + let stack = NSStackView(views: [header, detailField, progress, amountField, buttons]) + stack.orientation = .vertical; stack.alignment = .leading; stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + window.contentView!.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: window.contentView!.leadingAnchor, constant: 24), + stack.trailingAnchor.constraint(equalTo: window.contentView!.trailingAnchor, constant: -24), + stack.topAnchor.constraint(equalTo: window.contentView!.topAnchor, constant: 24), + stack.bottomAnchor.constraint(lessThanOrEqualTo: window.contentView!.bottomAnchor, constant: -20), + detailField.widthAnchor.constraint(equalTo: stack.widthAnchor), + progress.widthAnchor.constraint(equalTo: stack.widthAnchor), + buttons.widthAnchor.constraint(equalTo: stack.widthAnchor) + ]) + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + private static let language = UserDefaults.standard.string(forKey: "ifancontrol.ui.language") ?? (Locale.preferredLanguages.first?.hasPrefix("en") == true ? "en" : "zh") + private static func text(_ zh: String, _ en: String) -> String { language == "en" ? en : zh } + + func present(title: String, detail: String, cancel: (() -> Void)? = nil, + retry: (() -> Void)? = nil, manual: (() -> Void)? = nil) { + titleField.stringValue = title; detailField.stringValue = detail + symbolView.image = NSImage(systemSymbolName: retry == nil ? "arrow.triangle.2.circlepath" : "exclamationmark.circle", accessibilityDescription: nil) + symbolView.contentTintColor = retry == nil ? .secondaryLabelColor : .systemOrange + cancelAction = cancel + primaryAction = retry ?? cancel ?? { [weak self] in self?.dismiss() } + secondaryAction = manual + primaryButton.title = retry != nil ? Self.text("重试", "Try Again") : + (cancel != nil ? Self.text("取消", "Cancel") : Self.text("完成", "Done")) + primaryButton.keyEquivalent = cancel != nil ? "\u{1b}" : "\r" + secondaryButton.title = Self.text("手动下载", "Download Manually") + secondaryButton.isHidden = manual == nil + progress.isHidden = cancel == nil + amountField.stringValue = "" + amountField.isHidden = true + progress.isIndeterminate = true + if cancel != nil { progress.startAnimation(nil) } else { progress.stopAnimation(nil) } + window?.setContentSize(NSSize(width: 420, height: cancel == nil ? 170 : 190)) + bringForward() + } + + func updateTransfer(received: Int64, total: Int64) { + amountField.isHidden = false + progress.isIndeterminate = total <= 0 || received > total + if total > 0 && received <= total { + progress.stopAnimation(nil) + progress.doubleValue = min(1, Double(received) / Double(total)) + amountField.stringValue = "\(ByteCountFormatter.string(fromByteCount: received, countStyle: .file)) / \(ByteCountFormatter.string(fromByteCount: total, countStyle: .file))" + } else { + progress.startAnimation(nil) + amountField.stringValue = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) + } + } + func bringForward() { showWindow(nil); window?.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true) } + func dismiss() { progress.stopAnimation(nil); cancelAction = nil; window?.orderOut(nil) } + func windowShouldClose(_ sender: NSWindow) -> Bool { cancelAction?(); cancelAction = nil; return true } + @objc private func primary() { primaryAction?() } + @objc private func secondary() { secondaryAction?() } +} diff --git a/Sources/MacFanControl/main.swift b/Sources/MacFanControl/main.swift index 97fbe06..aed31a3 100644 --- a/Sources/MacFanControl/main.swift +++ b/Sources/MacFanControl/main.swift @@ -1735,6 +1735,7 @@ class FanManager { static let shared = FanManager() private let kentsmcPath = "/usr/local/bin/kentsmc" let defaultSafetyRPM = 3500 + var criticalTemperatureForDisplay: Double { criticalTemp } private let fallbackMaxRPM = 4900 private let telemetryRefreshInterval: TimeInterval = 2.0 private let validTemperatureRange = 0.0...120.0 @@ -2320,14 +2321,17 @@ class ConfigManager { ) } - func saveConfig(_ config: Config) { + @discardableResult + func saveConfig(_ config: Config) -> Bool { do { let data = try JSONEncoder().encode(config) try data.write(to: configFile) cachedConfig = config cachedConfigMtime = try? configFile.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + return true } catch { print("Error saving config: \(error)") + return false } } @@ -2353,15 +2357,49 @@ class ConfigManager { // 自动更新服务 @MainActor -class UpdateService { +class UpdateService: NSObject { static let shared = UpdateService() private let logger = Logger(subsystem: appSubsystem, category: "Updater") - private let manifestURL = UpdateManifest.channelURL + private let updatePreferences = UpdatePreferences.shared private let releasesHomeURL = URL(string: "https://github.com/PureMilkchun/iFanControl/releases")! private let lastCheckKey = "ifancontrol.update.last_check" private let automaticCheckEnabledKey = "ifancontrol.update.automatic_check_enabled" private var isChecking = false + private var isDownloading = false + private var checkTask: Task? + private var downloadTask: Task? + private var downloader: UpdatePackageDownloader? + private var activeSelection: UpdateSelection? + private var isTransferring = false + private var transferID = UUID() + private var manualCheckRequested = false + private let progressWindow = UpdateProgressWindowController() + + override init() { + super.init() + NotificationCenter.default.addObserver(self, selector: #selector(routeChanged(_:)), + name: UpdatePreferences.channelDidChange, object: nil) + } + + @objc private func routeChanged(_ notification: Notification) { + if let activeSelection, activeSelection != updatePreferences.selection { cancelCurrentOperation() } + } + + private func cancelCurrentOperation() { + checkTask?.cancel(); downloadTask?.cancel(); downloader?.cancel() + transferID = UUID() + isTransferring = false + progressWindow.dismiss() + } + + func presentInstalledUpdateIfNeeded() { + let defaults = UserDefaults.standard + let expected = defaults.integer(forKey: "ifancontrol.update.pending_build") + guard expected > 0, currentBuild >= expected else { return } + defaults.removeObject(forKey: "ifancontrol.update.pending_build") + InteractionNotice.shared.show(appL10n("已更新至 \(currentVersionDisplay)", "Updated to \(currentVersionDisplay)")) + } private var currentShortVersion: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? appL10n("未知版本", "Unknown") @@ -2372,7 +2410,8 @@ class UpdateService { } var currentVersionDisplay: String { - "\(currentShortVersion) (\(currentBuild))" + let betaLabel = Bundle.main.object(forInfoDictionaryKey: "IFanControlReleaseChannel") as? String == "beta" ? " Beta" : "" + return "\(currentShortVersion)\(betaLabel) (\(currentBuild))" } var releasesURL: URL { @@ -2393,31 +2432,49 @@ class UpdateService { } func checkForUpdates(triggeredByUser: Bool) { - if isChecking { return } + if isChecking || isDownloading { + if triggeredByUser { + manualCheckRequested = true + if isChecking { progressWindow.present(title: appL10n("正在检查更新", "Checking for Updates"), detail: appL10n("请稍候,正在读取可用版本。", "Please wait while we check the available version."), cancel: { [weak self] in self?.cancelCurrentOperation() }) } + else { progressWindow.bringForward() } + } + return + } if !triggeredByUser && !automaticChecksEnabled { logger.info("Skipping automatic update check because automatic checks are disabled.") return } isChecking = true + let selection = updatePreferences.selection + activeSelection = selection + manualCheckRequested = triggeredByUser + if triggeredByUser { + progressWindow.present(title: appL10n("正在检查更新", "Checking for Updates"), detail: appL10n("当前通道:\(selection.channel == .beta ? "Beta" : "正式版")", "Update channel: \(selection.channel == .beta ? "Beta" : "Stable")"), cancel: { [weak self] in self?.cancelCurrentOperation() }) + } logger.info("Starting update check. triggeredByUser=\(triggeredByUser, privacy: .public)") - Task { - defer { isChecking = false } + checkTask = Task { [self] in + defer { isChecking = false; checkTask = nil } do { - let (data, _) = try await URLSession.shared.data(from: manifestURL) + let (data, _) = try await URLSession.shared.data(from: selection.channel.manifestURL) + guard !Task.isCancelled, updatePreferences.selection == selection else { return } let manifest = try JSONDecoder().decode(UpdateManifest.self, from: data) + guard manifest.isCompatible(with: selection.channel) else { + throw NSError(domain: "UpdateService", code: 1005, userInfo: [NSLocalizedDescriptionKey: appL10n("更新通道信息不匹配,请稍后重试。", "Update channel information does not match. Please try again later.")]) + } recordLastCheckNow() if manifest.latestBuild <= currentBuild { self.logger.info("No update available. currentBuild=\(self.currentBuild, privacy: .public) remoteBuild=\(manifest.latestBuild, privacy: .public)") - if triggeredByUser { + if manualCheckRequested { + progressWindow.dismiss() showInfoAlert( title: appL10n("已是最新版本", "Up to Date"), message: appL10n( - "当前版本:\(currentVersionDisplay)\n远端版本:\(manifest.latestVersion) (\(manifest.latestBuild))", - "Current: \(currentVersionDisplay)\nLatest: \(manifest.latestVersion) (\(manifest.latestBuild))" + "当前版本:\(currentVersionDisplay)\n可用版本:\(manifest.versionDisplay) (\(manifest.latestBuild))", + "Current: \(currentVersionDisplay)\nLatest: \(manifest.versionDisplay) (\(manifest.latestBuild))" ) ) } @@ -2425,11 +2482,15 @@ class UpdateService { } self.logger.info("Update available. currentBuild=\(self.currentBuild, privacy: .public) remoteBuild=\(manifest.latestBuild, privacy: .public)") - presentUpdatePrompt(manifest: manifest) + progressWindow.dismiss() + presentUpdatePrompt(manifest: manifest, selection: selection) } catch { + guard !Task.isCancelled, updatePreferences.selection == selection else { return } self.logger.error("Update check failed: \(error.localizedDescription, privacy: .public)") - if triggeredByUser { - showUpdateFailureAlert(title: appL10n("检查更新失败", "Update Check Failed"), message: error.localizedDescription, releaseURL: releasesHomeURL) + if manualCheckRequested { + progressWindow.present(title: appL10n("检查更新失败", "Update Check Failed"), detail: error.localizedDescription, + retry: { [weak self] in self?.checkForUpdates(triggeredByUser: true) }, + manual: { [weak self] in guard let self else { return }; NSWorkspace.shared.open(self.releasesHomeURL) }) } else { print("Update check failed: \(error)") } @@ -2441,25 +2502,34 @@ class UpdateService { UserDefaults.standard.set(Date(), forKey: lastCheckKey) } - private func presentUpdatePrompt(manifest: UpdateManifest) { + private func presentUpdatePrompt(manifest: UpdateManifest, selection: UpdateSelection) { let notes = manifest.notes?.isEmpty == false ? manifest.notes! : appL10n("包含稳定性与功能改进。", "Includes stability and feature improvements.") let alert = NSAlert() - alert.messageText = appL10n("发现新版本 \(manifest.latestVersion)", "New Version Available \(manifest.latestVersion)") + alert.messageText = appL10n("发现新版本 \(manifest.versionDisplay)", "New Version Available \(manifest.versionDisplay)") alert.informativeText = appL10n( - "当前版本:\(currentVersionDisplay)\n远端版本:\(manifest.latestVersion) (\(manifest.latestBuild))\n\n\(notes)", - "Current: \(currentVersionDisplay)\nLatest: \(manifest.latestVersion) (\(manifest.latestBuild))\n\n\(notes)" + "当前版本:\(currentVersionDisplay)\n可用版本:\(manifest.versionDisplay) (\(manifest.latestBuild))\n\n\(notes)\n\n下载安装包并验证后,应用会退出并打开系统安装器。", + "Current: \(currentVersionDisplay)\nLatest: \(manifest.versionDisplay) (\(manifest.latestBuild))\n\n\(notes)\n\nAfter download and verification, the app will quit and open the system installer." ) alert.alertStyle = .informational alert.addButton(withTitle: appL10n("立即更新", "Update Now")) alert.addButton(withTitle: appL10n("稍后", "Later")) if alert.runModal() == .alertFirstButtonReturn { - startDownloadAndInstall(manifest: manifest) + startDownloadAndInstall(manifest: manifest, selection: selection) } } - private func startDownloadAndInstall(manifest: UpdateManifest) { - Task { + private func startDownloadAndInstall(manifest: UpdateManifest, selection: UpdateSelection) { + guard !isDownloading, updatePreferences.selection == selection else { return } + isDownloading = true + let id = UUID() + transferID = id + isTransferring = true + activeSelection = selection + progressWindow.present(title: appL10n("正在下载安装包", "Downloading Installer"), + detail: manifest.versionDisplay, cancel: { [weak self] in self?.cancelCurrentOperation() }) + downloadTask = Task { [self] in + defer { isDownloading = false; isTransferring = false; downloader = nil; downloadTask = nil } do { let packageURL: URL do { @@ -2468,16 +2538,28 @@ class UpdateService { throw NSError(domain: "UpdateService", code: 1001, userInfo: [NSLocalizedDescriptionKey: appL10n("更新信息暂不可用,请稍后重试。", "Update information is unavailable. Please try again later.")]) } logger.info("Downloading update package from \(packageURL.absoluteString, privacy: .public)") - let (data, response) = try await URLSession.shared.data(from: packageURL) + let transfer = UpdatePackageDownloader() + downloader = transfer + let (data, response) = try await transfer.download(packageURL) { [weak self] received, total in + Task { @MainActor in + guard let self, self.isTransferring, self.transferID == id, self.updatePreferences.selection == selection else { return } + self.progressWindow.updateTransfer(received: received, total: total) + } + } + isTransferring = false + guard !Task.isCancelled, updatePreferences.selection == selection else { return } guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode) else { throw NSError(domain: "UpdateService", code: 1002, userInfo: [NSLocalizedDescriptionKey: appL10n("下载安装包失败,请稍后重试。", "Could not download the installer. Please try again later.")]) } + progressWindow.present(title: appL10n("正在验证安装包", "Verifying Installer"), + detail: appL10n("下载已完成,正在确认文件完整性。", "Download complete. Checking file integrity."), cancel: { [weak self] in self?.cancelCurrentOperation() }) do { - try manifest.verifyPackage(data) + try await Task.detached(priority: .userInitiated) { try manifest.verifyPackage(data) }.value } catch { throw NSError(domain: "UpdateService", code: 1003, userInfo: [NSLocalizedDescriptionKey: appL10n("安装包校验失败,请重新下载。", "Installer verification failed. Please download it again.")]) } + try Task.checkCancellation() // Keep each downloaded installer at its own path while Installer uses it. let stagingDir = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("iFanControlUpdate-\(UUID().uuidString)", isDirectory: true) @@ -2485,19 +2567,23 @@ class UpdateService { let packagePath = stagingDir.appendingPathComponent("iFanControl-macOS.pkg") try data.write(to: packagePath, options: .atomic) + try Task.checkCancellation() + progressWindow.present(title: appL10n("即将打开系统安装器", "Opening Installer"), + detail: appL10n("iFanControl 将退出。请按系统向导安装,完成后重新打开应用。", "iFanControl will quit. Follow the system installer, then reopen the app."), cancel: { [weak self] in self?.cancelCurrentOperation() }) guard NSWorkspace.shared.open(packagePath) else { throw NSError(domain: "UpdateService", code: 1004, userInfo: [NSLocalizedDescriptionKey: appL10n("无法打开系统安装器,请从官网下载后打开安装包。", "Could not open Installer. Please download the package from our website and open it.")]) } logger.info("Native PKG installer launched for version \(manifest.latestVersion, privacy: .public)") // Normal termination restores automatic fan control before installation. + UserDefaults.standard.set(manifest.latestBuild, forKey: "ifancontrol.update.pending_build") NSApp.terminate(nil) } catch { + guard !Task.isCancelled, updatePreferences.selection == selection else { return } logger.error("Update download/install failed: \(error.localizedDescription, privacy: .public)") - showUpdateFailureAlert( - title: appL10n("更新失败", "Update Failed"), - message: error.localizedDescription, - releaseURL: githubReleaseURL(for: manifest.latestVersion) - ) + let releaseURL = selection.channel == .beta ? (manifest.betaReleaseURL ?? releasesHomeURL) : githubReleaseURL(for: manifest.latestVersion) + progressWindow.present(title: appL10n("更新未完成", "Update Not Completed"), detail: error.localizedDescription, + retry: { [weak self] in self?.checkForUpdates(triggeredByUser: true) }, + manual: { NSWorkspace.shared.open(releaseURL) }) } } } @@ -2650,14 +2736,8 @@ class MenuBarManager: NSObject { private var currentSensorItem: NSMenuItem? private var controlStatusItem: NSMenuItem? private var controlStatusContainerView: NSView? - private var controlStatusBackgroundView: NSView? - private var controlStatusTopSeparator: NSBox? - private var controlStatusBottomSeparator: NSBox? - private var controlStatusDividerField: NSTextField? - private var controlStatusTopLeftField: NSTextField? - private var controlStatusTopRightField: NSTextField? private var controlStatusBottomLeftField: NSTextField? - private var controlStatusBottomRightField: NSTextField? + private var controlModeField: NSTextField? private var modeItem: NSMenuItem? private var speedItem: NSMenuItem? private var autoStartItem: NSMenuItem? @@ -2728,19 +2808,7 @@ class MenuBarManager: NSObject { let effectiveSources = sources.isEmpty ? [.temperature] : sources let values = effectiveSources.map { sourceTexts(for: $0).value } let units = effectiveSources.map { sourceTexts(for: $0).unit } - let joinedValue = values.joined(separator: "|") - let joinedUnit = units.joined(separator: "|") - - switch style { - case .value: - return (joinedValue, "") - case .unit: - return (joinedUnit, "") - case .mode: - return ("\(joinedValue)|", modeLetter) - case .full: - return ("\(joinedUnit)|", modeLetter) - } + return MiniDisplayFormatter.line(values: values, units: units, style: style.rawValue, mode: modeLetter) } } @@ -2847,7 +2915,6 @@ class MenuBarManager: NSObject { menuTimer.resume() self.menuPollTimer = menuTimer - maybeShowStartupGuidance() } private func createStatusItem() { @@ -2902,6 +2969,7 @@ class MenuBarManager: NSObject { } @objc private func handleCurveSaved(_ notification: Notification) { + InteractionNotice.shared.show(appL10n("曲线已保存", "Curve saved")) if let curve = notification.userInfo?["curve"] as? [FanPoint] { fanCurve = curve } @@ -2940,100 +3008,51 @@ class MenuBarManager: NSObject { action: nil, keyEquivalent: "" ) hwItem.isEnabled = false - menu.addItem(hwItem) self.hardwareItem = hwItem // 温度源信息 let sensorItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") sensorItem.isEnabled = false - menu.addItem(sensorItem) self.currentSensorItem = sensorItem + // A quiet status line replaces the nested card and duplicate headings. let controlStatusItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") - let controlStatusView = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 52)) - let smallMenuFont = NSFont.systemFont(ofSize: 11) - let textColor = NSColor.textColor - let cardFrame = NSRect(x: 10, y: 4, width: 190, height: 44) - let leftX: CGFloat = cardFrame.minX + 14 - let separatorX: CGFloat = cardFrame.midX - let rightX: CGFloat = separatorX + 10 - let leftWidth: CGFloat = separatorX - leftX - 8 - let rightWidth: CGFloat = cardFrame.maxX - rightX - 12 - let topY: CGFloat = cardFrame.minY + 24 - let bottomY: CGFloat = cardFrame.minY + 8 - - let cardBackground = NSView(frame: cardFrame) - cardBackground.wantsLayer = true - cardBackground.layer?.cornerRadius = 8 - cardBackground.layer?.borderWidth = 1 - controlStatusView.addSubview(cardBackground) - - let topSeparator = NSBox(frame: NSRect(x: cardFrame.minX + 8, y: cardFrame.maxY - 1, width: cardFrame.width - 16, height: 1)) - topSeparator.boxType = .separator - controlStatusView.addSubview(topSeparator) - - let bottomSeparator = NSBox(frame: NSRect(x: cardFrame.minX + 8, y: cardFrame.minY, width: cardFrame.width - 16, height: 1)) - bottomSeparator.boxType = .separator - controlStatusView.addSubview(bottomSeparator) - - let topLeftField = NSTextField(labelWithString: "") - topLeftField.frame = NSRect(x: leftX, y: topY, width: leftWidth, height: 14) - topLeftField.font = smallMenuFont - topLeftField.textColor = .secondaryLabelColor - topLeftField.alignment = .left - topLeftField.lineBreakMode = .byTruncatingTail - - let topRightField = NSTextField(labelWithString: "") - topRightField.frame = NSRect(x: rightX, y: topY, width: rightWidth, height: 14) - topRightField.font = smallMenuFont - topRightField.textColor = .secondaryLabelColor - topRightField.alignment = .right - topRightField.lineBreakMode = .byTruncatingHead - - let bottomLeftField = NSTextField(labelWithString: "") - bottomLeftField.frame = NSRect(x: leftX, y: bottomY, width: leftWidth, height: 14) - bottomLeftField.font = NSFont.boldSystemFont(ofSize: 11) - bottomLeftField.textColor = textColor - bottomLeftField.alignment = .left - bottomLeftField.lineBreakMode = .byTruncatingTail - - let bottomRightField = NSTextField(labelWithString: "") - bottomRightField.frame = NSRect(x: rightX, y: bottomY, width: rightWidth, height: 14) - bottomRightField.font = smallMenuFont - bottomRightField.textColor = .secondaryLabelColor - bottomRightField.alignment = .right - bottomRightField.lineBreakMode = .byTruncatingHead - - let separatorField = NSTextField(labelWithString: "│") - separatorField.frame = NSRect(x: separatorX - 6, y: cardFrame.minY + 13, width: 12, height: 18) - separatorField.font = NSFont.systemFont(ofSize: 12) - separatorField.textColor = .separatorColor - separatorField.alignment = .center - - controlStatusView.addSubview(topLeftField) - controlStatusView.addSubview(topRightField) - controlStatusView.addSubview(bottomLeftField) - controlStatusView.addSubview(bottomRightField) - controlStatusView.addSubview(separatorField) - - controlStatusItem.view = controlStatusView - menu.addItem(controlStatusItem) + let statusView = NSView(frame: NSRect(x: 0, y: 0, width: 250, height: 28)) + let statusLabel = NSTextField(labelWithString: "") + statusLabel.font = .systemFont(ofSize: 12, weight: .medium) + statusLabel.frame = NSRect(x: 20, y: 6, width: 160, height: 16) + statusLabel.lineBreakMode = .byTruncatingTail + let modeLabel = NSTextField(labelWithString: "") + modeLabel.font = .systemFont(ofSize: 11) + modeLabel.textColor = .secondaryLabelColor + modeLabel.alignment = .right + modeLabel.frame = NSRect(x: 170, y: 6, width: 60, height: 16) + statusView.addSubview(statusLabel); statusView.addSubview(modeLabel) + controlStatusItem.view = statusView; menu.addItem(controlStatusItem) self.controlStatusItem = controlStatusItem - self.controlStatusContainerView = controlStatusView - self.controlStatusBackgroundView = cardBackground - self.controlStatusTopSeparator = topSeparator - self.controlStatusBottomSeparator = bottomSeparator - self.controlStatusDividerField = separatorField - self.controlStatusTopLeftField = topLeftField - self.controlStatusTopRightField = topRightField - self.controlStatusBottomLeftField = bottomLeftField - self.controlStatusBottomRightField = bottomRightField - layoutControlStatusView() - refreshControlStatusAppearance() + self.controlStatusContainerView = statusView + self.controlStatusBottomLeftField = statusLabel + self.controlModeField = modeLabel + menu.addItem(NSMenuItem.separator()) + + // 模式切换 + let modeItem = NSMenuItem(title: "", action: #selector(toggleMode), keyEquivalent: "m") + modeItem.target = self + menu.addItem(modeItem) + self.modeItem = modeItem + + // 手动转速(仅手动模式显示) + let spdItem = NSMenuItem(title: appL10n("设置转速...", "Set Speed..."), action: #selector(showSpeedSetting), keyEquivalent: "") + spdItem.target = self + spdItem.isHidden = true + menu.addItem(spdItem) + self.speedItem = spdItem // 温度源子菜单 let temperatureSourceItem = NSMenuItem(title: appL10n("选择温度源", "Select Temperature Source"), action: nil, keyEquivalent: "") let temperatureSourceMenu = NSMenu() + temperatureSourceMenu.addItem(sensorItem) + temperatureSourceMenu.addItem(NSMenuItem.separator()) let hottestItem = NSMenuItem(title: appL10n("自动选择(最热)", "Auto (Hottest)"), action: #selector(selectAutomaticTemperatureSource), keyEquivalent: "") hottestItem.target = self @@ -3090,49 +3109,36 @@ class MenuBarManager: NSObject { } menu.setSubmenu(temperatureSourceMenu, for: temperatureSourceItem) - menu.addItem(temperatureSourceItem) // 编辑曲线 let curveItem = NSMenuItem(title: appL10n("编辑曲线...", "Edit Curve..."), action: #selector(showFanCurveEditor), keyEquivalent: "c") curveItem.target = self menu.addItem(curveItem) + menu.addItem(temperatureSourceItem) + let powerItem = NSMenuItem(title: appL10n("电力消耗...", "Power Consumption..."), action: #selector(showPowerConsumption), keyEquivalent: "") + powerItem.target = self; menu.addItem(powerItem) menu.addItem(NSMenuItem.separator()) - // 模式切换 - let modeItem = NSMenuItem(title: "", action: #selector(toggleMode), keyEquivalent: "m") - modeItem.target = self - menu.addItem(modeItem) - self.modeItem = modeItem - - // 手动转速(仅手动模式显示) - let spdItem = NSMenuItem(title: appL10n("设置转速...", "Set Speed..."), action: #selector(showSpeedSetting), keyEquivalent: "") - spdItem.target = self - spdItem.isHidden = true - menu.addItem(spdItem) - self.speedItem = spdItem - - menu.addItem(NSMenuItem.separator()) - + let settingsItem = NSMenuItem(title: appL10n("设置", "Settings"), action: nil, keyEquivalent: "") + let settingsMenu = NSMenu() + settingsMenu.addItem(hwItem) + settingsMenu.addItem(NSMenuItem.separator()) // 安全兜底转速 let safetyItem = NSMenuItem(title: appL10n("安全兜底转速...", "Safety Floor RPM..."), action: #selector(showSafetyFloorSetting), keyEquivalent: "") safetyItem.target = self - menu.addItem(safetyItem) + settingsMenu.addItem(safetyItem) // 开机自启动 let autoItem = NSMenuItem(title: appL10n("开机自启动", "Launch at Login"), action: #selector(toggleAutoStart), keyEquivalent: "") autoItem.target = self - menu.addItem(autoItem) + settingsMenu.addItem(autoItem) self.autoStartItem = autoItem // 信息栏展示 let displayItem = NSMenuItem(title: appL10n("信息栏展示...", "Display..."), action: #selector(showDisplaySettings), keyEquivalent: "") displayItem.target = self - menu.addItem(displayItem) - - let powerItem = NSMenuItem(title: appL10n("电力消耗...", "Power Consumption..."), action: #selector(showPowerConsumption), keyEquivalent: "") - powerItem.target = self - menu.addItem(powerItem) + settingsMenu.addItem(displayItem) // 语言 let languageItem = NSMenuItem(title: appL10n("语言", "Language"), action: nil, keyEquivalent: "") @@ -3145,28 +3151,30 @@ class MenuBarManager: NSObject { en.target = self languageMenu.addItem(en) self.enItem = en - menu.setSubmenu(languageMenu, for: languageItem) - menu.addItem(languageItem) + settingsMenu.setSubmenu(languageMenu, for: languageItem) + settingsMenu.addItem(languageItem) - menu.addItem(NSMenuItem.separator()) + menu.setSubmenu(settingsMenu, for: settingsItem) + menu.addItem(settingsItem) // 关于/帮助 let aboutItem = NSMenuItem(title: appL10n("关于/帮助...", "About / Help..."), action: #selector(showHelp), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) - menu.addItem(NSMenuItem.separator()) - // 反馈入口(标准菜单项,自然对齐) let feedbackTitleItem = NSMenuItem(title: appL10n("告诉开发者 👋", "Talk to Dev 👋"), action: #selector(showFeedback), keyEquivalent: "") feedbackTitleItem.target = self menu.addItem(feedbackTitleItem) - // 模拟输入框(自定义视图) + // Intentional invitation: it looks like a text field to encourage feedback, + // and opens the dedicated composer rather than editing inside NSMenu. let feedbackItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") - let feedbackContainer = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 76)) - let feedbackField = NSTextField(frame: NSRect(x: 20, y: 6, width: 170, height: 64)) + let feedbackContainer = NSView(frame: NSRect(x: 0, y: 0, width: 250, height: 76)) + let feedbackField = NSTextField(frame: NSRect(x: 20, y: 6, width: 210, height: 64)) + feedbackField.identifier = NSUserInterfaceItemIdentifier("feedback-invitation") feedbackField.isEditable = false + feedbackField.isSelectable = false feedbackField.isBezeled = true feedbackField.bezelStyle = .roundedBezel feedbackField.font = NSFontManager.shared.convert(.systemFont(ofSize: 13), toHaveTrait: .italicFontMask) @@ -3176,11 +3184,20 @@ class MenuBarManager: NSObject { feedbackField.lineBreakMode = .byWordWrapping feedbackField.maximumNumberOfLines = 2 feedbackContainer.addSubview(feedbackField) - let clickGesture = NSClickGestureRecognizer(target: self, action: #selector(showFeedback)) - feedbackContainer.addGestureRecognizer(clickGesture) + // Native button hit testing also works during NSMenu tracking. Keep the + // inviting text-field appearance while making its entire area clickable. + let feedbackButton = NSButton(title: "", target: self, action: #selector(showFeedback)) + feedbackButton.frame = feedbackField.frame + feedbackButton.isTransparent = true + feedbackButton.identifier = NSUserInterfaceItemIdentifier("feedback-invitation-button") + feedbackButton.setAccessibilityLabel(feedbackField.stringValue) + feedbackButton.toolTip = appL10n("打开反馈窗口", "Open the feedback window") + feedbackField.setAccessibilityElement(false) + feedbackContainer.addSubview(feedbackButton) feedbackItem.view = feedbackContainer menu.addItem(feedbackItem) + menu.addItem(NSMenuItem.separator()) // 重启 / 退出 @@ -3199,8 +3216,6 @@ class MenuBarManager: NSObject { /// 原地更新动态菜单项(每 2 秒调用,不重建菜单) func updateDynamicMenuItems() { guard let statusItem = statusItem else { return } - layoutControlStatusView() - refreshControlStatusAppearance() let config = ConfigManager.shared.loadConfig() @@ -3298,11 +3313,11 @@ class MenuBarManager: NSObject { // 温度源 currentSensorItem?.title = appL10n("温度源 \(currentTemperatureSensorName)", "Temperature Source \(currentTemperatureSensorName)") let controlStatus = currentControlStatusLine() - controlStatusTopLeftField?.stringValue = controlStatus.topLeft - controlStatusTopRightField?.stringValue = controlStatus.topRight controlStatusBottomLeftField?.stringValue = controlStatus.bottomLeft controlStatusBottomLeftField?.textColor = controlStatus.statusColor - controlStatusBottomRightField?.stringValue = controlStatus.bottomRight + controlModeField?.stringValue = isAutoMode ? appL10n("自动", "Auto") : appL10n("手动", "Manual") + controlStatusContainerView?.toolTip = controlStatus.detailText + controlStatusContainerView?.setAccessibilityLabel(appL10n("控制状态", "Control status")) // 温度源子菜单:更新传感器数值和选中态 hottestItem?.state = (config.temperatureSourceMode == "manual" || averagedTemperatureSourceDefinition(for: config.temperatureSourceMode) != nil) ? .off : .on @@ -3322,7 +3337,7 @@ class MenuBarManager: NSObject { } // 模式切换 - modeItem?.title = isAutoMode ? appL10n("手动模式", "Manual Mode") : appL10n("自动模式", "Auto Mode") + modeItem?.title = isAutoMode ? appL10n("切换至手动控制", "Switch to Manual Control") : appL10n("切换至自动控制", "Switch to Auto Control") // 手动转速项(仅手动模式可见) speedItem?.isHidden = isAutoMode @@ -3335,59 +3350,6 @@ class MenuBarManager: NSObject { enItem?.state = currentLanguage == "en" ? .on : .off } - private func layoutControlStatusView() { - guard let containerView = controlStatusContainerView, - let cardBackground = controlStatusBackgroundView, - let topSeparator = controlStatusTopSeparator, - let bottomSeparator = controlStatusBottomSeparator, - let dividerField = controlStatusDividerField, - let topLeftField = controlStatusTopLeftField, - let topRightField = controlStatusTopRightField, - let bottomLeftField = controlStatusBottomLeftField, - let bottomRightField = controlStatusBottomRightField else { return } - - let baseWidth: CGFloat = 210 - let cardWidth: CGFloat = 190 - let containerWidth = baseWidth - containerView.frame = NSRect(x: 0, y: 0, width: containerWidth, height: 52) - - let cardX: CGFloat = 14 - let cardFrame = NSRect(x: cardX, y: 4, width: cardWidth, height: 44) - let leftX: CGFloat = cardFrame.minX + 14 - let separatorX: CGFloat = cardFrame.midX - let rightX: CGFloat = separatorX + 10 - let leftWidth: CGFloat = separatorX - leftX - 8 - let rightWidth: CGFloat = cardFrame.maxX - rightX - 12 - let topY: CGFloat = cardFrame.minY + 24 - let bottomY: CGFloat = cardFrame.minY + 8 - - cardBackground.frame = cardFrame - topSeparator.frame = NSRect(x: cardFrame.minX + 8, y: cardFrame.maxY - 1, width: cardFrame.width - 16, height: 1) - bottomSeparator.frame = NSRect(x: cardFrame.minX + 8, y: cardFrame.minY, width: cardFrame.width - 16, height: 1) - dividerField.frame = NSRect(x: separatorX - 6, y: cardFrame.minY + 13, width: 12, height: 18) - - topLeftField.frame = NSRect(x: leftX, y: topY, width: leftWidth, height: 14) - topRightField.frame = NSRect(x: rightX, y: topY, width: rightWidth, height: 14) - bottomLeftField.frame = NSRect(x: leftX, y: bottomY, width: leftWidth, height: 14) - bottomRightField.frame = NSRect(x: rightX, y: bottomY, width: rightWidth, height: 14) - } - - private func refreshControlStatusAppearance() { - guard let backgroundView = controlStatusBackgroundView else { return } - let appearance = statusItem?.button?.effectiveAppearance ?? NSApp.effectiveAppearance - let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua - - let fillColor: NSColor = isDark - ? NSColor.white.withAlphaComponent(0.07) - : NSColor.black.withAlphaComponent(0.035) - let borderColor: NSColor = isDark - ? NSColor.white.withAlphaComponent(0.12) - : NSColor.black.withAlphaComponent(0.07) - - backgroundView.layer?.backgroundColor = fillColor.cgColor - backgroundView.layer?.borderColor = borderColor.cgColor - } - private func currentControlStatusLine() -> (topLeft: String, topRight: String, bottomLeft: String, bottomRight: String, statusColor: NSColor, detailText: String) { guard let snapshot = ControlStatusStore.load() else { return ( @@ -3416,7 +3378,7 @@ class MenuBarManager: NSObject { let statusColor: NSColor switch snapshot.mode { case "normal": - modeText = appL10n("正常", "Normal") + modeText = appL10n("控制正常", "Control normal") statusColor = .systemGreen case "sensor_loss_fixed_rpm": modeText = appL10n("⚠ 传感器丢失", "⚠ Sensor Lost") @@ -3440,7 +3402,13 @@ class MenuBarManager: NSObject { bottomLeft = "" } - let detailText = snapshot.mode == "normal" ? bottomLeft : "" + // recordedAt is a status/detail transition timestamp, not a health-check + // freshness timestamp. Keep it out of the visible current-status row. + let recordExplanation = appL10n( + "最近一次状态记录:\(ageText)", + "Last status record: \(ageText)" + ) + let detailText = [bottomLeft, recordExplanation].filter { !$0.isEmpty }.joined(separator: "\n") return ( appL10n("控制状态", "Status"), @@ -3459,6 +3427,7 @@ class MenuBarManager: NSObject { } @objc func showFanCurveEditor() { + if let window = fanCurveWindowController?.window, window.isVisible { presentWindowFront(window); return } NSLog("iFanControl: showFanCurveEditor - creating FanCurvePresetWindowController") let config = ConfigManager.shared.loadConfig() fanCurveWindowController = FanCurvePresetWindowController( @@ -3563,6 +3532,7 @@ class MenuBarManager: NSObject { } @objc func showSpeedSetting() { + if let window = speedSettingWindowController?.window, window.isVisible { presentWindowFront(window); return } let config = ConfigManager.shared.loadConfig() let currentRPM = min(config.manualRPM, FanManager.shared.currentMaxRPM) let controlMode = ManualRPMControlMode(storedValue: config.manualRPMControlMode) @@ -3578,6 +3548,7 @@ class MenuBarManager: NSObject { } @objc func showDisplaySettings() { + if let window = displaySettingsWindowController?.window, window.isVisible { presentWindowFront(window); return } displaySettingsWindowController = DisplaySettingsWindowController() displaySettingsWindowController?.onSettingsChanged = { [weak self] in self?.statusItem?.button?.font = nil @@ -3599,6 +3570,7 @@ class MenuBarManager: NSObject { } @objc func showSafetyFloorSetting() { + if let window = safetyFloorWindowController?.window, window.isVisible { presentWindowFront(window); return } let config = ConfigManager.shared.loadConfig() let currentRPM = min(max(config.safetyFloorRPM ?? FanManager.shared.defaultSafetyRPM, 2000), FanManager.shared.currentMaxRPM) safetyFloorWindowController = SafetyFloorWindowController(initialRPM: currentRPM, maxRPM: FanManager.shared.currentMaxRPM) @@ -3668,7 +3640,7 @@ class MenuBarManager: NSObject { updateDynamicMenuItems() } - private func maybeShowStartupGuidance() { + func maybeShowStartupGuidance() { let defaults = UserDefaults.standard // 首次启动语言选择 @@ -3702,13 +3674,7 @@ class MenuBarManager: NSObject { let readings = FanManager.shared.availableTemperatureReadings() if readings.count > 1 && !defaults.bool(forKey: temperatureSourceHintKey) { defaults.set(true, forKey: temperatureSourceHintKey) - let alert = NSAlert() - alert.messageText = appL10n("温度源已自动选择", "Temperature Source Set to Auto") - alert.informativeText = appL10n("默认会使用当前最热的温度传感器来驱动风扇曲线,也可以在温度源菜单中选择 CPU 或 GPU 传感器的平均温度,或固定某个传感器。这里显示的是这台机器真实可读到的温度传感器,不一定与 CPU/GPU 核心数量一一对应。", "By default, the hottest available sensor drives the fan curve. You can also choose a CPU or GPU sensor average, or pin one sensor from the temperature-source menu. Sensor count may not match CPU/GPU core count.") - alert.alertStyle = .informational - alert.addButton(withTitle: appL10n("知道了", "OK")) - logger.info("Displayed first-run temperature source guidance") - alert.runModal() + InteractionNotice.shared.show(appL10n("温度源与控制模式可随时在菜单中调整。", "You can change the temperature source and control mode in the menu."), symbol: "info.circle") } } @@ -3721,6 +3687,7 @@ class MenuBarManager: NSObject { } @objc func showFeedback() { + menu?.cancelTracking() // 先关闭菜单,避免遮挡弹窗 self.menu?.cancelTracking() @@ -3838,16 +3805,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var launchDate = Date() private var consecutiveMissingTelemetryCount = 0 private var lastRecordedControlStatus: String? + private let startupWindow = StartupProgressWindowController() func applicationDidFinishLaunching(_ notification: Notification) { Self.shared = self launchDate = Date() NSApp.setActivationPolicy(.accessory) + startupWindow.present() + // Let the launch UI enter the run loop before the existing hardware probes. + DispatchQueue.main.async { [weak self] in + self?.finishLaunching() + } + } + + private func finishLaunching() { AppLog.shared.bootstrapSession() AppLog.shared.info("application did finish launching") updateControlStatus(mode: "normal", detail: appL10n("启动完成", "Startup complete"), force: true) FanManager.shared.refreshHardwareProfile() + startupWindow.setStage(.preferences) let config = ConfigManager.shared.syncHardwareProfile(maxRPM: FanManager.shared.currentMaxRPM) MenuBarManager.shared.setFanCurve(config.curve) MenuBarManager.shared.setIsAutoMode(config.mode == "auto") @@ -3858,6 +3835,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { MenuBarManager.shared.ensureAutoStartEnabled() } + startupWindow.setStage(.controls) MenuBarManager.shared.setupMenuBar() // 解锁风扇 @@ -3881,9 +3859,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { hbTimer.setEventHandler { PrivacyStatsService.shared.tick() } hbTimer.resume() statsHeartbeatTimer = hbTimer + startupWindow.dismiss() + AppLog.shared.info("startup ready: menu bar and control loop initialized") + UpdateService.shared.presentInstalledUpdateIfNeeded() + MenuBarManager.shared.maybeShowStartupGuidance() } func applicationWillTerminate(_ notification: Notification) { + startupWindow.dismiss() AppLog.shared.info("application will terminate") statsHeartbeatTimer?.cancel() PrivacyStatsService.shared.shutdown() @@ -4114,7 +4097,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let alert = NSAlert() alert.messageText = title - alert.informativeText = message + alert.informativeText = message + appL10n("\n\n如果问题持续,请到「关于/帮助 → 诊断与支持」导出诊断包。", "\n\nIf this persists, export diagnostics from About / Help → Diagnostics & Support.") alert.alertStyle = .warning alert.addButton(withTitle: appL10n("知道了", "OK")) alert.runModal() @@ -4156,10 +4139,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { class DisplaySettingsWindowController: NSWindowController { var onSettingsChanged: (() -> Void)? + private let previewField = NSTextField(labelWithString: "") private var fullRadio: NSButton! private var compactRadio: NSButton! private var miniRadio: NSButton! private var miniOptionsStack: NSStackView! + private var modeControl: NSSegmentedControl! + private var topStylePopup: NSPopUpButton! + private var bottomStylePopup: NSPopUpButton! private var topSourceCheckboxes: [String: NSButton] = [:] private var topStyleRadios: [String: NSButton] = [:] @@ -4246,7 +4233,7 @@ class DisplaySettingsWindowController: NSWindowController { init() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 380, height: 180), + contentRect: NSRect(x: 0, y: 0, width: 540, height: 390), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -4263,113 +4250,114 @@ class DisplaySettingsWindowController: NSWindowController { private func setupUI() { guard let contentView = window?.contentView else { return } + let root = NSStackView() + root.orientation = .vertical; root.alignment = .leading; root.spacing = 18 + root.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(root) + + modeControl = NSSegmentedControl(labels: [appL10n("完整", "Full"), appL10n("简洁", "Compact"), appL10n("迷你", "Mini")], trackingMode: .selectOne, target: self, action: #selector(modeSelectionChanged)) + modeControl.segmentStyle = .rounded + modeControl.setAccessibilityLabel(appL10n("菜单栏显示模式", "Menu bar display mode")) + modeControl.controlSize = .small; modeControl.font = .systemFont(ofSize: 12) + for index in 0..<3 { modeControl.setWidth(70, forSegment: index) } + let modeRow = NSStackView(views: [NativeInterface.label(appL10n("显示模式", "Display mode"), size: 12, color: .secondaryLabelColor), NSView(), modeControl]) + root.addArrangedSubview(modeRow) + modeRow.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + // The existing state controls preserve the update and preference semantics. + fullRadio = makeRadioButton(""); compactRadio = makeRadioButton(""); miniRadio = makeRadioButton("") + + let preview = NSStackView(); preview.orientation = .vertical; preview.spacing = 10; preview.alignment = .centerX + preview.addArrangedSubview(NativeInterface.label(appL10n("菜单栏预览", "Menu bar preview"), size: 11, weight: .medium, color: .secondaryLabelColor)) + previewField.font = .monospacedDigitSystemFont(ofSize: 18, weight: .medium) + previewField.alignment = .center; previewField.maximumNumberOfLines = 2 + previewField.identifier = NSUserInterfaceItemIdentifier("display-preview") + preview.addArrangedSubview(previewField) + previewField.heightAnchor.constraint(equalToConstant: 50).isActive = true + root.addArrangedSubview(preview) + preview.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + + miniOptionsStack = NSStackView(); miniOptionsStack.orientation = .vertical + miniOptionsStack.alignment = .leading; miniOptionsStack.spacing = 14 + miniOptionsStack.addArrangedSubview(makeLineRow(top: true)) + let separator = NSBox(); separator.boxType = .separator + miniOptionsStack.addArrangedSubview(separator) + separator.widthAnchor.constraint(equalTo: miniOptionsStack.widthAnchor).isActive = true + miniOptionsStack.addArrangedSubview(makeLineRow(top: false)) + root.addArrangedSubview(miniOptionsStack) + miniOptionsStack.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + + let footer = NSStackView(); footer.spacing = 8 + let hint = NativeInterface.label(appL10n("示例数值 · 设置即时生效", "Sample values · Changes apply immediately"), size: 11, color: .secondaryLabelColor) + footer.addArrangedSubview(hint); footer.addArrangedSubview(NSView()) + let swap = NativeInterface.button(appL10n("交换上下行", "Swap lines"), target: self, action: #selector(swapLines), small: true) + swap.toolTip = appL10n("交换两行的内容和样式", "Swap the content and style of both lines") + footer.addArrangedSubview(swap); root.addArrangedSubview(footer) + footer.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + NSLayoutConstraint.activate([ + root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), + root.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20) + ]) + } - // ── 显示模式 ────────────────────────── - let displayModeLabel = makeSectionLabel(appL10n("显示模式", "Display Mode")) - contentView.addSubview(displayModeLabel) - - fullRadio = makeRadioButton(appL10n("完整:65℃ | 2400 RPM [Auto]", "Full: 65℃ | 2400 RPM [Auto]")) - compactRadio = makeRadioButton(appL10n("简洁:53|981|A", "Compact: 53|981|A")) - miniRadio = makeRadioButton(appL10n("迷你:两行显示", "Mini: Two Lines")) - for radio in [fullRadio!, compactRadio!, miniRadio!] { - contentView.addSubview(radio) - radio.target = self - radio.action = #selector(displayModeChanged) - } - - // ── 迷你选项容器(缩进,视觉上从属于「迷你」)──────── - miniOptionsStack = NSStackView() - miniOptionsStack.orientation = .vertical - miniOptionsStack.alignment = .leading - miniOptionsStack.spacing = 6 - miniOptionsStack.translatesAutoresizingMaskIntoConstraints = false - miniOptionsStack.isHidden = true - contentView.addSubview(miniOptionsStack) - - // 上行内容(次级标签) - miniOptionsStack.addArrangedSubview(makeSubsectionLabel(appL10n("上层内容(可多选)", "Top Content (Multi-select)"))) - let topSourceGroup = makeRadioGroupStack() - miniOptionsStack.addArrangedSubview(topSourceGroup) - for item in Self.allSources { - let checkbox = makeCheckboxButton(appL10n(item.zh, item.en)) - checkbox.target = self - checkbox.action = #selector(topSourceChanged) - topSourceGroup.addArrangedSubview(checkbox) - topSourceCheckboxes[item.key.rawValue] = checkbox - } - miniOptionsStack.addArrangedSubview(makeSubsectionLabel(appL10n("上层样式(示例)", "Top Style (Examples)"))) - let topStyleGroup = makeRadioGroupStack() - miniOptionsStack.addArrangedSubview(topStyleGroup) - for item in Self.allStyles { - let radio = makeRadioButton(appL10n(item.zh, item.en)) - radio.target = self - radio.action = #selector(topStyleChanged) - topStyleGroup.addArrangedSubview(radio) - topStyleRadios[item.key.rawValue] = radio - } - - // 交换按钮 + 分隔线 - let swapRow = NSStackView() - swapRow.orientation = .horizontal - swapRow.alignment = .centerY - swapRow.spacing = 8 - swapRow.translatesAutoresizingMaskIntoConstraints = false - - let leftSep = NSBox(); leftSep.boxType = .separator; leftSep.translatesAutoresizingMaskIntoConstraints = false - let rightSep = NSBox(); rightSep.boxType = .separator; rightSep.translatesAutoresizingMaskIntoConstraints = false - let swapButton = NSButton(title: appL10n("交换上下行", "Swap Lines"), target: self, action: #selector(swapLines)) - swapButton.bezelStyle = .rounded - swapButton.font = .systemFont(ofSize: 12) - swapButton.translatesAutoresizingMaskIntoConstraints = false - - swapRow.addArrangedSubview(leftSep) - swapRow.addArrangedSubview(swapButton) - swapRow.addArrangedSubview(rightSep) - leftSep.widthAnchor.constraint(greaterThanOrEqualToConstant: 40).isActive = true - rightSep.widthAnchor.constraint(equalTo: leftSep.widthAnchor).isActive = true - miniOptionsStack.addArrangedSubview(swapRow) - leftSep.heightAnchor.constraint(equalToConstant: 1).isActive = true - rightSep.heightAnchor.constraint(equalToConstant: 1).isActive = true - - // 下行内容(次级标签) - miniOptionsStack.addArrangedSubview(makeSubsectionLabel(appL10n("下层内容(可多选)", "Bottom Content (Multi-select)"))) - let bottomSourceGroup = makeRadioGroupStack() - miniOptionsStack.addArrangedSubview(bottomSourceGroup) + private func makeLineRow(top: Bool) -> NSStackView { + let column = NSStackView(); column.orientation = .vertical; column.alignment = .leading; column.spacing = 8 + let header = NSStackView(); header.spacing = 12 + header.addArrangedSubview(NativeInterface.label(top ? appL10n("上行", "Top line") : appL10n("下行", "Bottom line"), weight: .medium)) + header.addArrangedSubview(NSView()) + let popup = NSPopUpButton(); popup.controlSize = .small; popup.font = .systemFont(ofSize: 12) + popup.addItems(withTitles: [appL10n("仅数值", "Numbers only"), appL10n("数值和单位", "Numbers and units"), appL10n("数值和模式", "Numbers and mode"), appL10n("数值、单位和模式", "Numbers, units and mode")]) + popup.target = self; popup.action = top ? #selector(topPopupChanged) : #selector(bottomPopupChanged) + popup.setAccessibilityLabel(top ? appL10n("上行样式", "Top line style") : appL10n("下行样式", "Bottom line style")) + header.addArrangedSubview(popup); column.addArrangedSubview(header) + if top { topStylePopup = popup } else { bottomStylePopup = popup } + let sources = NSStackView(); sources.spacing = 20 for item in Self.allSources { - let checkbox = makeCheckboxButton(appL10n(item.zh, item.en)) - checkbox.target = self - checkbox.action = #selector(bottomSourceChanged) - bottomSourceGroup.addArrangedSubview(checkbox) - bottomSourceCheckboxes[item.key.rawValue] = checkbox - } - miniOptionsStack.addArrangedSubview(makeSubsectionLabel(appL10n("下层样式(示例)", "Bottom Style (Examples)"))) - let bottomStyleGroup = makeRadioGroupStack() - miniOptionsStack.addArrangedSubview(bottomStyleGroup) + let title = item.key == .temperature ? appL10n("温度", "Temperature") : item.key == .rpm ? appL10n("转速", "RPM") : appL10n("功率", "Power") + let button = makeCheckboxButton(title); button.controlSize = .small; button.font = .systemFont(ofSize: 12) + button.target = self; button.action = top ? #selector(topSourceChanged) : #selector(bottomSourceChanged) + sources.addArrangedSubview(button) + if top { topSourceCheckboxes[item.key.rawValue] = button } else { bottomSourceCheckboxes[item.key.rawValue] = button } + } + sources.addArrangedSubview(NSView()); column.addArrangedSubview(sources) for item in Self.allStyles { - let radio = makeRadioButton(appL10n(item.zh, item.en)) - radio.target = self - radio.action = #selector(bottomStyleChanged) - bottomStyleGroup.addArrangedSubview(radio) - bottomStyleRadios[item.key.rawValue] = radio + let state = makeRadioButton("") + if top { topStyleRadios[item.key.rawValue] = state } else { bottomStyleRadios[item.key.rawValue] = state } } + column.widthAnchor.constraint(equalToConstant: 492).isActive = true + header.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true + sources.widthAnchor.constraint(equalTo: column.widthAnchor).isActive = true + return column + } - // ── 布局 ───────────────────────────── - NSLayoutConstraint.activate([ - displayModeLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - displayModeLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), - - fullRadio.topAnchor.constraint(equalTo: displayModeLabel.bottomAnchor, constant: 8), - fullRadio.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), - compactRadio.topAnchor.constraint(equalTo: fullRadio.bottomAnchor, constant: 4), - compactRadio.leadingAnchor.constraint(equalTo: fullRadio.leadingAnchor), - miniRadio.topAnchor.constraint(equalTo: compactRadio.bottomAnchor, constant: 4), - miniRadio.leadingAnchor.constraint(equalTo: fullRadio.leadingAnchor), - - miniOptionsStack.topAnchor.constraint(equalTo: miniRadio.bottomAnchor, constant: 12), - miniOptionsStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), - miniOptionsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), - miniOptionsStack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20), - ]) + @objc private func modeSelectionChanged(_ sender: NSSegmentedControl) { + fullRadio.state = sender.selectedSegment == 0 ? .on : .off + compactRadio.state = sender.selectedSegment == 1 ? .on : .off + miniRadio.state = sender.selectedSegment == 2 ? .on : .off + displayModeChanged(miniRadio) + } + @objc private func topPopupChanged(_ sender: NSPopUpButton) { + guard Self.allStyles.indices.contains(sender.indexOfSelectedItem), let state = topStyleRadios[Self.allStyles[sender.indexOfSelectedItem].key.rawValue] else { return } + topStyleChanged(state) + } + @objc private func bottomPopupChanged(_ sender: NSPopUpButton) { + guard Self.allStyles.indices.contains(sender.indexOfSelectedItem), let state = bottomStyleRadios[Self.allStyles[sender.indexOfSelectedItem].key.rawValue] else { return } + bottomStyleChanged(state) + } + + private func refreshPreview() { + func line(_ sources: [MiniLineSource], _ style: MiniLineStyle) -> String { + let texts = sources.map { source -> (String, String) in + switch source { case .temperature: return ("65", "65℃"); case .rpm: return ("2400", "2400 RPM"); case .power: return ("12.3", "12.3W") } + } + let result = MiniDisplayFormatter.line(values: texts.map { $0.0 }, units: texts.map { $0.1 }, style: style.rawValue, mode: "A") + return result.primary + result.accent + } + switch currentDisplayMode { + case "compact": previewField.stringValue = "65|2400|A" + case "mini": previewField.stringValue = line(currentMiniTopLine.sources, currentMiniTopLine.style) + "\n" + line(currentMiniBottomLine.sources, currentMiniBottomLine.style) + default: previewField.stringValue = "65℃ | 2400 RPM [Auto]" + } } // MARK: - Helpers @@ -4417,12 +4405,14 @@ class DisplaySettingsWindowController: NSWindowController { checkbox.state = topSources.contains(key) ? .on : .off } topStyleRadios[currentMiniTopLine.style.rawValue]?.state = .on + topStylePopup.selectItem(at: Self.allStyles.firstIndex(where: { $0.key == currentMiniTopLine.style }) ?? 3) let bottomSources = Set(currentMiniBottomLine.sources.map(\.rawValue)) for (key, checkbox) in bottomSourceCheckboxes { checkbox.state = bottomSources.contains(key) ? .on : .off } bottomStyleRadios[currentMiniBottomLine.style.rawValue]?.state = .on + bottomStylePopup.selectItem(at: Self.allStyles.firstIndex(where: { $0.key == currentMiniBottomLine.style }) ?? 1) } private func loadCurrentSettings() { @@ -4434,6 +4424,7 @@ class DisplaySettingsWindowController: NSWindowController { syncMiniLineControls() updateMiniOptionsVisibility() + refreshPreview() } // MARK: - Actions @@ -4447,6 +4438,7 @@ class DisplaySettingsWindowController: NSWindowController { currentDisplayMode = "mini" } updateMiniOptionsVisibility() + refreshPreview() onSettingsChanged?() } @@ -4464,6 +4456,7 @@ class DisplaySettingsWindowController: NSWindowController { } currentMiniTopLine = (sources, currentMiniTopLine.style) syncMiniLineControls() + refreshPreview() onSettingsChanged?() } @@ -4471,6 +4464,7 @@ class DisplaySettingsWindowController: NSWindowController { guard let key = topStyleRadios.first(where: { $0.value === sender })?.key, let style = MiniLineStyle(rawValue: key) else { return } currentMiniTopLine = (currentMiniTopLine.sources, style) + refreshPreview() onSettingsChanged?() } @@ -4488,6 +4482,7 @@ class DisplaySettingsWindowController: NSWindowController { } currentMiniBottomLine = (sources, currentMiniBottomLine.style) syncMiniLineControls() + refreshPreview() onSettingsChanged?() } @@ -4495,6 +4490,7 @@ class DisplaySettingsWindowController: NSWindowController { guard let key = bottomStyleRadios.first(where: { $0.value === sender })?.key, let style = MiniLineStyle(rawValue: key) else { return } currentMiniBottomLine = (currentMiniBottomLine.sources, style) + refreshPreview() onSettingsChanged?() } @@ -4504,19 +4500,27 @@ class DisplaySettingsWindowController: NSWindowController { currentMiniTopLine = bottom currentMiniBottomLine = top syncMiniLineControls() + refreshPreview() onSettingsChanged?() } private func updateMiniOptionsVisibility() { + modeControl.selectedSegment = fullRadio.state == .on ? 0 : compactRadio.state == .on ? 1 : 2 let showMini = miniRadio.state == .on miniOptionsStack.isHidden = !showMini - let height: CGFloat = showMini ? 640 : 180 - window?.setContentSize(NSSize(width: 380, height: height)) + refreshPreview() + guard let window else { return } + var frame = window.frame + let height: CGFloat = showMini ? 400 : 235 + frame.origin.y += frame.height - height + frame.size.height = height + window.setFrame(frame, display: true, animate: !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion) + } } @MainActor -class PowerConsumptionWindowController: NSWindowController { +class PowerConsumptionWindowController: NSWindowController, NSWindowDelegate { private let rows: [(key: String, zh: String, en: String)] = [ ("cpu", "CPU", "CPU"), ("gpu", "GPU", "GPU"), @@ -4525,6 +4529,7 @@ class PowerConsumptionWindowController: NSWindowController { ("pci", "PCI", "PCI"), ("total", "总计(DC In)", "Total (DC In)"), ] + private let samplingStatus = NSTextField(wrappingLabelWithString: "") private var valueLabels: [String: NSTextField] = [:] private var updateTimer: DispatchSourceTimer? @@ -4539,6 +4544,7 @@ class PowerConsumptionWindowController: NSWindowController { window.center() super.init(window: window) + window.delegate = self applyWindowMaterialStyle(window) setupUI() } @@ -4559,13 +4565,16 @@ class PowerConsumptionWindowController: NSWindowController { func refreshSnapshot() { let snapshot = PowerTelemetryReader.shared.snapshot if !snapshot.isSupported { - valueLabels["cpu"]?.stringValue = appL10n("当前机型暂不支持", "Not supported on this Mac") - for key in ["gpu", "ane", "ram", "pci", "total"] { + samplingStatus.stringValue = appL10n("当前机型暂不支持功率读取。", "Power readings are not supported on this Mac.") + for key in ["cpu", "gpu", "ane", "ram", "pci", "total"] { valueLabels[key]?.stringValue = "--" } return } + samplingStatus.stringValue = snapshot.hasAnyReading + ? appL10n("上次读取:\(Int(Date().timeIntervalSince(snapshot.lastUpdatedAt ?? Date())))秒前", "Last reading: \(Int(Date().timeIntervalSince(snapshot.lastUpdatedAt ?? Date())))s ago") + : appL10n("正在读取首个样本…", "Reading the first sample…") updateValueLabel(for: "cpu", value: snapshot.cpuWatts, hasReading: snapshot.hasAnyReading) updateValueLabel(for: "gpu", value: snapshot.gpuWatts, hasReading: snapshot.hasAnyReading) updateValueLabel(for: "ane", value: snapshot.aneWatts, hasReading: snapshot.hasAnyReading) @@ -4576,58 +4585,32 @@ class PowerConsumptionWindowController: NSWindowController { private func setupUI() { guard let contentView = window?.contentView else { return } - - let stack = NSStackView() - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 12 - stack.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(stack) - - let descriptionLabel = NSTextField(labelWithString: appL10n( - "基于 Apple Silicon Energy Model 的实时估算,仅供参考。", - "Live estimate based on Apple Silicon Energy Model. For reference only." - )) - descriptionLabel.textColor = .secondaryLabelColor - descriptionLabel.maximumNumberOfLines = 0 - descriptionLabel.lineBreakMode = .byWordWrapping - stack.addArrangedSubview(descriptionLabel) - - let grid = NSGridView() - grid.rowSpacing = 8 - grid.columnSpacing = 16 - grid.translatesAutoresizingMaskIntoConstraints = false - stack.addArrangedSubview(grid) - - for row in rows { - let titleLabel = NSTextField(labelWithString: appL10n(row.zh, row.en)) - titleLabel.font = .systemFont(ofSize: 13, weight: row.key == "total" ? .semibold : .regular) - let valueLabel = NSTextField(labelWithString: "--") - valueLabel.font = .monospacedDigitSystemFont(ofSize: 13, weight: row.key == "total" ? .semibold : .regular) - valueLabel.alignment = .right - valueLabel.stringValue = "--" - valueLabels[row.key] = valueLabel - grid.addRow(with: [titleLabel, valueLabel]) - } - - let noteLabel = NSTextField(labelWithString: appL10n( - "首次打开后的前一次采样可能显示为空,等待 1-2 秒即可。", - "The first sample may be empty right after opening. Please wait 1-2 seconds." - )) - noteLabel.textColor = .secondaryLabelColor - noteLabel.maximumNumberOfLines = 0 - noteLabel.lineBreakMode = .byWordWrapping - stack.addArrangedSubview(noteLabel) - + let root = NSStackView(); root.orientation = .vertical; root.alignment = .leading; root.spacing = 16 + root.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(root) + root.addArrangedSubview(NativeInterface.label(appL10n("总计(DC In)", "Total (DC In)"), size: 12, weight: .medium, color: .secondaryLabelColor)) + let total = NativeInterface.label("--", size: 30, weight: .medium) + root.addArrangedSubview(total); valueLabels["total"] = total + let separator = NSBox(); separator.boxType = .separator; root.addArrangedSubview(separator) + separator.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + let grid = NSGridView(); grid.rowSpacing = 10; grid.columnSpacing = 24; root.addArrangedSubview(grid) + grid.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + for row in rows where row.key != "total" { + let title = NativeInterface.label(appL10n(row.zh, row.en), size: 12) + let value = NativeInterface.label("--", size: 12) + value.font = .monospacedDigitSystemFont(ofSize: 12, weight: .regular); value.alignment = .right + valueLabels[row.key] = value; grid.addRow(with: [title, value]) + } + samplingStatus.font = .systemFont(ofSize: 11); samplingStatus.textColor = .secondaryLabelColor + root.addArrangedSubview(samplingStatus) + let footnote = NativeInterface.label(appL10n("基于 Apple Silicon Energy Model 的估算值。", "Estimate from Apple Silicon Energy Model."), size: 10, color: .secondaryLabelColor, wrapping: true) + root.addArrangedSubview(footnote); footnote.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true NSLayoutConstraint.activate([ - stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20), - grid.widthAnchor.constraint(equalTo: stack.widthAnchor), + root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 22), root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), root.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -22) ]) } + func windowWillClose(_ notification: Notification) { updateTimer?.cancel(); updateTimer = nil } + private func startTimerIfNeeded() { guard updateTimer == nil else { return } let timer = DispatchSource.makeTimerSource(queue: .main) @@ -4641,13 +4624,15 @@ class PowerConsumptionWindowController: NSWindowController { private func updateValueLabel(for key: String, value: Double, hasReading: Bool) { guard let label = valueLabels[key] else { return } - label.stringValue = hasReading ? String(format: "%.2f W", value) : "--" + if key == "total" && hasReading { label.attributedStringValue = NativeInterface.measurement(String(format: "%.2f", value), unit: "W") } + else { label.stringValue = hasReading ? String(format: "%.2f W", value) : "--" } } } // 转速设置窗口控制器 @MainActor class SpeedSettingWindowController: NSWindowController { + private var completionButton: NSButton? private var currentRPM: Int = 0 private let maxRPM: Int private var controlMode: ManualRPMControlMode @@ -4666,7 +4651,7 @@ class SpeedSettingWindowController: NSWindowController { self.currentRPM = initialRPM let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 420, height: 250), + contentRect: NSRect(x: 0, y: 0, width: 400, height: 280), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -4688,113 +4673,37 @@ class SpeedSettingWindowController: NSWindowController { private func setupUI() { guard let contentView = window?.contentView else { return } - - let titleLabel = NSTextField(labelWithString: appL10n("手动模式目标转速:", "Manual Mode Target RPM:")) - titleLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(titleLabel) - - let modeLabel = NSTextField(labelWithString: appL10n("调节方式", "Adjustment Mode")) - modeLabel.textColor = .secondaryLabelColor - modeLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(modeLabel) - - let controlModeSegmentedControl = NSSegmentedControl(labels: [appL10n("连续", "Continuous"), appL10n("档位", "Stepped")], trackingMode: .selectOne, target: self, action: #selector(controlModeChanged)) - controlModeSegmentedControl.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(controlModeSegmentedControl) - - let rpmSlider = NSSlider() - rpmSlider.minValue = 0 - rpmSlider.maxValue = Double(maxRPM) - rpmSlider.target = self - rpmSlider.action = #selector(sliderChanged) - rpmSlider.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(rpmSlider) - - let rpmLabel = NSTextField(labelWithString: "\(currentRPM) RPM") - rpmLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(rpmLabel) - - let rpmExplanationLabel = NSTextField(labelWithString: appL10n( - "实际转速接近目标值即可,不一定完全相等。", - "Actual RPM only needs to stay near the target; it may not match exactly." - )) - rpmExplanationLabel.textColor = .secondaryLabelColor - rpmExplanationLabel.font = .systemFont(ofSize: 12) - rpmExplanationLabel.alignment = .center - rpmExplanationLabel.lineBreakMode = .byWordWrapping - rpmExplanationLabel.maximumNumberOfLines = 2 - rpmExplanationLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(rpmExplanationLabel) - - let rpmHelpButton = NSButton(title: "?", target: self, action: #selector(showRPMHelp)) - rpmHelpButton.bezelStyle = .helpButton - rpmHelpButton.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(rpmHelpButton) - - let stepHintLabel = NSTextField(labelWithString: "") - stepHintLabel.textColor = .secondaryLabelColor - stepHintLabel.alignment = .center - stepHintLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(stepHintLabel) - - let applyStatusLabel = NSTextField(labelWithString: appL10n("✓ 已应用", "✓ Applied")) - applyStatusLabel.textColor = .systemGreen - applyStatusLabel.font = .systemFont(ofSize: 12, weight: .medium) - applyStatusLabel.alignment = .center - applyStatusLabel.alphaValue = 0 - applyStatusLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(applyStatusLabel) - - let applyButton = NSButton(title: appL10n("应用", "Apply"), target: self, action: #selector(applySpeed)) - applyButton.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(applyButton) - - let cancelButton = NSButton(title: appL10n("取消", "Cancel"), target: self, action: #selector(cancel)) - cancelButton.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(cancelButton) - + let root = NSStackView(); root.orientation = .vertical; root.alignment = .centerX; root.spacing = 14 + root.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(root) + let title = NativeInterface.label(appL10n("目标转速", "Target speed"), size: 15, weight: .medium) + root.addArrangedSubview(title); title.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + let value = NSTextField(labelWithString: ""); value.attributedStringValue = NativeInterface.rpm(currentRPM) + root.addArrangedSubview(value); rpmLabel = value + let explanation = NativeInterface.label(appL10n("实际转速接近目标值即可,不一定完全相等。", "Actual RPM may stay near the target without matching it exactly."), size: 12, color: .secondaryLabelColor, wrapping: true) + explanation.alignment = .center + let help = NSButton(title: "?", target: self, action: #selector(showRPMHelp)); help.bezelStyle = .helpButton + let hintRow = NSStackView(views: [explanation, help]); hintRow.spacing = 6; root.addArrangedSubview(hintRow) + hintRow.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + let slider = NSSlider(value: Double(currentRPM), minValue: 0, maxValue: Double(maxRPM), target: self, action: #selector(sliderChanged)) + root.addArrangedSubview(slider); slider.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true; rpmSlider = slider + let range = NSStackView(views: [NativeInterface.label("0 RPM", size: 10, color: .secondaryLabelColor), NSView(), NativeInterface.label("\(maxRPM) RPM", size: 10, color: .secondaryLabelColor)]) + root.addArrangedSubview(range); range.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + root.setCustomSpacing(3, after: slider) + let control = NSSegmentedControl(labels: [appL10n("连续", "Continuous"), appL10n("档位", "Stepped")], trackingMode: .selectOne, target: self, action: #selector(controlModeChanged)) + control.controlSize = .small; control.font = .systemFont(ofSize: 12) + let modeRow = NSStackView(views: [NativeInterface.label(appL10n("调节方式", "Adjustment"), size: 12, color: .secondaryLabelColor), NSView(), control]) + root.addArrangedSubview(modeRow); modeRow.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true; controlModeSegmentedControl = control + let step = NativeInterface.label("", size: 11, color: .secondaryLabelColor); root.addArrangedSubview(step); stepHintLabel = step + let status = NativeInterface.label(appL10n("✓ 已应用", "✓ Applied"), size: 11, color: .systemGreen, wrapping: true) + status.alphaValue = 0; applyStatusLabel = status + let cancelButton = NativeInterface.button(appL10n("取消", "Cancel"), target: self, action: #selector(cancel)); cancelButton.keyEquivalent = "\u{1b}" + completionButton = cancelButton + let apply = NativeInterface.button(appL10n("应用", "Apply"), target: self, action: #selector(applySpeed)); apply.keyEquivalent = "\r" + let footer = NSStackView(views: [status, NSView(), cancelButton, apply]); footer.spacing = 8 + root.addArrangedSubview(footer); footer.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), - titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - - modeLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), - modeLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), - - controlModeSegmentedControl.centerYAnchor.constraint(equalTo: modeLabel.centerYAnchor), - controlModeSegmentedControl.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - - rpmSlider.topAnchor.constraint(equalTo: modeLabel.bottomAnchor, constant: 14), - rpmSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - rpmSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - - rpmLabel.topAnchor.constraint(equalTo: rpmSlider.bottomAnchor, constant: 10), - rpmLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - - rpmExplanationLabel.topAnchor.constraint(equalTo: rpmLabel.bottomAnchor, constant: 6), - rpmExplanationLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - rpmExplanationLabel.widthAnchor.constraint(lessThanOrEqualTo: contentView.widthAnchor, constant: -70), - - rpmHelpButton.centerYAnchor.constraint(equalTo: rpmExplanationLabel.centerYAnchor), - rpmHelpButton.leadingAnchor.constraint(equalTo: rpmExplanationLabel.trailingAnchor, constant: 6), - - stepHintLabel.topAnchor.constraint(equalTo: rpmExplanationLabel.bottomAnchor, constant: 6), - stepHintLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - - applyStatusLabel.topAnchor.constraint(equalTo: stepHintLabel.bottomAnchor, constant: 6), - applyStatusLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - - applyButton.topAnchor.constraint(equalTo: applyStatusLabel.bottomAnchor, constant: 12), - applyButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: -50), - - cancelButton.topAnchor.constraint(equalTo: applyStatusLabel.bottomAnchor, constant: 12), - cancelButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 50) + root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 22), root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), root.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -22) ]) - - self.rpmSlider = rpmSlider - self.rpmLabel = rpmLabel - self.controlModeSegmentedControl = controlModeSegmentedControl - self.stepHintLabel = stepHintLabel - self.applyStatusLabel = applyStatusLabel } private func boundedRPM(_ rpm: Int) -> Int { @@ -4836,25 +4745,28 @@ class SpeedSettingWindowController: NSWindowController { let isStepped = (controlMode == .stepped) stepHintLabel?.stringValue = isStepped ? appL10n("步进:\(stepRPM) RPM", "Step: \(stepRPM) RPM") : "" stepHintLabel?.isHidden = !isStepped + window?.setContentSize(NSSize(width: 400, height: isStepped ? 310 : 280)) } private func updateSliderDisplay() { let effectiveRPM = (controlMode == .stepped) ? snappedRPM(for: currentRPM) : boundedRPM(currentRPM) currentRPM = effectiveRPM rpmSlider?.doubleValue = Double(effectiveRPM) - rpmLabel?.stringValue = "\(effectiveRPM) RPM" + rpmLabel?.attributedStringValue = NativeInterface.rpm(effectiveRPM) updateModeUI() } @objc func sliderChanged() { + completionButton?.title = appL10n("取消", "Cancel") guard let slider = rpmSlider else { return } let rpm = effectiveRPM(for: slider.doubleValue) currentRPM = rpm slider.doubleValue = Double(rpm) - rpmLabel?.stringValue = "\(rpm) RPM" + rpmLabel?.attributedStringValue = NativeInterface.rpm(rpm) } @objc private func controlModeChanged() { + completionButton?.title = appL10n("取消", "Cancel") controlMode = (controlModeSegmentedControl?.selectedSegment == 1) ? .stepped : .continuous updateSliderDisplay() } @@ -4889,7 +4801,13 @@ class SpeedSettingWindowController: NSWindowController { newConfig.manualRPM = currentRPM newConfig.manualRPMControlMode = controlMode.rawValue newConfig.manualRPMStep = stepRPM - ConfigManager.shared.saveConfig(newConfig) + guard ConfigManager.shared.saveConfig(newConfig) else { + applyStatusResetWorkItem?.cancel() + applyStatusLabel?.stringValue = appL10n("设置未保存,请重试。", "Settings were not saved. Please try again.") + applyStatusLabel?.textColor = .systemRed + applyStatusLabel?.alphaValue = 1 + return + } MenuBarManager.shared.setFanCurve(newConfig.curve) showApplyFeedback() @@ -4903,13 +4821,17 @@ class SpeedSettingWindowController: NSWindowController { applyStatusResetWorkItem?.cancel() guard let label = applyStatusLabel else { return } + completionButton?.title = appL10n("完成", "Done") + label.stringValue = appL10n("✓ 已应用", "✓ Applied") + label.textColor = .systemGreen label.alphaValue = 1.0 let workItem = DispatchWorkItem { [weak label] in guard let label else { return } NSAnimationContext.runAnimationGroup { context in context.duration = 0.25 - label.animator().alphaValue = 0 + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { label.alphaValue = 0 } + else { label.animator().alphaValue = 0 } } } applyStatusResetWorkItem = workItem @@ -4919,6 +4841,8 @@ class SpeedSettingWindowController: NSWindowController { @MainActor class FeedbackWindowController: NSWindowController { + private var submittedSuccessfully = false + private var closeAfterSuccess: DispatchWorkItem? private var contentTextView: NSTextView? private var emailField: NSTextField? private var sendButton: NSButton? @@ -4944,6 +4868,19 @@ class FeedbackWindowController: NSWindowController { fatalError("init(coder:) has not been implemented") } + override func showWindow(_ sender: Any?) { + if submittedSuccessfully { + closeAfterSuccess?.cancel() + closeAfterSuccess = nil + contentTextView?.string = "" + sendButton?.isEnabled = true + sendButton?.title = appL10n("发送", "Send") + statusLabel?.alphaValue = 0 + submittedSuccessfully = false + } + super.showWindow(sender) + } + private func setupUI() { guard let contentView = window?.contentView else { return } @@ -4989,15 +4926,22 @@ class FeedbackWindowController: NSWindowController { let sendButton = NSButton(title: appL10n("发送", "Send"), target: self, action: #selector(sendFeedback)) sendButton.bezelStyle = .rounded sendButton.keyEquivalent = "\r" + sendButton.keyEquivalentModifierMask = [.command] + sendButton.toolTip = appL10n("发送 · ⌘↩", "Send · ⌘↩") sendButton.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(sendButton) self.sendButton = sendButton + let cancelButton = NativeInterface.button(appL10n("关闭", "Close"), target: self, action: #selector(closeFeedbackWindow)) + cancelButton.keyEquivalent = "\u{1b}"; cancelButton.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(cancelButton) + let shortcut = NativeInterface.label("⌘↩", size: 11, color: .secondaryLabelColor) + shortcut.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(shortcut) + // 状态标签 - let statusLabel = NSTextField(labelWithString: "") + let statusLabel = NSTextField(wrappingLabelWithString: "") statusLabel.textColor = .systemGreen statusLabel.font = .systemFont(ofSize: 12, weight: .medium) - statusLabel.alignment = .center + statusLabel.alignment = .left statusLabel.alphaValue = 0 statusLabel.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview(statusLabel) @@ -5013,7 +4957,7 @@ class FeedbackWindowController: NSWindowController { scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), scrollView.heightAnchor.constraint(equalToConstant: 120), - emailLabel.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 12), + emailLabel.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 12), emailLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), emailField.topAnchor.constraint(equalTo: emailLabel.bottomAnchor, constant: 4), @@ -5021,15 +4965,22 @@ class FeedbackWindowController: NSWindowController { emailField.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), emailField.heightAnchor.constraint(equalToConstant: 24), - statusLabel.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 10), - statusLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + statusLabel.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: 6), + statusLabel.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + statusLabel.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), - sendButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 8), - sendButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + sendButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 16), + sendButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + cancelButton.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -8), + cancelButton.centerYAnchor.constraint(equalTo: sendButton.centerYAnchor), + shortcut.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + shortcut.centerYAnchor.constraint(equalTo: sendButton.centerYAnchor), sendButton.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16), ]) } + @objc private func closeFeedbackWindow() { window?.close() } + @objc private func sendFeedback() { let content = contentTextView?.string.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !content.isEmpty else { @@ -5038,7 +4989,7 @@ class FeedbackWindowController: NSWindowController { } guard FeedbackService.shared.canSend() else { - showStatus(appL10n("服务器压力大,请一小时后再试", "Server busy, please try again in an hour"), isError: true) + showStatus(appL10n("每小时最多发送5次,请稍后再试。", "You can send up to 5 messages per hour. Please try again later."), isError: true) return } @@ -5046,14 +4997,19 @@ class FeedbackWindowController: NSWindowController { sendButton?.isEnabled = false sendButton?.title = appL10n("发送中...", "Sending...") + statusLabel?.alphaValue = 0 - Task { + Task { [self] in let success = await FeedbackService.shared.sendFeedback(content: content, email: email) if success { - showStatus(appL10n("收到!我会尽快回复你", "Got it! I'll reply soon"), isError: false) - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + submittedSuccessfully = true + sendButton?.title = appL10n("已送达", "Delivered") + showStatus(email.isEmpty ? appL10n("✓ 收到啦,感谢你的反馈!", "✓ Received. Thanks for your feedback!") : appL10n("✓ 收到啦,我会通过邮箱回复你。", "✓ Received. I'll reply by email."), isError: false) + let close = DispatchWorkItem { [weak self] in self?.window?.close() } + closeAfterSuccess = close + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: close) } else { showStatus(appL10n("发送失败,请稍后再试", "Failed, please try later"), isError: true) sendButton?.isEnabled = true @@ -5132,6 +5088,7 @@ final class FeedbackService: @unchecked Sendable { @MainActor class SafetyFloorWindowController: NSWindowController { + private var completionButton: NSButton? private var currentRPM: Int = 0 private let minRPM = 2000 private let maxRPM: Int @@ -5145,7 +5102,7 @@ class SafetyFloorWindowController: NSWindowController { self.maxRPM = max(maxRPM, minRPM) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 430, height: 190), + contentRect: NSRect(x: 0, y: 0, width: 400, height: 270), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -5166,81 +5123,34 @@ class SafetyFloorWindowController: NSWindowController { private func setupUI() { guard let contentView = window?.contentView else { return } - - let titleLabel = NSTextField(labelWithString: appL10n("危险温度(95℃)时的最低兜底转速", "Minimum floor RPM at critical temperature (95°C)")) - titleLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(titleLabel) - - let hintLabel = NSTextField(labelWithString: appL10n("仅作托底,不会压低用户曲线已经给出的更高转速", "Only a safety floor; never caps higher RPM requested by your curve")) - hintLabel.textColor = .secondaryLabelColor - hintLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(hintLabel) - - let rpmSlider = NSSlider() - rpmSlider.minValue = Double(minRPM) - rpmSlider.maxValue = Double(maxRPM) - rpmSlider.doubleValue = Double(currentRPM) - rpmSlider.target = self - rpmSlider.action = #selector(sliderChanged) - rpmSlider.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(rpmSlider) - - let rpmLabel = NSTextField(labelWithString: "\(currentRPM) RPM") - rpmLabel.font = .monospacedDigitSystemFont(ofSize: 15, weight: .medium) - rpmLabel.alignment = .center - rpmLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(rpmLabel) - - let applyStatusLabel = NSTextField(labelWithString: appL10n("✓ 已应用", "✓ Applied")) - applyStatusLabel.textColor = .systemGreen - applyStatusLabel.font = .systemFont(ofSize: 12, weight: .medium) - applyStatusLabel.alignment = .center - applyStatusLabel.alphaValue = 0 - applyStatusLabel.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(applyStatusLabel) - - let applyButton = NSButton(title: appL10n("应用", "Apply"), target: self, action: #selector(applyValue)) - applyButton.bezelStyle = .rounded - applyButton.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(applyButton) - - let cancelButton = NSButton(title: appL10n("取消", "Cancel"), target: self, action: #selector(cancel)) - cancelButton.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(cancelButton) - + let root = NSStackView(); root.orientation = .vertical; root.alignment = .centerX; root.spacing = 16 + root.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(root) + let title = NativeInterface.label(appL10n("最低安全转速", "Minimum safety speed"), size: 15, weight: .medium) + root.addArrangedSubview(title); title.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + let value = NSTextField(labelWithString: ""); value.attributedStringValue = NativeInterface.rpm(currentRPM) + root.addArrangedSubview(value); rpmLabel = value + let hint = NativeInterface.label(appL10n("在临界温度(\(Int(FanManager.shared.criticalTemperatureForDisplay))℃)下托底,保留曲线要求的更高转速。", "A floor at \(Int(FanManager.shared.criticalTemperatureForDisplay))°C. Higher speeds requested by your curve are retained."), size: 12, color: .secondaryLabelColor, wrapping: true) + hint.alignment = .center; root.addArrangedSubview(hint); hint.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true + let slider = NSSlider(value: Double(currentRPM), minValue: Double(minRPM), maxValue: Double(maxRPM), target: self, action: #selector(sliderChanged)) + root.addArrangedSubview(slider); slider.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true; rpmSlider = slider + let range = NSStackView(views: [NativeInterface.label("\(minRPM) RPM", size: 10, color: .secondaryLabelColor), NSView(), NativeInterface.label("\(maxRPM) RPM", size: 10, color: .secondaryLabelColor)]) + root.addArrangedSubview(range); range.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true; root.setCustomSpacing(3, after: slider) + let status = NativeInterface.label(appL10n("✓ 已应用", "✓ Applied"), size: 11, color: .systemGreen, wrapping: true) + status.alphaValue = 0; applyStatusLabel = status + let cancelButton = NativeInterface.button(appL10n("取消", "Cancel"), target: self, action: #selector(cancel)); cancelButton.keyEquivalent = "\u{1b}"; completionButton = cancelButton + let apply = NativeInterface.button(appL10n("应用", "Apply"), target: self, action: #selector(applyValue)); apply.keyEquivalent = "\r" + let footer = NSStackView(views: [status, NSView(), cancelButton, apply]); footer.spacing = 8 + root.addArrangedSubview(footer); footer.widthAnchor.constraint(equalTo: root.widthAnchor).isActive = true NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 18), - titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - - hintLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), - hintLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), - - rpmSlider.topAnchor.constraint(equalTo: hintLabel.bottomAnchor, constant: 14), - rpmSlider.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), - rpmSlider.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), - - rpmLabel.topAnchor.constraint(equalTo: rpmSlider.bottomAnchor, constant: 10), - rpmLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - - applyStatusLabel.topAnchor.constraint(equalTo: rpmLabel.bottomAnchor, constant: 6), - applyStatusLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), - - applyButton.topAnchor.constraint(equalTo: applyStatusLabel.bottomAnchor, constant: 12), - applyButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: -44), - - cancelButton.centerYAnchor.constraint(equalTo: applyButton.centerYAnchor), - cancelButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 44) + root.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 22), root.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), root.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), root.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -22) ]) - - self.rpmSlider = rpmSlider - self.rpmLabel = rpmLabel - self.applyStatusLabel = applyStatusLabel } @objc private func sliderChanged() { + completionButton?.title = appL10n("取消", "Cancel") guard let slider = rpmSlider else { return } let rpm = Int(slider.doubleValue.rounded()) - rpmLabel?.stringValue = "\(rpm) RPM" + rpmLabel?.attributedStringValue = NativeInterface.rpm(rpm) } @objc private func applyValue() { @@ -5250,7 +5160,13 @@ class SafetyFloorWindowController: NSWindowController { let config = ConfigManager.shared.loadConfig() var newConfig = config newConfig.safetyFloorRPM = rpm - ConfigManager.shared.saveConfig(newConfig) + guard ConfigManager.shared.saveConfig(newConfig) else { + applyStatusResetWorkItem?.cancel() + applyStatusLabel?.stringValue = appL10n("设置未保存,请重试。", "Settings were not saved. Please try again.") + applyStatusLabel?.textColor = .systemRed + applyStatusLabel?.alphaValue = 1 + return + } showApplyFeedback() } @@ -5262,13 +5178,17 @@ class SafetyFloorWindowController: NSWindowController { applyStatusResetWorkItem?.cancel() guard let label = applyStatusLabel else { return } + completionButton?.title = appL10n("完成", "Done") + label.stringValue = appL10n("✓ 已应用", "✓ Applied") + label.textColor = .systemGreen label.alphaValue = 1.0 let workItem = DispatchWorkItem { [weak label] in guard let label else { return } NSAnimationContext.runAnimationGroup { context in context.duration = 0.25 - label.animator().alphaValue = 0 + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { label.alphaValue = 0 } + else { label.animator().alphaValue = 0 } } } applyStatusResetWorkItem = workItem @@ -5419,6 +5339,7 @@ class HelpWindowController: NSWindowController { case diagnostics case faq case donation + case developer func title() -> String { switch self { @@ -5426,6 +5347,7 @@ class HelpWindowController: NSWindowController { case .diagnostics: return appL10n("诊断与支持", "Diagnostics & Support") case .faq: return appL10n("常见问题", "FAQ") case .donation: return appL10n("支持作者", "Support the Author") + case .developer: return appL10n("开发者", "Developer") } } } @@ -5435,6 +5357,10 @@ class HelpWindowController: NSWindowController { private var anonymousStatsCheckbox: NSButton? private var currentSection: Section = .overview private var sidebarButtons: [Section: NSButton] = [:] + private var sidebarStack: NSStackView? + private var betaUpdateCheckbox: NSButton? + private var updateRouteLabel: NSTextField? + private var developerUnlockCounter = DeveloperModeUnlockCounter() private var appHeaderView: NSView? private var headerSeparatorView: NSView? private var sectionContainerView: NSView? @@ -5444,12 +5370,12 @@ class HelpWindowController: NSWindowController { private var qrImageView: AdaptiveQRCodeImageView? private var qrCaptionLabel: NSTextField? private var adaptiveCards: [AdaptiveCardView] = [] - private let sidebarWidth: CGFloat = 176 - private let contentWidth: CGFloat = 600 + private let sidebarWidth: CGFloat = 164 + private let contentWidth: CGFloat = 488 init() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 720, height: 500), + contentRect: NSRect(x: 0, y: 0, width: 700, height: 500), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false @@ -5461,6 +5387,10 @@ class HelpWindowController: NSWindowController { applyWindowMaterialStyle(window) setupUI() select(section: .overview) + NotificationCenter.default.addObserver( + self, selector: #selector(helpWindowWillClose(_:)), + name: NSWindow.willCloseNotification, object: window + ) DistributedNotificationCenter.default().addObserver( self, selector: #selector(systemAppearanceChanged(_:)), @@ -5474,12 +5404,13 @@ class HelpWindowController: NSWindowController { } deinit { + NotificationCenter.default.removeObserver(self) DistributedNotificationCenter.default().removeObserver(self) } private func makeSectionTitle(_ text: String) -> NSTextField { let label = NSTextField(labelWithString: text) - label.font = .systemFont(ofSize: 18, weight: .semibold) + label.font = .systemFont(ofSize: 20, weight: .semibold) return label } @@ -5490,11 +5421,7 @@ class HelpWindowController: NSWindowController { } private func makeBodyLabel(_ text: String) -> NSTextField { - let label = NSTextField(labelWithString: text) - label.maximumNumberOfLines = 0 - label.lineBreakMode = .byWordWrapping - label.textColor = .secondaryLabelColor - return label + NativeInterface.label(text, size: 12, color: .secondaryLabelColor, wrapping: true) } private func makeSymbolView(_ name: String, pointSize: CGFloat, weight: NSFont.Weight = .regular) -> NSImageView { @@ -5514,7 +5441,7 @@ class HelpWindowController: NSWindowController { let root = NSView() let stack = NSStackView() stack.orientation = .vertical - stack.spacing = 18 + stack.spacing = 14 stack.translatesAutoresizingMaskIntoConstraints = false root.addSubview(stack) @@ -5536,201 +5463,83 @@ class HelpWindowController: NSWindowController { } private func makeSidebarButton(for section: Section) -> NSButton { - let button = NSButton(title: section.title(), target: self, action: #selector(sidebarButtonClicked(_:))) - button.bezelStyle = .regularSquare - button.isBordered = false - button.setButtonType(.momentaryChange) - button.font = .systemFont(ofSize: 15, weight: .semibold) - button.alignment = .center - button.contentTintColor = .labelColor - button.wantsLayer = true - button.layer?.cornerRadius = 13 + let title: String + switch section { + case .diagnostics: title = appL10n("诊断与支持", "Support") + case .donation: title = appL10n("支持作者", "Support Author") + default: title = section.title() + } + let symbols = ["info.circle", "wrench.and.screwdriver", "questionmark.circle", "heart", "hammer"] + let button = NSButton(title: (section == .diagnostics ? " " : " ") + title, target: self, action: #selector(sidebarButtonClicked(_:))) + button.image = NSImage(systemSymbolName: symbols[section.rawValue], accessibilityDescription: nil)?.withSymbolConfiguration(.init(pointSize: 14, weight: .regular)) + button.image?.size = NSSize(width: 15, height: 15) + button.imageScaling = .scaleProportionallyDown + button.imagePosition = .imageLeading + button.bezelStyle = .regularSquare; button.isBordered = false + button.setButtonType(.momentaryChange); button.font = .systemFont(ofSize: 14) + button.alignment = .left; button.contentTintColor = .secondaryLabelColor + button.wantsLayer = true; button.layer?.cornerRadius = 6 button.translatesAutoresizingMaskIntoConstraints = false - button.heightAnchor.constraint(equalToConstant: 44).isActive = true - button.widthAnchor.constraint(equalToConstant: 132).isActive = true + button.heightAnchor.constraint(equalToConstant: 28).isActive = true + button.widthAnchor.constraint(equalToConstant: sidebarWidth - 24).isActive = true button.identifier = NSUserInterfaceItemIdentifier("sidebar-\(section.rawValue)") + button.setAccessibilityLabel(section.title()) return button } private func updateSidebarSelection() { for (section, button) in sidebarButtons { let isSelected = section == currentSection - button.contentTintColor = isSelected ? .white : .labelColor + button.contentTintColor = .labelColor + button.font = .systemFont(ofSize: 14, weight: isSelected ? .medium : .regular) button.layer?.backgroundColor = isSelected - ? NSColor.systemRed.cgColor + ? NSColor.controlAccentColor.withAlphaComponent(0.12).cgColor : NSColor.clear.cgColor } } private func setupUI() { guard let contentView = window?.contentView else { return } - - let splitView = NSSplitView() - splitView.isVertical = true - splitView.dividerStyle = .thin - splitView.translatesAutoresizingMaskIntoConstraints = false - contentView.addSubview(splitView) - - NSLayoutConstraint.activate([ - splitView.topAnchor.constraint(equalTo: contentView.topAnchor), - splitView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - splitView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - splitView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) - ]) - - let sidebarContainer = NSView() - sidebarContainer.translatesAutoresizingMaskIntoConstraints = false - sidebarContainer.widthAnchor.constraint(equalToConstant: sidebarWidth).isActive = true - splitView.addArrangedSubview(sidebarContainer) - - let sidebarBackground = NSVisualEffectView() - sidebarBackground.material = .sidebar - sidebarBackground.blendingMode = .withinWindow - sidebarBackground.state = .active - sidebarBackground.translatesAutoresizingMaskIntoConstraints = false - sidebarContainer.addSubview(sidebarBackground) - NSLayoutConstraint.activate([ - sidebarBackground.topAnchor.constraint(equalTo: sidebarContainer.topAnchor), - sidebarBackground.leadingAnchor.constraint(equalTo: sidebarContainer.leadingAnchor), - sidebarBackground.trailingAnchor.constraint(equalTo: sidebarContainer.trailingAnchor), - sidebarBackground.bottomAnchor.constraint(equalTo: sidebarContainer.bottomAnchor) - ]) - - let sidebarStack = NSStackView() - sidebarStack.orientation = .vertical - sidebarStack.alignment = .centerX - sidebarStack.spacing = 12 - sidebarStack.translatesAutoresizingMaskIntoConstraints = false - sidebarContainer.addSubview(sidebarStack) - NSLayoutConstraint.activate([ - sidebarStack.centerXAnchor.constraint(equalTo: sidebarContainer.centerXAnchor), - sidebarStack.centerYAnchor.constraint(equalTo: sidebarContainer.centerYAnchor), - sidebarStack.leadingAnchor.constraint(greaterThanOrEqualTo: sidebarContainer.leadingAnchor, constant: 12), - sidebarStack.trailingAnchor.constraint(lessThanOrEqualTo: sidebarContainer.trailingAnchor, constant: -12) - ]) - - var buttons: [Section: NSButton] = [:] - for section in Section.allCases { - let button = makeSidebarButton(for: section) - sidebarStack.addArrangedSubview(button) - buttons[section] = button - } - let rightContainer = NSView() - rightContainer.translatesAutoresizingMaskIntoConstraints = false - splitView.addArrangedSubview(rightContainer) - - let rightRoot = NSStackView() - rightRoot.orientation = .vertical - rightRoot.spacing = 12 - rightRoot.translatesAutoresizingMaskIntoConstraints = false - rightContainer.addSubview(rightRoot) - NSLayoutConstraint.activate([ - rightRoot.topAnchor.constraint(equalTo: rightContainer.topAnchor, constant: 14), - rightRoot.leadingAnchor.constraint(equalTo: rightContainer.leadingAnchor, constant: 18), - rightRoot.trailingAnchor.constraint(equalTo: rightContainer.trailingAnchor, constant: -18), - rightRoot.bottomAnchor.constraint(equalTo: rightContainer.bottomAnchor, constant: -14) - ]) - - let appHeader = NSStackView() - appHeader.orientation = .horizontal - appHeader.alignment = .centerY - appHeader.spacing = 10 - - let iconView = NSImageView() - iconView.image = NSApp.applicationIconImage - iconView.imageScaling = .scaleProportionallyUpOrDown - iconView.wantsLayer = true - iconView.layer?.cornerRadius = 10 - iconView.layer?.masksToBounds = true - iconView.translatesAutoresizingMaskIntoConstraints = false - iconView.widthAnchor.constraint(equalToConstant: 44).isActive = true - iconView.heightAnchor.constraint(equalToConstant: 44).isActive = true - appHeader.addArrangedSubview(iconView) - - let appTitleStack = NSStackView() - appTitleStack.orientation = .vertical - appTitleStack.spacing = 2 - let appTitle = NSTextField(labelWithString: "iFanControl") - appTitle.font = .systemFont(ofSize: 20, weight: .semibold) - let versionLabel = NSTextField(labelWithString: appL10n("版本 \(UpdateService.shared.currentVersionDisplay)", "Version \(UpdateService.shared.currentVersionDisplay)")) - versionLabel.textColor = .secondaryLabelColor - versionLabel.font = .systemFont(ofSize: 12) - appTitleStack.addArrangedSubview(appTitle) - appTitleStack.addArrangedSubview(versionLabel) - appHeader.addArrangedSubview(appTitleStack) - appHeader.addArrangedSubview(NSView()) - rightRoot.addArrangedSubview(appHeader) - - let separator = NSBox() - separator.boxType = .separator - rightRoot.addArrangedSubview(separator) - - let sectionTitleLabel = makeSectionTitle("") - sectionTitleLabel.alignment = .center - rightRoot.addArrangedSubview(sectionTitleLabel) - - let sectionScroll = NSScrollView() - sectionScroll.drawsBackground = false - sectionScroll.hasVerticalScroller = true - sectionScroll.autohidesScrollers = true - sectionScroll.scrollerStyle = .overlay - sectionScroll.translatesAutoresizingMaskIntoConstraints = false - rightRoot.addArrangedSubview(sectionScroll) - sectionScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true - - let sectionCanvas = NSView() - sectionCanvas.translatesAutoresizingMaskIntoConstraints = false - sectionScroll.documentView = sectionCanvas - - let sectionContent = NSView() - sectionContent.translatesAutoresizingMaskIntoConstraints = false - sectionCanvas.addSubview(sectionContent) + let sidebar = NSVisualEffectView() + sidebar.material = .sidebar; sidebar.blendingMode = .withinWindow; sidebar.state = .active + sidebar.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(sidebar) + let navigation = NSStackView(); navigation.orientation = .vertical; navigation.alignment = .leading; navigation.spacing = 4 + navigation.translatesAutoresizingMaskIntoConstraints = false; sidebar.addSubview(navigation) + let caption = NativeInterface.label("iFanControl", size: 12, weight: .medium, color: .secondaryLabelColor) + navigation.addArrangedSubview(caption) + navigation.setCustomSpacing(12, after: caption) + for section in Section.allCases where section != .developer || UpdatePreferences.shared.developerModeEnabled { + let button = makeSidebarButton(for: section); navigation.addArrangedSubview(button); sidebarButtons[section] = button + } + sidebarStack = navigation + let divider = NSBox(); divider.boxType = .separator; divider.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(divider) + + let content = NSStackView(); content.orientation = .vertical; content.alignment = .leading; content.spacing = 16 + content.translatesAutoresizingMaskIntoConstraints = false; contentView.addSubview(content) + let title = makeSectionTitle(""); content.addArrangedSubview(title); sectionTitleLabel = title + let scroll = NSScrollView(); scroll.drawsBackground = false; scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true; scroll.scrollerStyle = .overlay + content.addArrangedSubview(scroll) + scroll.widthAnchor.constraint(equalTo: content.widthAnchor).isActive = true + let sections = NSStackView(); sections.orientation = .vertical; sections.alignment = .centerX + sections.translatesAutoresizingMaskIntoConstraints = false; scroll.documentView = sections + sections.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor).isActive = true + sections.heightAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.heightAnchor).isActive = true + sectionContainerView = sections NSLayoutConstraint.activate([ - sectionCanvas.leadingAnchor.constraint(equalTo: sectionScroll.contentView.leadingAnchor), - sectionCanvas.trailingAnchor.constraint(equalTo: sectionScroll.contentView.trailingAnchor), - sectionCanvas.topAnchor.constraint(equalTo: sectionScroll.contentView.topAnchor), - sectionCanvas.bottomAnchor.constraint(equalTo: sectionScroll.contentView.bottomAnchor), - sectionCanvas.widthAnchor.constraint(equalTo: sectionScroll.contentView.widthAnchor), - - sectionContent.topAnchor.constraint(equalTo: sectionCanvas.topAnchor), - sectionContent.centerXAnchor.constraint(equalTo: sectionCanvas.centerXAnchor), - sectionContent.widthAnchor.constraint(equalToConstant: contentWidth), - sectionContent.widthAnchor.constraint(lessThanOrEqualTo: sectionCanvas.widthAnchor), - sectionContent.bottomAnchor.constraint(equalTo: sectionCanvas.bottomAnchor) + sidebar.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), sidebar.topAnchor.constraint(equalTo: contentView.topAnchor), sidebar.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth), + navigation.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 24), navigation.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12), + divider.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor), divider.topAnchor.constraint(equalTo: sidebar.topAnchor), divider.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor), divider.widthAnchor.constraint(equalToConstant: 1), + content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: 24), content.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), content.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 24), content.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) ]) - - self.versionLabel = versionLabel - self.sidebarButtons = buttons - self.appHeaderView = appHeader - self.headerSeparatorView = separator - self.sectionContainerView = sectionContent - self.sectionTitleLabel = sectionTitleLabel - - buildSectionViews() - refreshDonationPreview() - updateSidebarSelection() + buildSectionViews(); refreshDonationPreview(); updateSidebarSelection() } private func buildSectionViews() { - guard let container = sectionContainerView else { return } - sectionViews = [ - .overview: makeOverviewSection(), - .diagnostics: makeDiagnosticsSection(), - .faq: makeFAQSection(), - .donation: makeDonationSection() - ] - - for view in sectionViews.values { - view.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(view) - NSLayoutConstraint.activate([ - view.topAnchor.constraint(equalTo: container.topAnchor), - view.leadingAnchor.constraint(equalTo: container.leadingAnchor), - view.trailingAnchor.constraint(equalTo: container.trailingAnchor), - view.bottomAnchor.constraint(equalTo: container.bottomAnchor) - ]) - view.isHidden = true - } + sectionViews = [.overview: makeOverviewSection(), .diagnostics: makeDiagnosticsSection(), .faq: makeFAQSection(), .donation: makeDonationSection()] + for view in sectionViews.values { view.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true } + addDeveloperSectionIfNeeded() } private func makeOverviewSection() -> NSView { @@ -5740,46 +5549,53 @@ class HelpWindowController: NSWindowController { iconView.image = NSApp.applicationIconImage iconView.imageScaling = .scaleProportionallyUpOrDown iconView.wantsLayer = true - iconView.layer?.cornerRadius = 20 + iconView.layer?.cornerRadius = 14 iconView.layer?.masksToBounds = true iconView.translatesAutoresizingMaskIntoConstraints = false - iconView.widthAnchor.constraint(equalToConstant: 108).isActive = true - iconView.heightAnchor.constraint(equalToConstant: 108).isActive = true + iconView.widthAnchor.constraint(equalToConstant: 72).isActive = true + iconView.heightAnchor.constraint(equalToConstant: 72).isActive = true + iconView.identifier = NSUserInterfaceItemIdentifier("developer-unlock-icon") + let unlockClick = NSClickGestureRecognizer(target: self, action: #selector(overviewIconClicked(_:))) + iconView.addGestureRecognizer(unlockClick) stack.addArrangedSubview(iconView) let appName = NSTextField(labelWithString: "iFanControl") - appName.font = .systemFont(ofSize: 36, weight: .bold) + appName.font = .systemFont(ofSize: 26, weight: .semibold) appName.alignment = .center stack.addArrangedSubview(appName) let version = NSTextField(labelWithString: appL10n("版本 \(UpdateService.shared.currentVersionDisplay)", "Version \(UpdateService.shared.currentVersionDisplay)")) - version.font = .systemFont(ofSize: 18, weight: .medium) + version.font = .systemFont(ofSize: 12) version.textColor = .secondaryLabelColor version.alignment = .center stack.addArrangedSubview(version) let intro = makeBodyLabel( appL10n( - "iFanControl 是一款面向 Apple Silicon 设备的轻量风扇控制工具,支持自动曲线与手动转速。", - "iFanControl is a lightweight fan control utility for Apple Silicon devices, supporting auto curves and manual RPM." + "面向 Apple Silicon 的温度监测与风扇控制工具。", + "Temperature monitoring and fan control for Apple Silicon." ) ) - intro.font = .systemFont(ofSize: 14) + intro.font = .systemFont(ofSize: 13) intro.alignment = .center - intro.preferredMaxLayoutWidth = 420 + intro.preferredMaxLayoutWidth = 360 + intro.widthAnchor.constraint(equalToConstant: 360).isActive = true stack.addArrangedSubview(intro) let buttonRow = NSStackView() buttonRow.orientation = .horizontal buttonRow.spacing = 10 - let restartButton = NSButton(title: appL10n("重新启动", "Restart"), target: self, action: #selector(restartApp)) - restartButton.bezelStyle = .rounded let updateButton = NSButton(title: appL10n("检查更新", "Check Updates"), target: self, action: #selector(checkUpdates)) updateButton.bezelStyle = .rounded let githubButton = NSButton(title: "GitHub", target: self, action: #selector(openGitHub)) githubButton.bezelStyle = .rounded - buttonRow.addArrangedSubview(restartButton) + let websiteButton = NSButton(title: appL10n("访问官网", "Visit Website"), target: self, action: #selector(openWebsite)) + websiteButton.bezelStyle = .rounded + websiteButton.identifier = NSUserInterfaceItemIdentifier("visit-website") + websiteButton.toolTip = "https://ifancontrol.puremilkchun.top" + // Restart is available with the diagnostic quick checks. buttonRow.addArrangedSubview(updateButton) + buttonRow.addArrangedSubview(websiteButton) buttonRow.addArrangedSubview(githubButton) stack.addArrangedSubview(buttonRow) @@ -5790,7 +5606,9 @@ class HelpWindowController: NSWindowController { ) autoCheck.state = UpdateService.shared.automaticChecksEnabled ? .on : .off automaticUpdateCheckbox = autoCheck - stack.addArrangedSubview(autoCheck) + autoCheck.controlSize = .small; autoCheck.font = .systemFont(ofSize: 12) + let preferences = NSStackView(); preferences.orientation = .vertical; preferences.alignment = .leading; preferences.spacing = 10 + preferences.addArrangedSubview(autoCheck) let statsCheck = NSButton( checkboxWithTitle: appL10n("匿名统计用户量", "Anonymous user-count stats"), @@ -5801,7 +5619,7 @@ class HelpWindowController: NSWindowController { anonymousStatsCheckbox = statsCheck let statsHelpButton = NSButton(title: "?", target: self, action: #selector(showAnonymousStatsHelp)) - statsHelpButton.bezelStyle = .rounded + statsHelpButton.bezelStyle = .helpButton statsHelpButton.font = .systemFont(ofSize: 11, weight: .semibold) statsHelpButton.setContentHuggingPriority(.required, for: .horizontal) @@ -5812,112 +5630,150 @@ class HelpWindowController: NSWindowController { statsRow.addArrangedSubview(statsCheck) statsRow.addArrangedSubview(statsHelpButton) statsRow.setHuggingPriority(.required, for: .horizontal) - stack.addArrangedSubview(statsRow) + statsCheck.controlSize = .small; statsCheck.font = .systemFont(ofSize: 12) + preferences.addArrangedSubview(statsRow) let statsHint = makeBodyLabel( appL10n( - "仅用于匿名统计有多少人在使用 iFanControl,帮助开发者判断这个软件是不是还活着。不会上传姓名、邮箱、序列号或设备名称。", - "Only used to anonymously count how many people are using iFanControl, so the developer knows the app is still alive. It does not upload your name, email, serial number, or device name." + "匿名统计使用人数,不上传姓名、邮箱、序列号或设备名称。", + "Counts usage anonymously. No name, email, serial number or device name is uploaded." ) ) statsHint.font = .systemFont(ofSize: 11) - statsHint.alignment = .center - statsHint.preferredMaxLayoutWidth = 430 - stack.addArrangedSubview(statsHint) + statsHint.alignment = .left + statsHint.preferredMaxLayoutWidth = 360 + statsHint.widthAnchor.constraint(equalToConstant: 360).isActive = true + preferences.addArrangedSubview(statsHint) + stack.addArrangedSubview(preferences) return view } - private func makeDiagnosticsSection() -> NSView { - let (view, stack) = makeSectionRoot(horizontalCentered: true, verticalCentered: true) - - let supportCard = makeCardView() - supportCard.translatesAutoresizingMaskIntoConstraints = false - supportCard.widthAnchor.constraint(equalToConstant: 410).isActive = true - supportCard.heightAnchor.constraint(equalToConstant: 410).isActive = true - stack.addArrangedSubview(supportCard) - - let supportStack = NSStackView() - supportStack.orientation = .vertical - supportStack.alignment = .centerX - supportStack.spacing = 18 - supportStack.translatesAutoresizingMaskIntoConstraints = false - supportCard.addSubview(supportStack) - NSLayoutConstraint.activate([ - supportStack.centerXAnchor.constraint(equalTo: supportCard.centerXAnchor), - supportStack.centerYAnchor.constraint(equalTo: supportCard.centerYAnchor), - supportStack.leadingAnchor.constraint(greaterThanOrEqualTo: supportCard.leadingAnchor, constant: 28), - supportStack.trailingAnchor.constraint(lessThanOrEqualTo: supportCard.trailingAnchor, constant: -28), - supportStack.topAnchor.constraint(greaterThanOrEqualTo: supportCard.topAnchor, constant: 28), - supportStack.bottomAnchor.constraint(lessThanOrEqualTo: supportCard.bottomAnchor, constant: -28) - ]) - - let supportTitle = NSTextField(labelWithString: appL10n("先导出诊断包,再发邮件给我们。", "Export diagnostics first, then email us.")) - supportTitle.font = .systemFont(ofSize: 18, weight: .semibold) - supportTitle.alignment = .center - supportTitle.maximumNumberOfLines = 0 - supportTitle.lineBreakMode = .byWordWrapping - supportTitle.preferredMaxLayoutWidth = 300 - supportStack.addArrangedSubview(supportTitle) + private func addDeveloperSectionIfNeeded() { + guard UpdatePreferences.shared.developerModeEnabled, + sectionViews[.developer] == nil, sectionContainerView != nil else { return } + if sidebarButtons[.developer] == nil { + let button = makeSidebarButton(for: .developer) + sidebarButtons[.developer] = button + sidebarStack?.addArrangedSubview(button) + } - let supportHint = makeBodyLabel( - appL10n( - "我们会结合日志更快定位问题。若只是小异常,也可以先试试重新启动或检查更新。", - "Logs help us diagnose issues faster. For smaller problems, you can also try restarting the app or checking for updates first." - ) + let (view, stack) = makeSectionRoot(horizontalCentered: true, verticalCentered: true) + stack.alignment = .centerX + let betaCheck = NSButton( + checkboxWithTitle: appL10n("接收 Beta 更新", "Receive Beta Updates"), + target: self, action: #selector(toggleBetaUpdates(_:)) ) - supportHint.font = .systemFont(ofSize: 14) - supportHint.preferredMaxLayoutWidth = 300 - supportHint.alignment = .center - supportStack.addArrangedSubview(supportHint) - - let primaryButtons = NSStackView() - primaryButtons.orientation = .horizontal - primaryButtons.spacing = 10 - primaryButtons.alignment = .centerY - - let exportButton = NSButton(title: appL10n("导出诊断包", "Export Diagnostics"), target: self, action: #selector(exportDiagnostics)) - exportButton.bezelStyle = .rounded - let contactButton = NSButton(title: appL10n("联系支持", "Contact Support"), target: self, action: #selector(contactSupport)) - contactButton.bezelStyle = .rounded - let uninstallButton = NSButton(title: appL10n("完整卸载", "Full Uninstall"), target: self, action: #selector(uninstallControlComponents)) - uninstallButton.bezelStyle = .rounded - primaryButtons.addArrangedSubview(exportButton) - primaryButtons.addArrangedSubview(contactButton) - primaryButtons.addArrangedSubview(uninstallButton) - supportStack.addArrangedSubview(primaryButtons) - - let helperHint = NSTextField(labelWithString: appL10n("也可以先做这两步快速自检:", "You can also try these quick checks first:")) - helperHint.font = .systemFont(ofSize: 12, weight: .medium) - helperHint.textColor = .tertiaryLabelColor - helperHint.alignment = .center - supportStack.addArrangedSubview(helperHint) - - let secondaryButtons = NSStackView() - secondaryButtons.orientation = .horizontal - secondaryButtons.spacing = 10 - secondaryButtons.alignment = .centerY - let restartButton = NSButton(title: appL10n("重新启动", "Restart"), target: self, action: #selector(restartApp)) - restartButton.bezelStyle = .rounded + betaCheck.identifier = NSUserInterfaceItemIdentifier("beta-update-checkbox") + stack.addArrangedSubview(betaCheck) + betaUpdateCheckbox = betaCheck + + let routeLabel = NSTextField(labelWithString: "") + routeLabel.textColor = .secondaryLabelColor + stack.addArrangedSubview(routeLabel) + updateRouteLabel = routeLabel + + let hint = makeBodyLabel(appL10n( + "启用后,自动与手动检查都会使用 Beta 通道。\n切回正式通道只改变后续更新路线,当前安装版本不会改变。", + "When enabled, automatic and manual checks use the Beta channel.\nSwitching to Stable changes future updates; your installed version stays the same." + )) + hint.alignment = .center + hint.preferredMaxLayoutWidth = 360 + hint.widthAnchor.constraint(equalToConstant: 360).isActive = true + stack.addArrangedSubview(hint) + let updateButton = NSButton(title: appL10n("检查更新", "Check Updates"), target: self, action: #selector(checkUpdates)) updateButton.bezelStyle = .rounded - let openLogButton = NSButton(title: appL10n("打开日志目录", "Open Log Folder"), target: self, action: #selector(openLogFolder)) - openLogButton.bezelStyle = .rounded - secondaryButtons.addArrangedSubview(restartButton) - secondaryButtons.addArrangedSubview(updateButton) - secondaryButtons.addArrangedSubview(openLogButton) - supportStack.addArrangedSubview(secondaryButtons) + stack.addArrangedSubview(updateButton) + + let exitButton = NSButton(title: appL10n("退出开发者模式", "Exit Developer Mode"), target: self, action: #selector(exitDeveloperMode)) + exitButton.bezelStyle = .rounded + exitButton.identifier = NSUserInterfaceItemIdentifier("exit-developer-mode") + stack.addArrangedSubview(exitButton) + + sectionViews[.developer] = view + view.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true + refreshDeveloperSettings() + } + + private func refreshDeveloperSettings() { + let preferences = UpdatePreferences.shared + betaUpdateCheckbox?.state = preferences.betaEnabled ? .on : .off + updateRouteLabel?.stringValue = preferences.betaEnabled + ? appL10n("当前更新通道:Beta", "Update Channel: Beta") + : appL10n("当前更新通道:正式版", "Update Channel: Stable") + } + + @objc private func overviewIconClicked(_ sender: NSClickGestureRecognizer) { + guard !UpdatePreferences.shared.developerModeEnabled, + developerUnlockCounter.registerClick() else { return } + UpdatePreferences.shared.unlockDeveloperMode() + addDeveloperSectionIfNeeded() + select(section: .developer) + } + + @objc private func helpWindowWillClose(_ notification: Notification) { + developerUnlockCounter.reset() + } + + @objc private func toggleBetaUpdates(_ sender: NSButton) { + UpdatePreferences.shared.setBetaEnabled(sender.state == .on) + refreshDeveloperSettings() + } + + @objc private func exitDeveloperMode() { + UpdatePreferences.shared.exitDeveloperMode() + developerUnlockCounter.reset() + select(section: .overview) + sectionViews.removeValue(forKey: .developer)?.removeFromSuperview() + if let button = sidebarButtons.removeValue(forKey: .developer) { + sidebarStack?.removeArrangedSubview(button) + button.removeFromSuperview() + } + betaUpdateCheckbox = nil + updateRouteLabel = nil + } + + private func makeDiagnosticsSection() -> NSView { + let (view, stack) = makeSectionRoot() + stack.alignment = .leading + func section(_ title: String, _ detail: String) { + let heading = NSTextField(labelWithString: title) + heading.font = .systemFont(ofSize: 13, weight: .medium) + stack.addArrangedSubview(heading) + let body = makeBodyLabel(detail) + body.preferredMaxLayoutWidth = contentWidth + body.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true + stack.addArrangedSubview(body) + } + func buttons(_ entries: [(String, Selector)]) { + let row = NSStackView(); row.spacing = 10 + for (title, action) in entries { + let button = NSButton(title: title, target: self, action: action) + button.bezelStyle = .rounded; button.controlSize = .small; button.font = .systemFont(ofSize: 12); row.addArrangedSubview(button) + } + stack.addArrangedSubview(row) + } + section(appL10n("先做一次快速自检", "Start with a Quick Check"), + appL10n("小异常可以先重启应用,或检查是否有可用更新。", "For a minor issue, try restarting the app or checking for an update.")) + buttons([(appL10n("重新启动", "Restart"), #selector(restartApp)), (appL10n("检查更新", "Check Updates"), #selector(checkUpdates))]) + let separator = NSBox(); separator.boxType = .separator + separator.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true; stack.addArrangedSubview(separator) + section(appL10n("联系支持", "Contact support"), + appL10n("诊断包会保存在本机并在访达中显示。你可以检查内容,再自行附到支持邮件中。", "The diagnostic ZIP is saved locally and revealed in Finder. Review it, then attach it to your support email.")) + buttons([(appL10n("导出诊断包", "Export Diagnostics"), #selector(exportDiagnostics)), (appL10n("联系支持", "Contact Support"), #selector(contactSupport))]) + buttons([(appL10n("打开日志目录", "Open Log Folder"), #selector(openLogFolder))]) + let separator2 = NSBox(); separator2.boxType = .separator + separator2.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true; stack.addArrangedSubview(separator2) + section(appL10n("移除 iFanControl", "Remove iFanControl"), + appL10n("完整卸载会移除应用、控制组件和本机设置。开始前会再次确认删除范围。", "A full uninstall removes the app, control components and local settings. You will confirm the scope before removal.")) + buttons([(appL10n("完整卸载…", "Full Uninstall…"), #selector(uninstallControlComponents))]) return view } private func makeFAQSection() -> NSView { let (view, stack) = makeSectionRoot(horizontalCentered: true) - let intro = makeBodyLabel(appL10n("整理了最常遇到的几个问题,方便快速定位设置逻辑。", "A short set of answers for the most common questions.")) - intro.font = .systemFont(ofSize: 14) - intro.preferredMaxLayoutWidth = contentWidth - intro.alignment = .center - stack.addArrangedSubview(intro) let faqItems: [(String, String)] = [ ( @@ -5945,25 +5801,29 @@ class HelpWindowController: NSWindowController { stack.addArrangedSubview(card) let qaStack = NSStackView() + qaStack.alignment = .leading qaStack.orientation = .vertical qaStack.spacing = 8 qaStack.translatesAutoresizingMaskIntoConstraints = false card.addSubview(qaStack) let q = NSTextField(labelWithString: item.0) - q.font = .systemFont(ofSize: 16, weight: .semibold) + q.font = .systemFont(ofSize: 13, weight: .medium) + q.maximumNumberOfLines = 0 + q.lineBreakMode = .byWordWrapping + q.preferredMaxLayoutWidth = contentWidth - 32 qaStack.addArrangedSubview(q) let a = makeBodyLabel(item.1) - a.font = .systemFont(ofSize: 14) - a.preferredMaxLayoutWidth = contentWidth - 48 + a.font = .systemFont(ofSize: 12) + a.preferredMaxLayoutWidth = contentWidth - 32 qaStack.addArrangedSubview(a) NSLayoutConstraint.activate([ - qaStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 18), - qaStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24), - qaStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -24), - qaStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -18) + qaStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14), + qaStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + qaStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + qaStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14) ]) } return view @@ -5981,7 +5841,7 @@ class HelpWindowController: NSWindowController { stack.addArrangedSubview(coffeeIcon) let introTitle = NSTextField(labelWithString: appL10n("请作者喝杯咖啡", "Buy the author a coffee")) - introTitle.font = .systemFont(ofSize: 22, weight: .bold) + introTitle.font = .systemFont(ofSize: 18, weight: .semibold) introTitle.alignment = .center stack.addArrangedSubview(introTitle) @@ -6028,13 +5888,15 @@ class HelpWindowController: NSWindowController { qrImage.widthAnchor.constraint(equalToConstant: 168).isActive = true qrImage.heightAnchor.constraint(equalToConstant: 168).isActive = true - let hint = NSTextField(labelWithString: appL10n("感谢支持,帮助 iFanControl 持续改进。", "Thank you for helping iFanControl keep improving.")) + let hint = NSTextField(wrappingLabelWithString: appL10n("感谢支持,帮助 iFanControl 持续改进。", "Thank you for helping iFanControl keep improving.")) hint.textColor = .secondaryLabelColor hint.font = .systemFont(ofSize: 13) + hint.alignment = .center qrStack.addArrangedSubview(caption) qrStack.addArrangedSubview(qrImage) qrStack.addArrangedSubview(hint) + hint.widthAnchor.constraint(equalTo: qrStack.widthAnchor).isActive = true self.donationMethodControl = methodControl self.qrImageView = qrImage @@ -6047,6 +5909,7 @@ class HelpWindowController: NSWindowController { versionLabel?.stringValue = appL10n("版本 \(UpdateService.shared.currentVersionDisplay)", "Version \(UpdateService.shared.currentVersionDisplay)") automaticUpdateCheckbox?.state = UpdateService.shared.automaticChecksEnabled ? .on : .off anonymousStatsCheckbox?.state = PrivacyStatsService.shared.anonymousStatsEnabled ? .on : .off + refreshDeveloperSettings() refreshDonationPreview() super.showWindow(sender) } @@ -6058,8 +5921,10 @@ class HelpWindowController: NSWindowController { appHeaderView?.isHidden = isOverview headerSeparatorView?.isHidden = isOverview sectionTitleLabel?.isHidden = isOverview - for (key, view) in sectionViews { - view.isHidden = (key != section) + if let container = sectionContainerView as? NSStackView { + for child in container.arrangedSubviews { container.removeArrangedSubview(child); child.removeFromSuperview() } + if let page = sectionViews[section] { page.isHidden = false; container.addArrangedSubview(page) } + container.layoutSubtreeIfNeeded() } updateSidebarSelection() } @@ -6108,6 +5973,11 @@ class HelpWindowController: NSWindowController { NSWorkspace.shared.open(UpdateService.shared.releasesURL) } + @objc private func openWebsite() { + guard let url = URL(string: "https://ifancontrol.puremilkchun.top") else { return } + NSWorkspace.shared.open(url) + } + @objc private func restartApp() { MenuBarManager.shared.restartApp() } diff --git a/docs/development/2.9.10-beta-channel.md b/docs/development/2.9.10-beta-channel.md new file mode 100644 index 0000000..4affac4 --- /dev/null +++ b/docs/development/2.9.10-beta-channel.md @@ -0,0 +1,84 @@ +# 2.9.10:隐藏 Beta 更新入口与 build63 联调 + +日期:2026-10-12。状态:开发者使用本地build62种子包,当前已上线2.9.10 Beta/build63用于在线升级联调;正式线路仍为2.9.9/build52。未创建正式GitHub Release,未向稳定用户发布62/63。 + +## 已确认与实现 + +- 简介页 108pt 大 App 图标在同一次开窗累计点击十次,第九次仍保持普通模式;未解锁时关闭帮助窗口清零。 +- 开发者模式与 Beta 偏好使用独立 UserDefaults,跨重启/升级保留;不写风扇 config.json。 +- 默认正式通道;解锁只显示新的开发者页面,不自动订阅 Beta。在开发者页面单独勾选“接收 Beta 更新”。 +- Beta 开关同时作用于手动检查及既有启动后 30 秒自动检查,保持自动检查开关原语义。 +- 取消 Beta 或退出开发者模式只改变之后的更新路线,不修改当前安装版本、不触发降级。退出会清除 Beta 偏好并重新隐藏开发者入口。 +- 使用同一 UpdateService:manifest → 比较整数 build → 原“立即更新/稍后”提示 → 下载 PKG → SHA256 → 系统安装器 → 正常退出。预下载仍未实现。 +- 检查和下载固定本次通道及修订号,切换路线(包括切走后再切回)后旧结果不弹窗、不打开安装器。下载进行时不重复启动检查/下载。 +- 系统版本号为 2.9.10,build 从 52 加 10 到 62。正式和 Beta 之后共用递增 build,下一构建从大于 62 的编号开始。 + +## 对稳定用户的范围控制 + +默认四个帮助侧栏页面、简介内容及布局保持原样。新开发者页面只有解锁后才构建,不增加普通模式隐藏布局约束。没有对 main.swift 做整体拆分,新增偏好/计数模型独立成文件。 + +对比 HEAD 的实际源码片段,硬件探测/读写、风扇控制、降级、配置、功率/匿名遥测、反馈、菜单栏构建与轮询、曲线编辑模块和 PKG preinstall/postinstall 均未改变。保留正常退出恢复系统风扇控制,不使用强杀 App 更新。 + +未恢复已移除的 24 小时检查节流,未加入周期检查、跳过版本、预下载或其他 3.0 功能。Beta 的独立 feed、包路径与版本标签是新增部分;正式线路继续读取原 `update-manifest-pkg.json`。 + +## 发布契约 + +| 项目 | 正式 | Beta | +| --- | --- | --- | +| feed | update-manifest-pkg.json | update-manifest-beta.json | +| App 身份 | 原 bundle id 与安装目录 | 同一 App,配置保持兼容 | +| 包路径 | iFanControl-macOS.pkg?build=N | 独立带版本/build 文件名 | +| manifest 元数据 | release_channel: stable(兼容省略) | release_channel: beta;可指向 promoted stable | +| 当前版本身份 | 原数字版本/build | 数字版本/build 加 Info.plist IFanControlReleaseChannel: beta | +| 失败 Release 链接 | 保留原 v{version} 拼接 | manifest 可选 release_url,缺失时使用 Releases 首页 | + +Beta 包不能覆盖官网正式包。正式通道拒绝声明为 Beta 的元数据。发布更高 build 的正式版时,Beta feed 同步该正式版的独立副本,保证开发者也能走同一路线进入正式版。两份 native feed 都设置 no-store。 + +legacy `update-manifest.json` 与两份 ZIP 永久固定 2.9.9/build52。Beta 独立测试期间,正式 manifest、正式 PKG、`/download`、官网页面/时间线以及正式 GitHub Release 都保持原版本。 + +## 交付物 + +- 候选包:`packaging/pkg/output/release-candidate/iFanControl-2.9.10.pkg` +- PKG SHA256:`a52adf4e09316fdbe3809b7c70a3d3bbeef8845e48a910142307398561ac7ec6` +- 构建来源与校验记录:同目录 `build-info.json`。 +- 待正式发布通道预览:同目录 `channel-preview/`,含正式/Beta manifest、对应包、缓存头、download handler 及不变的 legacy 副本;**不能整体部署为 Beta 测试线路**。 +- 已上线初始 Beta feed 为 stable 身份的 2.9.10/build62,通过独立 `iFanControl-beta-2.9.10-62.pkg` 副本提供;身份字段与本地种子包一致,不代表正式发布。没有填写尚不存在的 v2.9.10 GitHub Release 链接。 +- 正式发布计划:同目录 `release-plan.json`;实际正式发布时才同步 GitHub、官网 `/download`、两份 native feed 等,正式 build 必须高于测试过的所有 Beta build。 + +## Beta 初始独立部署(2026-10-12,build62) + +- Production:`https://cb249cea.ifan-59w.pages.dev`,完整部署 ID `cb249cea-6cb3-4703-84b2-477f13454a28`,branch main。 +- Beta feed:`https://ifan-59w.pages.dev/update-manifest-beta.json`,2.9.10/build62,no-store。 +- 独立 PKG:`https://ifan-59w.pages.dev/iFanControl-beta-2.9.10-62.pkg?build=62`,SHA256 与本地候选包一致。 +- 根据旧 Production `2f2dccc2-3480-4a94-ace9-e8f37ebe4baf` 的 53 个静态资产哈希构建隔离 staging;52 个匹配本地,线上 schema.sql 与本地不同,取线上原文件发布,保留本地修改。 +- 新 Production 的原 53 个资产哈希全部相同,仅新增 Beta manifest 与独立 PKG;缓存头只新增 Beta 规则。7 个后端函数源码、D1 绑定、环境变量、兼容日期/标记保持原样。 +- Wrangler 上传受连接错误影响;使用同一 Wrangler 编译的 functions bundle 和同一资产清单,经已授权 Cloudflare API 完成部署。 +- 准备、部署和线上校验记录:`packaging/pkg/output/beta-bootstrap-62/`。 +- 线上重新下载 Beta/正式/旧 manifest、Beta/正式 PKG、旧版固定 ZIP 和时间线,字节均与 staging 相同;三个 manifest 均为 JSON/no-store,`/download` HEAD 仍跳转 build52。主页及旧 ZIP 兼容别名的重复下载未在当前网络完成,以 Cloudflare 完整部署资产哈希与旧 Production 完全相同作为保留证据;不宣称它们完成了重新下载。 +- 初始上线时build62订阅Beta后显示已是最新版本;下述build63已上线,种子62现应提示升级63。 + +## Beta build63 联调发布(2026-10-12) + +- 版本2.9.10 Beta/build63:仅递增CFBundleVersion并加入IFanControlReleaseChannel=beta。重新签名后,Mach-O代码签名段之前的字节与62相同,所有资源/安装脚本保留。 +- Production:`https://a0b0d0c8.ifan-59w.pages.dev`,完整ID `a0b0d0c8-ba6c-4102-bf91-53b43b6fccf4`,部署完成于2026-10-12T11:02:21Z。 +- feed使用release_channel=beta,latest_build=63;独立包`iFanControl-beta-2.9.10-63.pkg?build=63`,SHA256 `fd8c59d08c02f1f3a6dcd6c05f8bc2de662d61786b6617bb28b946dec80a7391`。未填写不存在的GitHub Release链接,保留62包。 +- 只上传2个Beta文件,原54个非Beta-feed资产哈希相同;复用上一部署完全相同的functions bundle/routing/headers,运行绑定保持不变。 +- 线上Beta63 PKG重新下载后与候选包字节相同,Beta/正式/旧manifest均JSON/no-store;正式及旧feed仍52,`/download` HEAD仍跳转52。 +- 完成PKG展开/代码签名/脚本/输入哈希验证、更新测试和隔离帮助窗口Beta63版本标识实际截图检查。未执行用户安装或硬件控制。 +- 包、source-record.json、build-info.json及verification.json在`packaging/pkg/output/beta-63/`。下一build大于63,最终正式build高于所有Beta build。 + +## 验证结果与限制 + +通过 debug/release 构建、App/tool 签名、最终 PKG 展开后内容/资源/脚本校验,以及旧通道和待发布新通道的哈希校验。保留原有编译警告,没有顺手修复不相关代码。 + +`bash packaging/pkg/test_updates.sh` 已接入 CI,覆盖: + +- 原字段兼容、PKG 选择、损坏包/非法元数据、SHA256。 +- 默认正式路线、隐藏状态无法启用 Beta、9/10 点击及关窗重置、解锁不订阅、持久偏好、退出、切换来回的请求失效。 +- 真实 UpdateService 的隔离编译夹具:手动/自动语义、“稍后”不下载、正式/Beta 同一下载校验路径、HTTP/哈希失败不启动安装、安装器打开失败不退出、旧检查/下载结果忽略、自动失败安静而手动失败提示。 + +夹具只替换网络、弹窗选择、系统安装器打开、退出及任务完成观测,使用独立 App 偏好域;没有实际执行安装或硬件控制。有效 fixture 校验后确实写出 PKG 路径,打开与退出由测试接口记录,不能据此宣称已完成真实原生安装。 + +原生 UI 通过独立测试 App 加载当前 HelpWindowController,未启动 AppDelegate/菜单/控温循环。使用 CUA 实际点击验证前九次隐藏、第十次解锁、Beta 默认未勾选、勾选后显示通道、退出后隐藏;实际查看中文浅/深色和英文深色截图,并与旧版原生简介布局对照。离屏 cacheDisplay 漏掉部分原生控件的图片未作为最终视觉证据。英文原有较长侧栏名称截断未在本阶段改动。 + +build63已具备真实HTTPS下载和哈希证据,种子62→检查更新→系统安装器安装→再启动→配置/Beta偏好保留待用户联调。本次没有操作 `/Applications`、风扇控制或推送给稳定用户。 diff --git a/docs/development/3.0-preparation.md b/docs/development/3.0-preparation.md new file mode 100644 index 0000000..0928c65 --- /dev/null +++ b/docs/development/3.0-preparation.md @@ -0,0 +1,162 @@ +# 3.0 筹备:2.9.9 基线理解与 Beta 通道方案 + +日期:2026-10-12。基线:`c2d896c`,App bundle 为 `2.9.9 / build 52`。 + +本文件记录首轮只读梳理。随后用户确认四项规则,已实施 2.9.10/build62 本地候选版;具体范围、验证及发布状态见 [2.9.10 Beta 通道交付记录](2.9.10-beta-channel.md)。为缩小首个承载版本的改动,更新服务暂保留在 main.swift,没有同时做机械拆分。 + +本次完成代码阅读、历史追踪和基础验证;没有修改业务代码、替换运行中的 App、执行安装、发布或整理旧文件。下文区分当前事实、已有设计理由和建议;待确认的产品规则不视为已批准的实现。 + +## 阅读范围和证据边界 + +- 完整阅读当前 App 的三个 SPM target:MacFanControl、FanCurveEditor、IOReportBridge,包括全部 Swift/C 源文件。 +- 阅读 Package.swift、CI、App Info.plist、根目录安装/卸载/诊断脚本、PKG 构建/资源渲染/安装脚本/manifest 测试/通道校验。 +- 阅读 CLAUDE.md、PROJECT_MEMORY.md、PROJECT_TIMELINE.md、CODE_AUDIT.md、中英文 README、已有经验记录,并追踪影响当前架构的提交。 +- 阅读相邻官网的发布规范、两份 manifest、缓存配置、下载 handler、版本展示,以及心跳和后台版本解析的相关代码。 +- 已识别离线热模型实验、UI HTML 实验、历史安装目录及父目录旧工程;这些不在当前 SPM target 中,本次没有对所有实验和整个网站/后台逐行审查。 +- `kentsmc` 是仓库内的 arm64 二进制,没有随本工程提供源码或明确版本映射;不能把理解 App 的调用契约表述为审阅了该二进制的全部实现。 +- 对没有提交说明、需求记录或注释支撑的原始编写理由,只能作推断,不能声称已知道作者的全部意图。 + +## 当前架构及设计理由 + +| 部分 | 当前职责 | 已确认的理由或约束 | +| --- | --- | --- | +| AppDelegate | 启动配置、菜单栏、解锁风扇、2 秒控制循环、启动更新检查、心跳;正常退出恢复系统风扇控制 | 生命周期是安装更新闭环的一部分。PKG 打开成功后正常 terminate,不能为了更新直接强杀 App | +| FanManager | 风扇/传感器 profile、最大 RPM、曲线插值、平滑下发、命令失败兜底 | 命令 in-flight 时跳过,下一周期重新计算;2 秒节流、50/75/150 RPM 变化阈值、0.3 平滑系数用于减轻调速抖动 | +| BackgroundHardwareReader | 后台串行读取 kentsmc,NSLock 缓存,一次性 snapshot | 主线程控制/UI 周期读缓存;防重入避免积压;全轮失败保留最后成功数据,按时间判断能否继续使用 | +| SensorCatalog | 静态温度 key、类别、显示排序、未知 key 的名称推断 | 静态列表与异步 `kentsmc -l` 动态发现并行。未知候选需两次有效读数且差值不超过 15℃,减少误纳入 | +| 温度源选择 | 最热、固定传感器、CPU 平均、GPU 平均;菜单与控温共用选择函数 | 平均必须按组件分开;手动传感器不可读会回退最热;选定平均组没有读数时返回 nil,进入既有缺失数据处理 | +| 控制降级 | fresh ≤15 秒;cached_stale >15 且 ≤45 秒;expired >45 秒或没有成功记录 | UI 超过 15 秒显示 `--`,控制仍可短时使用缓存;20 秒启动宽限、连续缺失门槛 5;温度缺失先尝试 2200 RPM,命令失败/没有控制能力再交还系统 | +| ConfigManager | 用户配置 JSON、mtime 缓存、硬件范围同步 | 配置目录维持 `Application Support/MacFanControl`,不能因产品名称或目录整理改变迁移契约;mtime 用来感知编辑器写入 | +| FanCurveEditor | FanPoint/Config 类型、默认曲线、曲线视图、三个预设、保存 | 预设切换和重置即时发送通知;拖拽当前曲线后关闭窗口保存。Y 轴方向只改显示和坐标换算。曲线绘图使用平滑路径,控制计算使用线性插值 | +| MenuBarManager | 菜单一次构建、动态更新、状态栏三种展示、窗口入口、登录项 | 2.9.0/2.9.1 的菜单内存和延迟优化应保留;结构改变时允许一次重建,不应每两秒重建;屏幕/SystemUIServer 变化保留恢复路径 | +| MiniStatusView | 自定义两行绘制 | 当前实现为自定义 NSView;历史曾用图片渲染,不能仅按旧审查记录恢复旧方案 | +| 各设置窗口 | 显示偏好即时生效;手动转速/安全转速点应用写配置 | 保留现有生效时机;“应用成功”当前是配置写入反馈,硬件实际 RPM 会由控制循环逐步达到 | +| HelpWindowController | 简介、诊断支持、FAQ、赞赏;版本与更新开关 | 简介有 108pt App 图标,其他页页头有 44pt 图标;普通菜单栏显示遥测文字,未显示 App 图标。隐藏入口应先确定指哪一个图标 | +| PowerTelemetryReader | IOReport Energy Model 差分算功率;SMC PDTR 读取 DC In | 初次只有能量基线,没有可显示功率;下一轮才有差分。IOReportBridge 声明系统接口,不等于自带可移植实现 | +| ThermalAnomalyAlertService | 10 分钟桶、低功率/高 RPM/高温规则;12 小时提醒冷却 | 至少 200 样本、480 秒跨度;两桶连续合格或近期四桶合格才提醒。它是观测提醒,不直接调速;离线 ML 实验未接入生产控制 | +| PrivacyStatsService | 匿名 ID、版本/build、15 分钟入队、日批量上传、离线保存 | 启动 flush、当前 build 成功确认与重试;积压按事件原版本分组;退出同步等待发送是明确设计。反馈已与心跳解耦 | +| FeedbackService/AppLog | 反馈限流、日志轮转、诊断导出、邮件撰写入口 | 日志支持远程诊断;诊断包与发布包不同,诊断包允许包含用户配置。打开邮件客户端不等于自动发送邮件 | +| InstallationCoordinator | App 内生成临时完整卸载脚本 | 用户不用保留原安装包;脚本等待 App 退出后继续删除文件;这是卸载链路,不能误复用于新更新 | + +`hasProbedHardware = true` 必须保持在 profile 内的 `refreshTelemetry()` 之前,否则存在递归调用。历史 kentsmc semaphore 超时包装造成读取停滞并被回滚,本次不重引入。启动/profile 和少数恢复路径仍有同步读取,不能把“日常后台轮询”误写成“所有硬件 I/O 都已经异步”,也不能在 Beta 工作中顺手调整这些路径。 + +配置类型位于 FanCurveEditor 是当前模块依赖结果:主程序依赖编辑器并 typealias 导入。直接把 Config 移回 executable target 会让编辑器无法依赖它;今后抽模型需要独立公共 target。 + +## 更新的完整契约 + +当前路径:启动 30 秒后自动检查(开关开启时)或帮助页手动检查 → 请求 PKG manifest → 比较整数 latest_build 与当前 CFBundleVersion → 新版本提示“立即更新 / 稍后” → 确认后下载 PKG → HTTP 状态检查及 SHA256 校验 → UUID 临时目录原子保存 → NSWorkspace 打开系统安装器 → App 正常退出 → 用户完成系统安装 → 用户从应用程序打开。 + +- 自动检查没有定期 timer,没有 24 小时节流;last_check 仅记录时间。提交 `f95c414` 明确移除了节流,避免新版本发布后被旧检查时间挡住。 +- 手动检查即使关闭自动检查仍可执行;自动失败保持安静,手动失败显示提示并提供 GitHub 入口。 +- `mandatory`、`published_at` 存在于解码模型,当前未用于强制更新或提示策略;`rollout_percent`、`min_supported_build` 未参与客户端决策,不能推断已实现灰度/强更。 +- 本阶段不加入预下载、不改变提示顺序;这是规划第三项的工作。 +- legacy `update-manifest.json` 永久保持 2.9.9/build52,两个 legacy ZIP 固定内容和哈希;新的 stable/Beta 都使用 PKG。 +- 新 stable manifest 是 `update-manifest-pkg.json`;官网版本展示和 `/download` 仍指 stable。当前 download handler 的 build 查询参数单独写在源码中,不自动读取 manifest。 +- PKG 使用同一 App/bundle/package identifier、安装目录、kentsmc 与 sudoers 位置;preinstall 要求 App 已退出,postinstall 不从 root 安装进程启动 GUI,也不创建用户设置。 +- 构建脚本展开最终 PKG 后验证资源、App/tool 哈希、脚本、代码签名、sudoers;不执行安装。组件设置 `BundleIsVersionChecked = true`,降级需要真实系统安装验证。 +- SHA256 是现有完整性验证方式。保留“不增加文件大小校验”的项目约束;历史记录的 CDN 原因解释没有在本次独立复现。 + +## 第四项建议实现边界 + +这是建议,尚未写入业务代码或发布。 + +1. 解锁状态与更新偏好分开:点击指定 App 图标十次后露出开发者区域;解锁本身不自动订阅 Beta。Beta 勾选后,手动与启动自动检查都使用所选通道。 +2. 开发者模式建议使用独立 UserDefaults key,保持跨启动/升级;提供明确退出入口。退出时关闭 Beta 偏好,重新隐藏区域。点击计数建议只在本次窗口会话累计,关窗未解锁则归零。 +3. 引入 UpdateChannel/UpdatePreferences,stable 保持当前 endpoint,Beta 建议新 `update-manifest-beta.json`。Beta PKG 使用独立且带 build 的文件名,避免覆盖官网下载的 stable 包或旧 ZIP。 +4. 将 UpdateService 从 main.swift 小范围提取,stable/Beta 共用 manifest 解码、版本比较、提示、下载、SHA256、系统安装器、正常退出和失败处理。不能靠另一份 updater 模拟“测试更新”。 +5. 发布产物自身的 stable/Beta 身份与用户订阅通道分开存储。保持数字 CFBundleShortVersionString 和整数 CFBundleVersion,界面用额外字段显示 Beta 标签/序号。不要直接把 `3.0.0-beta.1` 写进系统版本字段。 +6. stable/Beta 共用单调递增 build,例如 stable 52 → beta 53 → beta 54 → stable 55(示例,未占用版本号)。版本名仍需遵守原生安装器的版本规则;更高 build 并不能替代跨版本降级测试。 +7. 普通通道只提供正式版本。Beta 通道建议在正式发布时也指向最终正式包,保证持续使用 Beta 路线的开发者可以收到正式发布;另一可选方案是订阅 Beta 时同时比较两份 feed,但必须明确采用哪种政策。 +8. 现有失败链接按 `v{latestVersion}` 拼接。Beta tag 可能包含序号,建议 manifest 加可选 release_url 并保持 stable 默认兼容,避免下载失败后跳错 Release。 +9. 检查开始时固定 channel,收到结果时确认 channel/偏好仍有效,防止检查过程中关闭 Beta 后出现旧 Beta 提示;下载与安装入口避免重复操作。只解决新增通道引入的状态关系,保持现有常规更新的可验证性。 +10. Beta manifest 同样设置 no-store;Beta 发布校验应检查 feed、包、hash、App version/build/release 身份一致,并继续执行 legacy 固定通道校验。更新正式包时同步官网、stable feed、GitHub;发布 Beta 时保持正式下载入口与 legacy 资产不变。 + +Apple 的 [CFBundleShortVersionString 文档](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleshortversionstring)要求三段整数版本;[CFBundleVersion 文档](https://developer.apple.com/documentation/bundleresources/information-property-list/cfbundleversion)说明数字构建版本及分发前递增。现有后台以 `version-build` 拼接并按 `-` 分割,因此预发布后缀还会影响后台解析;额外 Beta 显示字段可以避免本阶段被迫改统计契约。 + +切回 stable 需要明确含义:只改变后续检查通道,还是立即安装当前正式版。若 Beta build 大于 stable,现有比较会判为没有更高版本;不能把通道开关标成“已回到正式版”。自动降级会涉及原生安装器、配置兼容与真实验证,应作为明确需求处理。 + +## 合理的整理顺序 + +当前有两层独立 Git 仓库:父目录仓库管理官网/后台,当前目录有独立 App 仓库。不要直接移动整个主目录、合并 .git 或误以为在 App 仓库提交就会包含官网改动。父目录当前大量近期网站/后台文件尚未跟踪,建议先建立能够恢复的各项目版本基线,再讨论是否统一仓库。 + +### 随 Beta 功能做的小整理 + +- 提取 `Sources/MacFanControl/Updates/UpdateService.swift`,UpdateManifest 和新增通道/偏好保持同一目录;保留 target 结构及原业务行为。 +- 在 `Tests/` 建立真实更新契约测试,接入 SPM/CI。目前 manifest 测试是 packaging 下独立 Swift 文件,CI 检查 Tests 目录,没有自动执行它。 +- 明确 stable/Beta/legacy 发布清单,减少未来把 Beta 写到正式通道的机会。 + +### Beta 稳定后逐步拆分 + +```text +Sources/ + MacFanControl/ + main.swift # 仅程序入口 + App/AppDelegate.swift + Hardware/ # 读取、探测、命令执行、SensorCatalog + Control/ # 曲线计算、控制状态、降级 + Updates/ # 服务、manifest、通道与偏好 + Persistence/ # 配置管理 + Diagnostics/ # 日志与诊断导出 + Telemetry/ # 功率、匿名统计、异常提醒、反馈服务 + UI/MenuBar/ # 菜单与 MiniStatusView + UI/Windows/ # Help/Display/Speed/Safety/Power/Feedback + UI/Components/ # 自适应卡片和二维码等 + FanCurveEditor/ # 先保留模块,稍后按需要抽公共模型 target + IOReportBridge/ +Resources/ # Info.plist 模板和唯一 App 图标源 +Vendor/kentsmc/ # 工具来源、固定版本/哈希、许可证 +scripts/ # 可复现 build-app、校验与打包入口 +packaging/pkg/ # 保留正式 PKG 流程 +packaging/legacy/ # 历史 ZIP 脚本和迁移说明 +docs/development/ # 当前架构和筹备文档 +docs/history/ # 时间线、审查记录、旧公告 +experiments/ # UI/离线模型实验及其说明 +build/ # 生成的 App/PKG/临时输出,忽略提交 +``` + +这是目标结构,不是本次搬迁计划。先做机械拆文件并验证编译/运行,再做行为变化;跨文件 private 访问和 Swift actor 隔离必须随拆分核对。避免拆分、改 UI、改硬件策略、改 updater 同时发生。 + +当前 App 二进制、签名产物和图标仍在 Git 中,README 所称“只保留源码”不准确。建议先补可复现生成 App bundle 的脚本,确认 PKG 可从新输出路径构建,再停止跟踪可生成的 App/binary/signature。不能先删 App bundle,因为当前 PKG 构建依赖它。 + +`iFanControl-2.8.27` 仍有重复脚本/tool,部分 App 内容已在本次开始前被本地删除。根 icon 与现 App icon、两份 kentsmc 的 SHA256 分别相同,存在可消除的重复;但不要替用户恢复/完成那些删除,也不要删除线上冻结迁移 ZIP。当前记忆还引用已不存在的旧目录图标路径,应随资源来源整理修正。 + +离线 analysis 和 liquid-glass HTML 应标记实验状态,并与生产模块、发布输出分开;用户反馈/邮件记录也应从根目录生产工程文件中分离。这些文件是否归档/纳入跟踪由其用途决定,不能统一当垃圾删除。 + +### 文档与代码的已证实差异 + +| 文档或界面 | 当前代码 | 处理建议 | +| --- | --- | --- | +| 中文 README 写自动更新 24 小时节流 | `f95c414` 已明确取消,当前无节流 | 修正文档,不恢复节流 | +| 记忆中旧心跳缓存 192 条/48h | 当前上限 2880 条/30 天 | 更新当前架构段,保留历史时间线 | +| 记忆中旧启动宽限 12 秒、旧 stale 门槛/失败数 | 当前宽限 20 秒、15/45 秒状态门槛、缺失触发数 5 | 标明最新基线 | +| 安全转速 UI 文案 95℃ | 当前 criticalTemp 为 110℃,历史明确调整过 | 先统一真实规则说明;不要因文案自行改回 95℃ | +| 旧记录提及高温交还系统、reset 所有预设等 | 当前控制循环无旧高温分支,reset 当前预设 | 历史描述不能当当前契约,涉及恢复功能应单独分析确认 | +| 旧单曲线控制器和部分恢复辅助方法仍存在 | 当前入口用预设控制器;部分方法无当前调用点 | 列为候选清理项,保留理由待核实后单独处理 | + +## Stats 借鉴的边界 + +已核对上游 commit `0c0e551e199938a92af963c25a15a3ca1aef7764` 的 [传感器目录](https://github.com/exelban/stats/blob/0c0e551e199938a92af963c25a15a3ca1aef7764/Modules/Sensors/values.swift)和[读取器](https://github.com/exelban/stats/blob/0c0e551e199938a92af963c25a15a3ca1aef7764/Modules/Sensors/readers.swift)。Stats 保留机型平台标签和是否参与平均的标记,未知 key 独立分类;本工程目录合并了多平台 key,并按名称推断组件、动态候选参与当前选择。这是现有策略差异,并不能仅凭上游不同认定本工程错误。 + +第二项可以以后以“固定上游版本、带来源的平台/类别数据、真实读取夹具、失败/恢复场景”推进;仅复制一份最新 key 清单不能证明没有实机的兼容性。第四项无需改变硬件策略。 + +## 本次验证与后续验收 + +已通过: + +- `swift build`:完整 debug 构建通过;保留原有 unused 变量、capture 和本机 linker 路径警告,没有把这些警告处理混入本轮。 +- 独立编译运行 `UpdateManifest.swift + packaging/pkg/test_update_manifest.swift`:PKG URL/字段选择、SHA256、损坏及非法元数据测试通过。 +- `python3 packaging/pkg/verify_channels.py`:本地 legacy 固定通道、双 ZIP 别名、包内版本/无配置及 PKG 哈希通过。 +- `codesign --verify --strict iFanControl.app` 与 kentsmc 验证通过。 + +未验证:线上 manifest 请求由 urllib 返回 HTTP 403,web 读取亦不可达,因此没有宣称线上通道验证通过;未运行硬件控制、未安装 PKG、未完成任何 Beta UI 或完整在线升级测试。 + +第四项完成时应覆盖:普通模式完全隐藏;第九/第十次点击边界;重开窗口、重启、升级与退出开发者模式;解锁但未勾选仍走 stable;自动/手动遵守相同通道;通道切换期间的旧请求结果;损坏包和失败链接;stable/Beta feed 与对应产物一致;Beta A → Beta B → 正式版的真实 HTTPS、SHA256、Installer、退出、再启动与设置保留;legacy 文件哈希保持不变。fixture 测试通过不能替代真实安装升级验收。 + +## 集中待确认(首轮记录,现已确认) + +1. 十次点击是否指“关于/帮助 → 简介”的大 App 图标?建议同次窗口会话累计十次,未解锁时关窗清零。 +2. 开发者模式是否跨启动/升级保存,并且解锁后单独勾选 Beta 才使用该通道?建议持久保存,提供退出入口。 +3. 切回正式通道是否只影响后续更新,还是必须立即回退到当前正式版?建议第一阶段只切路线,等待更高 build 正式版;真实降级另立需求。 +4. 首个带解锁入口的正式基线继续用 2.9.9 加 build,还是发 2.9.10?建议在主线可交付版本中先提供入口,再用独立 Beta PKG 迭代 3.0;两条线路共享递增 build。 + +用户随后确认:采用简介大图标与关窗重置;开发者模式保留;只改变更新路线;首个承载版本为 2.9.10,build 加 10 至 62。 diff --git a/docs/development/3.0-ui-audit.md b/docs/development/3.0-ui-audit.md new file mode 100644 index 0000000..aaf5bcf --- /dev/null +++ b/docs/development/3.0-ui-audit.md @@ -0,0 +1,173 @@ +# iFanControl 3.0 UI 与全流程体验梳理 + +基线:2.9.10 Beta / build63。日期:2026-10-12至13日。 + +本轮完成现状盘点、原生窗口审视和改善设计。没有改动产品 UI 源码、控温规则、用户配置、安装包或线上发布。用户已确认通过 Beta 更新到63;配置和偏好的逐项保留结果尚未反馈,不能把这些结果一并认定通过。 + +## 设计目标 + +简洁:让用户少做无意义的选择,第一眼找到当前状态和下一步。必要的硬件状态与失败信息仍然可见。 + +优雅:操作有自然的起点、可理解的等待和明确的收尾。完成反馈的时机必须真实,留出用户看清结果的时间,再安静地回到工作状态。 + +高效:常用操作路径短,编辑与预览在同一处,后台刷新不打断交互,不让用户反复判断“到底生效了吗”。 + +PKG 已体现这三个目标:熟悉的系统安装步骤、克制的介绍、系统原生完成勾和完成后的打开指引。3.0 应把同等质量延伸到应用内的检查、等待、设置、异常及反馈流程。 + +## 审视方法与证据边界 + +- 阅读当前菜单、全部活跃窗口、弹窗/生命周期入口、曲线模块及 PKG 生成/安装脚本,并结合历史记录解释设计意图。 +- 通过独立原生 App 加载当前 UI 类,实际查看中文浅色、部分深色和英文深色界面;观察连续/档位切换、应用完成反馈、空反馈验证、帮助导航、十次解锁和原生菜单。 +- 测试 App 使用独立偏好域和配置目录;硬件数据为模拟值,不启动 AppDelegate 或控温轮询。反馈发送为本地替身,不向开发者发送消息。更新弹窗使用明确标注的样例,未下载/安装样例版本。 +- 系统 Installer 实际打开63包,查看介绍、目的宗卷和安装类型,在安装按钮前停止并关闭。未重新安装,未为审视复演系统完成勾动画。该动画的体验由用户说明,包生成配置确认其被有意保留。 +- 截图来自 CUA 原生窗口,不使用漏掉控件的离屏 cacheDisplay 图。系统强调色、指针高亮及窗口装饰不视为 App 自己设计的品牌元素。 +- 官网下载入口已做源码盘点;浏览器工具超时,未完成本轮整站视觉审视。安装授权、完整卸载、故障弹窗的全套场景做了源码盘点,未对用户机器触发这些操作。 + +截图与隔离探针位于 `packaging/pkg/output/ui-audit-63/`。这是一套审视工具,不是待分发 App。图片地址在本文使用本机路径,便于当前工作区查看。 + +## 应当保留的现有设计 + +1. **系统安装器与完成勾。** `build.py` 有意不加入自定义 conclusion/readme;`render_open_hint.swift` 使用侧栏背景继续展示打开说明,保留系统完成反馈。postinstall 不以 root 身份启动 GUI 或创建用户设置,这也应保留。 +2. **菜单一次构建、状态原地刷新。** 这是性能与交互稳定性的设计,不应在视觉改造中退回高频重建菜单。 +3. **显示设置即时生效、曲线关闭保存、转速明确应用。** 分别对应展示偏好、曲线编辑和控制参数,差异有意义。统一的是提示方式,不是强行改成一种保存语义。 +4. **目标 RPM 与实际 RPM 的区别。** 现有说明防止用户误认为轻微波动就是失败;“已应用”只能确认设置已被接受,不能伪称硬件必然达到精确数值。 +5. **遥测失效占位及系统接管。** `--`、启动中、传感器丢失、交还系统有实际控制含义,不能为了视觉干净隐藏这些状态。 +6. **隐藏开发者模式与独立 Beta 路线。** 继续保持普通用户默认入口和正式发布不变。 +7. **作者的亲切语气。** 反馈与支持作者可以保留人格;调整信息密度和层级,无须把全部文案改成冷冰冰的术语。 + +## 全部活跃界面与入口清单 + +“原生”表示本轮实际查看了渲染;“源码”表示盘点了内容与行为,但未触发相应真实操作。 + +| 界面/阶段 | 当前行为与理由 | 改善方向 | 证据 | +| --- | --- | --- | --- | +| 官网下载入口 | 常规下载 PKG;旧版另保留升级到2.9.9的入口 | 清楚区分首次安装与旧版迁移,保持正式版本展示 | 源码 | +| PKG 介绍 | 图标、短介绍、退出/授权/保留设置/打开说明 | 保留;安装前条件放在此时说明,避免用户到失败后才知道 | 原生 | +| PKG 目的宗卷/安装类型 | 仅支持当前启动磁盘、所有用户;控制组件需要系统级安装 | 原生授权保留;评估唯一合法目标的冗余选择,不能改变安装作用域 | 原生 | +| PKG 授权/执行/失败 | 系统授权、脚本检查、校验签名;运行中的 App 会使安装停止 | 不自绘替代;失败说明给出退出 App 后重试的具体路径 | 源码 | +| PKG 完成/首次打开 | 系统完成勾;侧栏指引手动从应用程序打开 | 保留原生完成页,不用自定义完成页覆盖它 | 源码及用户说明 | +| 首次启动 | 语言选择;按条件提示无可控风扇及温度源默认规则 | 减少连续弹窗;普通信息可集中引导,异常状态清楚区分并保留 | 源码 | +| 状态栏完整/简洁/迷你 | 提供不同密度;迷你允许两行组合、单位及模式字母 | 明确可读默认值;显示设置里提供组合后的真实样式预览 | 源码及设置原生 | +| 主菜单与状态卡 | 硬件/温度源、控制状态、曲线、模式、偏好、帮助、反馈、退出 | 依“观察→控制→设置→支持”组织;减少重复入口与装饰占位 | 原生 | +| 温度源子菜单 | 自动最热、CPU/GPU平均及按类别分组的实际传感器 | 保留分组与真实数据;当前选择更易辨认,长列表保持易定位 | 原生 AX/源码 | +| 模式切换 | 菜单文字是将要切换到的模式;手动转速入口只在手动模式出现 | 分清“当前自动”与“切换到手动”,减少用户猜测 | 原生/源码 | +| 手动转速 | 连续/档位、滑块、目标值、解释、应用/取消;应用后短暂绿勾 | 更突出目标数值;保留应用语义,明确应用后的关闭动作不是撤销 | 原生及应用反馈 | +| 安全兜底 | 2000至机型上限,应用反馈;设置下限不压低更高曲线目标 | 文案对应真实规则;补长英文换行/边距,保留控制策略 | 原生/源码/历史 | +| 曲线编辑 | 5点曲线、3预设、双击重命名、Y轴方向、重置当前预设、关闭保存 | 当前预设/目标点数值更清楚;编辑状态可见;键盘与读屏支持 | 原生 | +| 信息栏展示 | 三种显示密度;迷你有上下内容多选及各自样式,即时生效 | 预览居前、上下两行并列编辑;按需展开高级项,减少长纵向表单 | 原生 | +| 电力消耗 | 六项估算、DC In总计、首次空采样说明 | 主次数字分层;加载/不支持/已更新用状态表达,保留估算边界 | 原生正常状态/源码其他状态 | +| 反馈 | 内容+可选邮箱;发送中、成功后关闭、失败可重试、每小时限流 | 错误靠近相关输入;成功不承诺无邮箱时回复;保留草稿和亲切语气 | 原生空态/验证,源码发送状态 | +| 帮助:简介 | 大图标/版本、重启/检查/GitHub、更新与匿名统计偏好 | 关于信息与运行偏好分层,保留简洁的品牌介绍 | 原生 | +| 帮助:诊断与支持 | 导出、联系、卸载一排;快速自检在下面 | 自检优先,导出后给出下一步;卸载放到独立低优先级区域 | 原生 | +| 帮助:FAQ | 四个问题,解释温度源/传感器/控制模式/兼容处理 | 保留解释;标题与正文对齐,随问题增长再考虑折叠或定位 | 原生 | +| 帮助:支持作者 | 咖啡文案、支付方式切换、自适应二维码 | 保留轻松语气、二维码对比度与静区,不增加完成动画假称付款成功 | 原生 | +| 帮助:开发者 | 十次解锁,独立订阅、当前路线、检查、退出 | 当前安装版本与更新路线持续区分;隐藏契约保持 | 原生 | +| 更新检查/无更新/有更新 | 手动检查有结果弹窗;自动无更新安静;有更新使用原生确认 | 给检查中状态;用“可用版本”代替“远端版本”;开发信息按需显示 | 原生样例/源码 | +| 下载/校验/交接安装器 | 下载并SHA256校验;打开系统安装器成功后正常退出 | 首要改善:可见的等待阶段、真实进度和取消/重试;交接提示不冒充安装成功 | 源码 | +| 重启、语言切换 | 语言存储后需重启;询问现在或稍后 | 保留生效条件;推迟重启时仍能找到明确提醒 | 源码 | +| 异常提醒与失败提示 | 无风扇、保守兜底、交还系统、散热异常、更新失败/安装器打开失败 | 标明“发生了什么、当前保护状态、用户下一步”;重要异常不淡出丢失 | 源码 | +| 完整卸载 | 二次确认后在终端执行,移除组件/配置/日志/登录项 | 和支持动作分离;保留删除范围与密码不回显说明,避免无结果退出 | 源码 | + +旧 `FanCurveWindowController` 的保存成功弹窗不是当前主菜单的编辑入口;实际入口为预设曲线控制器。旧 ZIP 安装终端只属于冻结迁移线路,不能当成当前 PKG 主体验。重置操作针对当前预设,不是重置所有预设。 + +## 关键发现与对应设计 + +### 1. 把更新流程的等待与完成接起来(第一轮) + +当前“立即更新”之后,下载和校验期间没有可见进度。用户再次点击检查时服务会忽略重复请求,但界面也没有解释正在进行什么。PKG 后半段已有清楚、自然的完成体验,前半段尚未达到同一水准。 + +建议保留原生新版本确认,增加一个安静的更新状态区域,按真实阶段表达: + +| 阶段 | 用户看到 | 完成条件 | +| --- | --- | --- | +| 检查 | 正在检查更新;当前路线 | manifest读取与解析成功 | +| 可用更新 | 版本、简短改动、更新/稍后 | 用户选择更新 | +| 下载 | 正在下载安装包;有总量时展示真实进度,无总量时显示活动状态 | 网络传输结束且HTTP成功 | +| 校验 | 正在验证安装包 | SHA256匹配 | +| 交接 | 即将打开系统安装器,应用将退出 | 系统安装器打开成功 | +| 安装 | 使用系统安装器的步骤、授权与进度 | Installer确认执行成功 | +| 完成 | 保留系统完成勾和打开说明 | 系统确认安装完成 | +| 再次启动 | 当前版本清楚可见;可考虑一次性的简短已更新反馈 | 实际运行版本符合本次更新目标 | + +取消必须停止相应请求,使后续结果不再打开安装器;失败允许明确重试。不能用固定计时器模拟下载百分比,也不能在安装器刚打开时显示“更新成功”。本轮设计只改善现有主动更新过程,不引入计划中的自动预下载功能。 + +### 2. 常用设置让用户“看到结果,再作选择”(第二轮) + +信息栏设置目前从180pt变为640pt高,组合选项完整,但每个例子只解释局部值。建议上方固定显示最终两行预览,下方用“上行/下行”的并列配置,内容与样式同组。默认给出清楚的可读组合,复杂组合仍保留在高级项里。切换显示密度时避免突兀的整窗高度跳变。 + +主菜单保留原生NSMenu,按任务整理层级。2026-10-13维护者明确确认:大反馈邀请框虽然不可直接输入、与“告诉开发者”指向同一窗口,但文本框外观本身用于鼓励用户反馈,必须保留。此前建议替换为普通轻量入口的判断已撤回;应保持外观与点击打开独立窗口的语义,再优化对齐和操作可达性。详见 [反馈邀请修正记录](3.0-ui-feedback-intent.md)。 + +### 3. 曲线和转速完成感统一,保存规则保留(第三轮) + +手动/安全转速已有“✓ 已应用”,约1.2秒后以0.25秒淡出。这种原位、轻量反馈值得延续。应用后仍显示“取消”可能让人误以为会撤销,应让未应用编辑与已应用完成的含义清楚。 + +曲线应显示当前预设及编辑/保存状态,拖动点时数值更易读;保留双击重命名,并可提供更容易发现的重命名入口。切换预设会通知主程序切换曲线,关闭时才持久化整个编辑结果,不能把所有拖动误称为即时保存。保存成功只在真实写入后反馈,失败应保留编辑内容。 + +正向/反向是既有坐标显示选项,保持数据及默认选择,不因曲线视觉方向擅改规则。轴名称明确温度与目标RPM。当前窗口标题里的“v33”与产品build63混用,可把组件调试版本移至诊断信息。曲线点目前未出现在原生AX树,应补键盘和辅助功能访问,而不改变5点控制模型。 + +### 4. 支持、错误与长文案按任务分层(第四轮) + +诊断页先给快速自检,再把“导出诊断→联系支持”的顺序接起来;完整卸载独立放置。诊断包导出成功后,用户应清楚文件在哪里、接下来做什么;该ZIP不应未经用户选择自动发送。 + +英文实际渲染中,`Diagnostics & Support`、`Support the Author`和`Export Diagnostics`被截断。中文看起来宽松并不能证明本地化完成;布局按更长文案验证。英文安全说明也逼近右边缘,应加入合理宽度、换行与边距。 + +反馈发送成功当前总是承诺回复,但邮箱是可选的,宜根据是否有联系方式调整收尾文案。限流当前写“服务器压力大”,实际是本地每小时5次限制,提示应准确。保留已有发送中按钮、失败后恢复发送和成功收尾;外部发送成功只能在接口确认后显示。 + +功率页的首次等待说明在数据显示后仍占据同样位置,可根据状态变成“读取中/刚刚更新/暂不支持”。分组件估算和DC In总计不是同一测量范围,不能为了图表整齐强迫二者数值相等。 + +### 已证实的说明差异:安全温度 + +窗口写95℃;实际 `criticalTemp` 为110℃,`calculateFanRPM` 在达到该门槛时施加兜底。提交 `e5f620f` 明确把95改成110,因此110不是本轮发现的控温bug。后续统一UI说明与实际策略,不把硬件规则改回95来迁就文案。遥测陈旧状态还参与保守转速下限计算,说明也应避免把整个保护策略缩成一个温度数字。 + +## 动效与完成反馈准则 + +- 设置切换、显示/隐藏内容和短反馈可以用短淡入淡出,重点是保持上下文位置稳定;常规状态不循环播放庆祝动画。 +- 成功勾只表示真实完成的动作。配置保存、反馈送达、系统安装完成是不同事件,不共用一个过早触发的“成功”。 +- 重要失败/保护状态持续可见,恢复后再自然回到正常状态;用户的编辑内容在失败时保留。 +- 尊重减少动态效果设置;提供清楚静态状态。键盘、VoiceOver、深浅色、中文英文一起验收。 +- Installer自己的完成动画不替换、不重新实现。避免自动从root安装脚本启动用户App;任何后续打开便利设计都需保持当前权限与安装边界。 + +## 实施顺序与验收 + +第一批:补齐检查、下载、校验、交接的可见状态,保留PKG完成勾;统一安全说明的真实语义与本地化布局。该批不做菜单整体重写、设备策略或预下载。 + +第二批:菜单按任务分组,保留主动反馈邀请框及其产品意图,改善信息栏组合预览。 + +第三批:曲线编辑状态、重命名可发现性、转速确认反馈与辅助功能。 + +第四批:关于/帮助、诊断、反馈、功率及首次使用/失败路径的细节统一。 + +每批单独进入Beta。下一可发布build大于63,正式线路维持2.9.9/build52。验收同时观察进入、等待、成功、失败、取消、重开和重启;在真实任务里确认更省心,再考虑正式发布。 + +## 当前原生截图 + +以下是现状证据,不是改版效果图。数据来自隔离模拟,图中系统强调色与指针高亮取决于本机环境。 + +### 安装介绍 + +![当前 PKG 介绍]() + +### 更新确认(明确标注的隔离样例) + +![当前更新弹窗]() + +### 主菜单与英文帮助 + +![当前主菜单]() + +![英文诊断页]() + +### 信息栏组合设置与曲线 + +![当前信息栏设置]() + +![当前曲线深色界面]() + +其余窗口截图同在审视目录:`speed-native-light.jpg`、`safety-native-light.jpg`、`safety-native-en-dark.jpg`、`power-native-light.jpg`、`feedback-native-light.jpg`、`overview-native-light.jpg`、`diagnostics-native-light.jpg`、`faq-native-light.jpg`、`donation-native-light.jpg`、`developer-native-light.jpg`、`pkg-install-type-native-dark.jpg`。 + +## 源码定位 + +- `Sources/MacFanControl/main.swift`:更新2370–2558,菜单2940–3210,启动/重启/支持入口3620起,显示4169起,功率4532起,手动转速4663起,反馈4934起,安全5147起,帮助5429起。 +- `Sources/FanCurveEditor/FanCurveWindow.swift`:活跃预设控制器253起,切换/关闭保存429起;`FanCurveView.swift`:绘图与鼠标拖动。 +- `packaging/pkg/build.py`、`render_welcome.swift`、`render_open_hint.swift`、`resources/Welcome.json`、`scripts/preinstall`、`scripts/postinstall`。 +- `docs/development/3.0-preparation.md`:此前整理的意图/历史约束;旧描述以当前代码和本次直接观察为准。 diff --git a/docs/development/3.0-ui-delivery.md b/docs/development/3.0-ui-delivery.md new file mode 100644 index 0000000..4302339 --- /dev/null +++ b/docs/development/3.0-ui-delivery.md @@ -0,0 +1,52 @@ +# 四组 UI 改善:实现与验收记录 + +此文件保留第一轮64/65的历史交付。维护者随后否定了视觉效果,第二轮原生设计调整与当前Beta66交付见 [UI第二轮记录](3.0-ui-polish.md)。第一轮行为验收不能视为审美验收通过。 + +2026-10-13。目标:一次完成更新流程、菜单/信息栏、曲线/调速、帮助/支持四组任务,自主验收后交付人工验收。对应 `3.0-ui-audit.md` 的四组改善;设备适配和自动预下载不在本次范围。正式/旧线路继续为 2.9.9/build52。 + +实现及自主验收完成;真实安装、风扇控制和个人使用体验交由维护者人工验收。Beta 65 已部署;公网安装包重新下载后逐字节/SHA256核验通过,正式/旧manifest及 `/download` 仍52。 + +## 交付版本与人工验收 + +- **2.9.10 Beta / build64**:本地验收起点,`packaging/pkg/output/beta-64/iFanControl-2.9.10.pkg`。SHA256 `7b5bc24472446ed4fcecf2288d15b488dc70599063cfef3896f657adcd7019b8`。 +- **2.9.10 Beta / build65**:Beta 在线更新目标,`https://ifan-59w.pages.dev/iFanControl-beta-2.9.10-65.pkg?build=65`。SHA256 `d4778e53e8420faf1e359c9176cf9094ec9c114e49709cf515fcd23061d393bd`。签名之前的可执行代码与64完全相同,仅 build 元数据与对应签名不同。 +- Production:`61e8903f-d65f-48fd-b6ad-15dfbbf52431`,前一 Production 为 `a0b0d0c8-ba6c-4102-bf91-53b43b6fccf4`。正式发布尚未进行;未来正式 build 必须大于65。 + +建议按以下顺序验收: + +1. 正常退出当前63,安装本地64,从「应用程序」打开,确认帮助页显示64。检查原曲线、预设、模式、温度源、展示偏好及 Beta 偏好保留。 +2. 浏览菜单、展示设置预览、曲线控制点数值编辑/重命名/关闭保存、手动转速与安全兜底、帮助/反馈/功率。确认简洁与效率是否符合自己的日常操作。 +3. 在开发者页开启 Beta,手动检查更新。下载65时先取消一次,再重新检查并完成更新;有总量时显示真实字节进度,没有总量时只显示已收到字节。 +4. 校验后应用正常退出、系统安装器打开;完成原生 PKG 安装并查看系统完成勾。重新打开应用,确认65以及短暂打勾提示,原有设置继续保留。 +5. 关闭 Beta/退出开发者模式后,后续检查切回正式路线,当前65保留,不自动降级。切换中英文后按既有语义重启,检查常用界面的深浅色和键盘操作。 + +63直接在线升级65也能得到全部UI改动,但下载安装阶段由旧63客户端显示,无法完整验收新版更新界面,因此提供本地64作为起点。 + +## 实现与证据 + +所有下列截图均来自当前源代码的原生 AppKit 窗口,不是网页仿制或效果图。隔离应用使用独立 bundle ID、UserDefaults 与配置目录,不运行 AppDelegate/控温循环;模拟遥测、反馈网络、更新网络、Installer 打开及退出等外部效果。更新传输使用实际 PKG 字节和正确 SHA256,故障测试有明确标识。 + +| 任务 | 最终行为 | 自主验收证据 | +| --- | --- | --- | +| 1 更新 | 手动检查可见;自动无结果安静;真实字节进度,未知总量无虚构百分比;取消/换路线停止旧请求;HTTP/SHA/打开失败可重试;下载→校验→Installer,正常退出保留;仅实际运行达到目标build才给轻量打勾确认 | `bash packaging/pkg/test_updates.sh` PASS:真实 UpdateService、流式数据、取消/重试、自动检查中手动加入、损坏包/HTTP/打开失败、通道旧结果、完成凭据。原生查看并操作取消/重试/下载及打开失败;[已知总量](assets/ui-validation-64/update-known-total.jpg)、[未知总量](assets/ui-validation-64/update-unknown-total.jpg)、[打开失败](assets/ui-validation-64/update-open-failure.jpg)、[达到版本后的提示](assets/ui-validation-64/update-receipt-notice.jpg) | +| 2 菜单/展示 | 保留原生菜单一次构建、原地更新;按操作分组,移除重复假输入,明确切换目标;两行配置与真实渲染共用格式,设置即时生效,可预览、交换;来源顺序、至少一个来源与原偏好键保留 | 原生操作多选、最后一个来源、交换、完整/迷你切换、重开持久化;控制器回归验证顺序去重/保存/共享格式。截图:[原生菜单](assets/ui-validation-64/menu-en-dark.jpg)、[中文展示](assets/ui-validation-64/display-zh-light.jpg)、[英文深色](assets/ui-validation-64/display-en-dark.jpg) | +| 3 曲线/调速 | 当前预设可见,显式重命名入口保留双击;控制点可键盘输入/读屏,沿用端点、温度顺序与RPM范围;未编辑温度不损失精度;切换/重置即时通知、关闭保存;写入失败保留窗口;成功才反馈。转速显式应用,应用后“完成”,再调整回“取消” | 控制器回归验证最小窗口布局、精度、仅改RPM、边界、无效输入、预设、配置字段保留、写入失败 veto。原生操作数值、越界、无效输入、重命名、重置及失败后重试;手动/兜底保存成功与故障。截图:[中文曲线](assets/ui-validation-64/curve-zh-light.jpg)、[英文深色](assets/ui-validation-64/curve-en-dark.jpg)、[无效输入](assets/ui-validation-64/curve-invalid.jpg)、[保存失败](assets/ui-validation-64/curve-save-failure.jpg)、[转速保存失败](assets/ui-validation-64/speed-save-failure.jpg) | +| 4 帮助/支持 | 自检→导出诊断→联系支持;卸载独立且保留原确认;FAQ左对齐;长英文换行;十次解锁、默认隐藏、Beta单独开关保持;反馈普通回车换行、⌘↩发送,发送清旧提示、失败留草稿,成功可再次发送且延迟关闭不误关重开的窗口;功率说明加载/数据/不支持、关窗停止计时器;普通首次说明改为非阻断,安全提醒保留 | 原生九次不解锁、第十次进入开发者且Beta仍关闭;帮助/诊断/FAQ/赞赏、反馈空输入/发送中/失败及功率数据/不支持已查看。控制器回归覆盖限流、有无邮箱成功、失败重开/再发/延迟关闭及功率计时器。截图:[诊断中文](assets/ui-validation-64/support-zh-light.jpg)、[诊断英文深色](assets/ui-validation-64/support-en-dark.jpg)、[FAQ](assets/ui-validation-64/faq-en-dark.jpg)、[开发者](assets/ui-validation-64/developer-en-dark.jpg)、[反馈失败](assets/ui-validation-64/feedback-failure.jpg)、[功率数据](assets/ui-validation-64/power-zh-light.jpg)、[不支持](assets/ui-validation-64/power-unsupported-en-dark.jpg)、[赞赏文字修正](assets/ui-validation-64/donation-en-dark.jpg) | + +`python3 packaging/pkg/test_ui.py` PASS。测试编译实际控制器,只为独立配置目录、功率初始化、反馈与保存失败弹窗提供测试接口;不写生产配置、不发真实反馈、不控制风扇。日志:`packaging/pkg/output/ui-validation-64/ui-tests.log` 与 `updater-tests.log`。 + +## 保留边界与交付检查 + +- 已对照63源码快照确认:BackgroundHardwareReader、PowerTelemetryReader、传感器列表、手动RPM规则、FanManager控温方法、Config字段定义与PKG安装脚本保持原样。FanManager仅增加读取临界温度的展示属性;更新偏好只增加换路线通知以取消请求,原解锁/持久化/退出策略不变;ConfigManager同一原子写入返回是否成功。记录:`packaging/pkg/output/ui-validation-64/protected-invariants.json`。 +- 安全说明110℃来自既有代码与历史变更;未把规则改回旧文案95℃。曲线坐标方向、插值算法及预设即时应用语义保留。 +- Release构建通过。64/65均由最终Release二进制打包;构建器展开包核对App/tool内容、ad-hoc应用签名、脚本语法及sudoers;沿用当前未公证的PKG分发方式。没有本机安装/替换 `/Applications`。 +- 实际查看65的系统欢迎/目的宗卷/安装类型,未点击「安装」。[欢迎页](assets/ui-validation-64/pkg65-welcome.jpg)、[安装类型](assets/ui-validation-64/pkg65-install-type.jpg)。原生conclusion未被自定义覆盖;本次没有复演系统安装成功动画,真实安装及系统完成勾属于上述人工验收。 +- 主要界面已看中文浅色及英文深色实际图像;可编辑控制点辅助标签已在AX树验证。减少动态效果的系统偏好分支经过源码检查,没有更改维护者的全局系统设置;没有声称跑完整VoiceOver读屏会话。 +- Beta部署以线上63资产清单构建隔离staging,复用完全相同后端bundle/routes/header与运行绑定,仅Beta feed改变、新65 PKG增加。原55个非Beta-feed资产不变,包括正式PKG、旧双ZIP、主页、后台函数与线上schema;本地其他官网修改保留,没有整体部署旧release-candidate。 +- 部署与公网核验记录:`packaging/pkg/output/beta-65/verification.json`。三份feed应为JSON/no-store,Beta65包应与本地逐字节一致,正式/旧feed和 `/download` 仍52。 + +## 文件整理建议 + +先明确发布入口与验证入口,再拆文件,避免本次UI验收和目录迁移相互干扰。第一步可将当前四个小型UI辅助类归入 `Sources/MacFanControl/UI/`,将原生控制器从大型main.swift按Updater、菜单、设置、帮助支持逐块提取;保持同一target和访问边界,每次提取独立构建/验收。硬件、控温、IOReport适配应保持独立,不以UI抽取改变调用线程或计时器。 + +测试夹具后续集中到 `Tests/`(更新协议、原生控制器与视觉驱动分开),`packaging/pkg/` 保留构建/通道保护/发布检查入口。生成的包、线上快照和视觉证据统一留在已忽略的output目录,文档引用最终版本。历史包与实验页面先列清单再归档,不按名字直接删除。详细原始整理理由见 `3.0-preparation.md`;本次没有顺带移动历史文件或改官网工程。 diff --git a/docs/development/3.0-ui-feedback-intent.md b/docs/development/3.0-ui-feedback-intent.md new file mode 100644 index 0000000..d812097 --- /dev/null +++ b/docs/development/3.0-ui-feedback-intent.md @@ -0,0 +1,28 @@ +# 恢复反馈邀请与说明状态时间:Beta68 + +2026-10-13。维护者明确确认:主菜单“告诉开发者”下面的文本框外观,是主动邀请用户反馈的产品设计。点击后打开独立反馈窗口是预期行为。先前把它当成冗余入口删除,是本轮UI梳理中的误判,已经恢复。 + +## 当前行为 + +- 保留原邀请文案、圆角文本框外观、留白和不可在菜单内直接编辑的语义。整个框均可点击,进入独立反馈窗口;标题入口也继续保留。宽度对齐当前菜单,未把邀请框缩成普通菜单文字。 +- 邀请框上使用不绘制自身的原生按钮处理点击,先退出菜单追踪再打开反馈窗口。中文和英文的实际控件点击均已打开反馈窗口,正文获得焦点;没有提交真实反馈。 +- 顶部显示“控制正常 · 自动/手动”。12分钟前这样的数字不再与正常状态并排,时间保留在提示文字里,明确命名为“最近一次状态记录”。 + +![中文主菜单](assets/ui-validation-68/menu-zh-light.jpg) + +[点击后进入反馈窗口](assets/ui-validation-68/invitation-opens-feedback-zh.jpg)、[英文深色手动菜单](assets/ui-validation-68/menu-manual-en-dark.jpg)、[英文反馈窗口](assets/ui-validation-68/invitation-opens-feedback-en.jpg)。 + +## 时间的实际含义 + +AppDelegate.updateControlStatus按mode和detail组成签名,普通更新在签名相同时直接返回。因此正常状态没有改变时,控温循环持续运行也不会逐次重写recordedAt。启动及交还系统等force路径允许强制记录。这个时间标记最近一次状态记录,不能把它当成最近一次传感器采样或正常检查的时间。 + +本轮保留记录规则、force语义、控温循环与配置。只修正呈现与说明,没有为了让显示时间新鲜而改变记录频率。 + +## 验收与发布 + +- Release构建及原生控制器回归通过。菜单夹具使用12分钟前的记录,检查当前状态/模式、提示中的记录时间、不可编辑邀请框及其按钮覆盖区域、全部13个菜单命令可达、同一菜单对象原地刷新;最终英文证据在 `packaging/pkg/output/ui-validation-68/menu-behavior.json`。 +- 实际查看中文浅色与英文深色,实际点击邀请控件进入独立反馈窗口。截图来自独立偏好/配置和模拟遥测夹具,不运行AppDelegate控温、不替换用户应用、不发送真实反馈。工具的任意菜单坐标点击接口不支持该自定义区域,原生按钮控件点击已完成正向验证。 +- 对照67 source-record:仅MenuBarManager的反馈邀请/点击路径、状态呈现改变;其外代码和其他产品源文件及PKG脚本保持原样,见 `packaging/pkg/output/beta-68/protected-invariants.json`。67的完成勾大小、圆角填充与动效继续保留。 +- Beta68独立PKG SHA256 `aca31c48e7e2b1ed820748d031ab74c065dd2d935765e4d2179366c1fcf89ed2`。Production `9b2ca0fa-eb39-417d-a8f5-1d25fcb6b665` 成功;线上PKG重下载与本地逐字节一致,三份feed JSON/no-store,保留58个非Beta-feed旧资产及相同后端/运行绑定,HEAD官网下载仍52。证据见 `packaging/pkg/output/beta-68/verification.json`。正式/旧线路保持52,不进行正式GitHub发布或源码提交/推送。 + +此邀请框是后续UI改动的明确保留项。精简应保留产品意图,不能仅按是否与另一入口通往同一页面判断其价值。 diff --git a/docs/development/3.0-ui-help-sidebar.md b/docs/development/3.0-ui-help-sidebar.md new file mode 100644 index 0000000..f46c071 --- /dev/null +++ b/docs/development/3.0-ui-help-sidebar.md @@ -0,0 +1,18 @@ +# 关于/帮助侧栏与官网入口:Beta69 + +2026-10-13。按维护者的截图反馈,放大左侧导航文字,缩小选择区域,在简介页新增访问官网按钮。 + +- 导航文字12→14点,选中项继续使用medium字重;图标同步适度放大。 +- 侧栏176→164点,选中背景152×30→140×28点;正文获得额外空间,窗口保持700×500。 +- 英文支持作者使用可完整显示的“Support Author”,辅助功能标签保留“Support the Author”。 +- 原生“访问官网 / Visit Website”按钮位于检查更新与GitHub之间,使用系统默认浏览器打开项目已有官方域名 `https://ifancontrol.puremilkchun.top`。 + +![中文浅色简介](assets/ui-validation-69/help-zh-light.jpg) + +[英文深色简介](assets/ui-validation-69/help-en-dark.jpg)、[中文支持页](assets/ui-validation-69/help-zh-diagnostics.jpg)、[英文开发者页](assets/ui-validation-69/help-en-developer.jpg)。截图来自隔离偏好/配置的原生控制器夹具。 + +Release构建与原生控制器回归通过。实际点击官网按钮记录完整目标URL,官网HEAD返回200;实际点击App图标10次进入开发者页,新增导航完整显示。中英文深浅色实际渲染已检查,没有截断导航或简介按钮。 + +对照68,源代码修改仅在HelpWindowController内;其他源文件、PKG脚本、反馈邀请框、控制状态语义与完成提示精确保持,见 `packaging/pkg/output/beta-69/protected-invariants.json`。没有运行真实控温、替换用户安装的应用、发送反馈或执行PKG安装。 + +Beta69独立发布,正式/旧线路及官网下载保持2.9.9/build52。最终云端部署、公网PKG与SHA256、59个非Beta-feed资产及后端不变证据见 `packaging/pkg/output/beta-69/verification.json`。实际安装与重启由维护者通过Beta更新继续验收。 diff --git a/docs/development/3.0-ui-menu-notice.md b/docs/development/3.0-ui-menu-notice.md new file mode 100644 index 0000000..de08ab9 --- /dev/null +++ b/docs/development/3.0-ui-menu-notice.md @@ -0,0 +1,38 @@ +# 主菜单与完成提示:Beta67 + +此文件保留67的历史交付。维护者随后明确反馈邀请框必须保留、正常状态旁的记录时间造成歧义;当前修正见 [Beta68反馈邀请与状态文案](3.0-ui-feedback-intent.md)。 + +2026-10-13。维护者明确“下拉框繁杂”指菜单栏展开的主菜单。本轮只修改主菜单的呈现及完成提示;曾制作的信息栏设置简化试稿已撤回,设置窗口恢复为66的原样。 + +## 主菜单 + +- 将原来的双列状态卡片收为一行“控制状态 + 刷新时间”,保留原状态颜色、告警文字与时间规则。详情放在提示文字中。 +- 控制模式、手动转速(仅手动时出现)、曲线、温度源、功耗保持直接可用。开机启动、展示设置、安全转速、语言集中到“设置”子菜单。 +- 硬件信息移入设置子菜单,当前温度源移入温度源子菜单;去掉重复分组标题和多余分隔线。帮助、反馈、重启及退出保留。 +- 菜单仍一次构建、动态原地更新,不在每个刷新周期重建。所有操作selector、控温/读取/计时器/状态栏渲染、状态映射及配置语义保持。 + +[此前英文菜单](assets/ui-validation-66/menu-en-dark.jpg) → [本轮英文手动模式](assets/ui-validation-67/menu-manual-en-dark.jpg)。 + +![中文主菜单](assets/ui-validation-67/menu-zh-light.jpg) + +[设置子菜单](assets/ui-validation-67/menu-settings-zh-light.jpg)、[温度源子菜单](assets/ui-validation-67/menu-temperature-en-dark.jpg)。 + +## 完成提示 + +移除叠加popover材质的背景,采用单一系统颜色圆角填充与原生窗口阴影;透明角部不再叠加材质亮边。完成勾从24×24增大到36×36点,提示窗口从340×72调整为360×80点;保留0.24秒描边、减少动态效果的静态分支、非阻断显示与1.8秒自动消失。 + +![英文深色完成提示](assets/ui-validation-67/notice-en-dark.jpg) + +[浅色完成提示](assets/ui-validation-67/notice-en-light.jpg)、[中文深色](assets/ui-validation-67/notice-zh-dark.jpg)。 + +原生视图直接渲染的RGBA PNG经AppKit读取核对:720×160像素、四角alpha均为0;深色圆弧236个半透明抗锯齿像素中,亮色像素为0。实际行布局中完成勾为36×36点。证据:`packaging/pkg/output/ui-validation-67/notice-edge-verification.json`、`notice-dark.json`、`check_notice_edges.swift`。浅色圆弧使用浅色背景正常抗锯齿,未以“无任何亮像素”判断浅色结果。 + +## 验证与交付 + +- Release构建通过;原生控制器回归通过,代码保护核对确认其相关控制器恢复/保留。最终菜单夹具实际运行13个命令selector可达、手动转速可见性、语言选中态、同一菜单对象原地刷新断言后展示菜单。原生中文浅色、英文深色/手动模式与子菜单均已实际查看。 +- 由66原生夹具移除模拟替代,还原出的原始main.swift与66记录的SHA256完全吻合;再比较67,所有MenuBarManager之外的代码逐字相同。MenuBarManager的操作方法、状态映射、轮询、状态栏渲染亦逐字相同。其余产品源文件和PKG脚本仅InteractionNotice变化,见 `packaging/pkg/output/beta-67/protected-invariants.json`。 +- 夹具使用独立偏好/配置与模拟遥测,不运行AppDelegate或控温循环,不安装、不替换 `/Applications`、不发送反馈。完成提示通过隔离版本回执触发;真实更新/系统Installer安装仍由维护者体验。 +- 本地与线上 **2.9.10 Beta / build67** PKG SHA256:`18fba494216e4e4ebb85223ea4ea9f5d9f250cfe64a257706083f59daf836e92`。公网包重新下载逐字节核对通过,包展开、签名、脚本和sudoers检查沿用构建器验证。 +- Production `62d8bf39-9351-4f9f-8d1b-89e91ec69651`,前一66部署保留。仅Beta feed和独立67 PKG变化,原57个非Beta-feed资产、后端bundle/路由及运行绑定保持。三份feed JSON/no-store,正式/旧feed及HEAD `/download` 仍52。见 `packaging/pkg/output/beta-67/verification.json`。 + +开启Beta后检查更新即可从66升级67。下一build大于67;没有正式发布、提交/推送或整理历史文件。后续主菜单的低频设置请从“设置”进入。 diff --git a/docs/development/3.0-ui-polish.md b/docs/development/3.0-ui-polish.md new file mode 100644 index 0000000..bbadd31 --- /dev/null +++ b/docs/development/3.0-ui-polish.md @@ -0,0 +1,49 @@ +# 原生 UI 第二轮:Beta 66 + +本文件保留66的历史设计与验收。当前主菜单和完成提示的两项修正已发布Beta67,见 [本轮记录](3.0-ui-menu-notice.md)。信息栏设置没有采用之后的简化试稿,仍维持本文件的66设计。 + +2026-10-13。第一轮完成了状态反馈和可用性改善,但维护者认为视觉效果比原版差。本轮重新检查构图、比例、信息层级和操作密度,并保留已经验证的行为。审美是否符合维护者预期仍需实际使用评价;行为测试通过不等于好看。 + +## 设计依据 + +已安装并阅读 [redesign-existing-projects](https://github.com/Leonxlnx/taste-skill/tree/main/skills/redesign-skill) 和 [macos-app-design](https://github.com/petekp/agent-skills/tree/main/skills/macos-app-design),包括后者的原生设计参考。前者用于先诊断再修改,后者用于约束原生组件、字体、系统颜色与菜单栏工具的窗口模型。没有把网页营销布局、装饰纹理或网页动画库搬进 App。 + +设计方向:菜单栏工具的辅助窗口应紧凑、目的明确。主要读数或编辑对象优先;说明弱化但保持可读;次要编辑按需展开。按钮遵循既有生效时机,避免为了统一外观改变使用语义。所有图像均为实际 AppKit 渲染,非效果图。 + +## 具体变化与前后对照 + +| 界面 | 本轮变化 | 第一轮 64/65 | 本轮 66 | +| --- | --- | --- | --- | +| 显示设置 | 模式分段选择;两行内容与样式横向组织;保留最终预览与交换 | [之前](assets/ui-validation-64/display-zh-light.jpg) | [中文浅色](assets/ui-validation-66/display-zh-light.jpg)、[英文深色](assets/ui-validation-66/display-en-dark.jpg) | +| 曲线编辑 | 默认突出曲线;数值面板按需展开,⌘⌥I;减少坐标网格与按钮的视觉竞争 | [之前](assets/ui-validation-64/curve-zh-light.jpg) | [默认](assets/ui-validation-66/curve-zh-light.jpg)、[数值面板](assets/ui-validation-66/curve-inspector-zh-light.jpg)、[英文深色](assets/ui-validation-66/curve-inspector-en-dark.jpg) | +| 调速 | 数字与单位分级;说明、范围、调节方式与主要动作形成稳定顺序 | [之前](assets/ui-validation-64/speed-en-dark.jpg) | [中文](assets/ui-validation-66/speed-zh-light.jpg)、[英文分档](assets/ui-validation-66/speed-stepped-en-dark.jpg)、[应用成功](assets/ui-validation-66/speed-applied-zh-light.jpg) | +| 安全转速 | 沿用相同读数与操作排布;说明对应原110℃规则 | — | [中文](assets/ui-validation-66/safety-zh-light.jpg)、[英文深色](assets/ui-validation-66/safety-en-dark.jpg) | +| 功耗 | 总功耗优先;组件功耗对齐;采样状态与估算说明退到辅助层级 | [之前](assets/ui-validation-64/power-zh-light.jpg) | [数据](assets/ui-validation-66/power-zh-light.jpg)、[不支持](assets/ui-validation-66/power-unsupported-zh-light.jpg) | +| 帮助 | 窗口缩至700×500;侧栏176;只布局当前页面,避免隐藏页面撑大窗口;子页移除重复图标页头 | [之前](assets/ui-validation-64/help-zh-light.jpg) | [简介](assets/ui-validation-66/overview-zh-light.jpg)、[支持](assets/ui-validation-66/support-en-dark.jpg)、[FAQ](assets/ui-validation-66/faq-en-dark.jpg)、[支持作者](assets/ui-validation-66/donation-en-dark.jpg) | +| 反馈 | 原生输入区;关闭与发送置于右下;⌘↩提示独立;“关闭”准确描述窗口动作 | — | [中文](assets/ui-validation-66/feedback-zh-light.jpg)、[隔离发送成功](assets/ui-validation-66/feedback-delivered-en-dark.jpg) | +| 更新 | 缩短窗口;标题、说明、字节进度与动作层级清晰;成功提示使用0.24秒描边勾,减少动态效果时静态显示 | [之前](assets/ui-validation-64/update-known-total.jpg) | [下载](assets/ui-validation-66/update-progress-en-dark.jpg)、[未知总量](assets/ui-validation-66/update-unknown-total-zh-light.jpg)、[失败恢复](assets/ui-validation-66/update-failed-en-dark.jpg)、[完成提示](assets/ui-validation-66/update-complete-en-dark.jpg) | + +![中文显示设置](assets/ui-validation-66/display-zh-light.jpg) + +![中文曲线默认视图](assets/ui-validation-66/curve-zh-light.jpg) + +## 验证与保留边界 + +- Release构建、原生控制器回归及真实UpdateService隔离测试通过。日志:`packaging/pkg/output/ui-validation-66/{release-build,ui-tests,updater-tests}.log`。回归包括显示来源顺序/最后一个来源/交换、曲线精度/范围/无效输入/保存失败、反馈失败草稿与重发、功耗状态与计时器。 +- 实际查看中文浅色、英文深色的主要窗口,以及功耗不支持、分档调速、反馈成功、已知/未知下载总量、打开安装器失败与重试、取消下载、安装版本回执提示。九次图标点击仍隐藏,第十次进入开发者页且Beta未勾选,见[实际截图](assets/ui-validation-66/developer-en-dark.jpg)。未执行完整VoiceOver会话或修改系统减少动态效果设置。 +- 隔离应用使用独立偏好/配置、模拟遥测与网络;更新传输使用真实PKG字节及正确SHA256。外部打开安装器与退出被替代,反馈不发送到生产服务器,不启动控温循环。 +- 对照65源快照,硬件读取、遥测、安全规则、FanManager、ConfigManager、更新服务/协议/下载器/偏好、AppDelegate生命周期、反馈传输、Config字段及PKG脚本保持原样。记录:`packaging/pkg/output/visual-refinement-66/protected-invariants.json`。曲线绘图样式与窗口布局改变,控制算法与配置契约保留。 +- 曲线数值面板默认隐藏,但输入无效而阻止关闭时会自动展开,避免错误字段不可见;面板偏好独立于控制配置。手动调速及安全转速仍需显式应用,展示偏好仍即时生效,曲线仍关闭保存。 +- 完成提示仍只在运行版本达到记录的目标build后显示。App内勾与系统Installer完成勾是两个位置;本轮看到的是隔离应用的版本回执提示,未执行真实安装、重演系统Installer完成页或替换用户 `/Applications` 应用。PKG资源及脚本沿用65,并经构建器展开验证。 + +## 交付与人工验收 + +已发布 **2.9.10 Beta / build66**,安装包[独立下载](https://ifan-59w.pages.dev/iFanControl-beta-2.9.10-66.pkg?build=66)。本地包:`packaging/pkg/output/beta-66/iFanControl-2.9.10.pkg`;SHA256 `202343b95ef5938d1f3508a05d05843a92d03da3efd6f90db5c9427803c24057`。沿用现有ad-hoc应用签名及未公证PKG方式。 + +Production `91662a64-a4c5-41ca-9a1a-7b1109678715`,前一版 `61e8903f-d65f-48fd-b6ad-15dfbbf52431` 保留。只更新Beta feed并新增独立66包;其余56个资产哈希及相同后端bundle、路由、运行绑定保留。线上包重新下载与本地逐字节一致,三份feed为JSON/no-store,正式/旧线路及HEAD `/download` 仍52。证据:`packaging/pkg/output/beta-66/verification.json`。未正式发布、提交/推送源码或修改生产数据。 + +从已安装Beta65启用Beta后检查更新即可升级66。下载安装阶段由65显示,重新打开后看到66新界面和完成提示。人工重点检查原曲线/预设/温度源/展示/Beta偏好保留、常用窗口是否更顺手,以及真实设备控制和系统安装完成体验。下一次Beta可完整验收66的下载界面。后续build必须大于66。 + +## 整理建议 + +先固定可执行的发布、通道保护和原生验收入口,再拆大型main.swift。把UI辅助类及控制器逐块归入同一target下的UI目录,测试夹具集中到Tests;公共Config若独立需另设共享target。生成包、线上快照和图像继续留在忽略的output目录,文档只引用实际交付版本。旧包和实验文件先列清单后归档,不按名称直接删除。详见[原始项目梳理](3.0-preparation.md);本轮未进行目录迁移。 diff --git a/docs/development/3.0-ui-startup.md b/docs/development/3.0-ui-startup.md new file mode 100644 index 0000000..3afbf78 --- /dev/null +++ b/docs/development/3.0-ui-startup.md @@ -0,0 +1,32 @@ +# 启动等待界面:Beta70 + +2026-10-13。维护者观察到启动等待期间仅有一闪而过的更新完成提示,缺少持续反馈,容易被误认为卡住。 + +## 真实启动边界 + +读取原日志证实等待发生在初始化阶段。例如69在08:17:24.636Z开始会话,08:17:32.350Z记录硬件profile(约7.7秒),08:17:34.945Z完成风扇解锁。profile日志之后仍有初始化读取,不能将该日志当完整启动边界。 + +原流程为硬件识别及初始遥测、硬件配置同步、偏好/自启动同步、菜单栏、风扇解锁、控制循环与后台任务。控制循环仍使用已有2秒间隔与20秒遥测启动宽限;这些规则都有原有理由,本轮不改。 + +## 当前体验 + +- 原生轻量面板在硬件探测前显示“正在启动 iFanControl”,带App图标及不确定进度指示器。非模态、非激活,不抢走其他应用的键盘焦点。 +- 文案对应实际开始执行的阶段:识别温度与风扇、恢复设置、准备菜单栏与风扇控制。没有假百分比、固定等待或最短展示计时。 +- 面板先进入窗口系统并刷新内容,初始化安排在主队列下一轮执行。阶段变更强制刷新标签,原生NSProgressIndicator使用独立线程动画,以覆盖原有同步探测的等待。Apple SDK明确说明usesThreadedAnimation为独立线程行为。 +- 菜单栏、解锁调用及控制循环完成初始化,后台任务安排好后立即收起。这里只表示应用初始化完成,不声称每台设备都可控、已完成首轮闭环或已获取每个采样值;无风扇及异常设备继续使用原提示与安全机制。 +- “已更新至…”的原有真实版本回执改为面板收起后显示。首次语言、无风扇提示与温度源说明也在这之后,避免覆盖或叠加启动面板。控制循环在首次引导之前已经准备好。 +- 退出时停止指示器并收起面板。原生动画及系统颜色沿用平台行为,不增加自定义进度动画。 + +![中文浅色启动](assets/ui-validation-70/startup-zh-light.jpg) + +[中文准备阶段](assets/ui-validation-70/startup-controls-zh-light.jpg)、[英文深色启动](assets/ui-validation-70/startup-en-dark.jpg)、[英文准备阶段](assets/ui-validation-70/startup-controls-en-dark.jpg)、[英文恢复设置](assets/ui-validation-70/startup-preferences-en-dark.jpg)、[启动后更新回执](assets/ui-validation-70/ready-update-en-dark.jpg)。截图使用生产控制器与隔离的演示宿主,阶段由验收快捷键推进;不是运行真实硬件的安装后启动录像。 + +## 验证与边界 + +Release、原生控制器回归、真实UpdateService隔离回归通过。新增 `packaging/pkg/test_startup.py` 编译实际AppDelegate/菜单/启动面板,替换硬件I/O、特权命令、功率初始化、匿名网络及弹窗点击,使用独立bundle/配置/日志。覆盖普通、硬件主线程等待6秒、无风扇、解锁失败、待更新build高于当前、首次语言选择六种启动。断言面板在探测前可见,原有控制/菜单/心跳计时器与菜单已存在才关闭,之后才调用回执与引导,未来build回执仍保留。无风扇及首次引导在面板关闭后触发。 + +实际查看中文浅色/英文深色及阶段切换、原生busy indicator与启动后回执。减少动态效果沿用原生指示器行为,没有增加强制最低展示时间。没有缩短或改变现有硬件探测本身;真实启动时长和系统安装后的表现由维护者从69升级70继续验证。 + +源代码改动为新增StartupProgressWindow.swift,以及AppDelegate启动展示/延后一轮初始化/结束收尾/退出收尾;MenuBarManager仅将首次引导调用从setup移到ready之后。FanManager、CommandExecutor、硬件读取器、控制循环、安全规则、更新服务、PKG脚本、帮助侧栏/官网、反馈邀请、状态时间和完成勾精确保持69。证据见 `packaging/pkg/output/beta-70/protected-invariants.json` 与source-changes.diff。 + +Beta70独立发布,正式/旧线路和官网下载维持2.9.9/build52。保留60个旧非Beta-feed资产及相同后端/绑定,公网包逐字节/SHA与feed路由核验见 `packaging/pkg/output/beta-70/verification.json`。未执行安装、真实风扇控制、发送反馈、替换用户/Applications中的应用、正式Release或源码提交/推送。 diff --git a/docs/development/assets/ui-validation-64/curve-en-dark.jpg b/docs/development/assets/ui-validation-64/curve-en-dark.jpg new file mode 100644 index 0000000..1a9749b Binary files /dev/null and b/docs/development/assets/ui-validation-64/curve-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/curve-invalid.jpg b/docs/development/assets/ui-validation-64/curve-invalid.jpg new file mode 100644 index 0000000..0062052 Binary files /dev/null and b/docs/development/assets/ui-validation-64/curve-invalid.jpg differ diff --git a/docs/development/assets/ui-validation-64/curve-save-failure.jpg b/docs/development/assets/ui-validation-64/curve-save-failure.jpg new file mode 100644 index 0000000..48c1f73 Binary files /dev/null and b/docs/development/assets/ui-validation-64/curve-save-failure.jpg differ diff --git a/docs/development/assets/ui-validation-64/curve-zh-light.jpg b/docs/development/assets/ui-validation-64/curve-zh-light.jpg new file mode 100644 index 0000000..6095055 Binary files /dev/null and b/docs/development/assets/ui-validation-64/curve-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-64/developer-en-dark.jpg b/docs/development/assets/ui-validation-64/developer-en-dark.jpg new file mode 100644 index 0000000..36dce0c Binary files /dev/null and b/docs/development/assets/ui-validation-64/developer-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/display-en-dark.jpg b/docs/development/assets/ui-validation-64/display-en-dark.jpg new file mode 100644 index 0000000..df3eeba Binary files /dev/null and b/docs/development/assets/ui-validation-64/display-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/display-zh-light.jpg b/docs/development/assets/ui-validation-64/display-zh-light.jpg new file mode 100644 index 0000000..38b2284 Binary files /dev/null and b/docs/development/assets/ui-validation-64/display-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-64/donation-en-dark.jpg b/docs/development/assets/ui-validation-64/donation-en-dark.jpg new file mode 100644 index 0000000..90d0cd9 Binary files /dev/null and b/docs/development/assets/ui-validation-64/donation-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/faq-en-dark.jpg b/docs/development/assets/ui-validation-64/faq-en-dark.jpg new file mode 100644 index 0000000..444b3df Binary files /dev/null and b/docs/development/assets/ui-validation-64/faq-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/feedback-failure.jpg b/docs/development/assets/ui-validation-64/feedback-failure.jpg new file mode 100644 index 0000000..980ad03 Binary files /dev/null and b/docs/development/assets/ui-validation-64/feedback-failure.jpg differ diff --git a/docs/development/assets/ui-validation-64/help-zh-light.jpg b/docs/development/assets/ui-validation-64/help-zh-light.jpg new file mode 100644 index 0000000..b051d09 Binary files /dev/null and b/docs/development/assets/ui-validation-64/help-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-64/menu-en-dark.jpg b/docs/development/assets/ui-validation-64/menu-en-dark.jpg new file mode 100644 index 0000000..e5ad0a2 Binary files /dev/null and b/docs/development/assets/ui-validation-64/menu-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/pkg65-install-type.jpg b/docs/development/assets/ui-validation-64/pkg65-install-type.jpg new file mode 100644 index 0000000..366b8d2 Binary files /dev/null and b/docs/development/assets/ui-validation-64/pkg65-install-type.jpg differ diff --git a/docs/development/assets/ui-validation-64/pkg65-welcome.jpg b/docs/development/assets/ui-validation-64/pkg65-welcome.jpg new file mode 100644 index 0000000..0dc1f26 Binary files /dev/null and b/docs/development/assets/ui-validation-64/pkg65-welcome.jpg differ diff --git a/docs/development/assets/ui-validation-64/power-unsupported-en-dark.jpg b/docs/development/assets/ui-validation-64/power-unsupported-en-dark.jpg new file mode 100644 index 0000000..db2204f Binary files /dev/null and b/docs/development/assets/ui-validation-64/power-unsupported-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/power-zh-light.jpg b/docs/development/assets/ui-validation-64/power-zh-light.jpg new file mode 100644 index 0000000..1147551 Binary files /dev/null and b/docs/development/assets/ui-validation-64/power-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-64/speed-en-dark.jpg b/docs/development/assets/ui-validation-64/speed-en-dark.jpg new file mode 100644 index 0000000..f01c3db Binary files /dev/null and b/docs/development/assets/ui-validation-64/speed-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/speed-save-failure.jpg b/docs/development/assets/ui-validation-64/speed-save-failure.jpg new file mode 100644 index 0000000..2047957 Binary files /dev/null and b/docs/development/assets/ui-validation-64/speed-save-failure.jpg differ diff --git a/docs/development/assets/ui-validation-64/support-en-dark.jpg b/docs/development/assets/ui-validation-64/support-en-dark.jpg new file mode 100644 index 0000000..2e48e54 Binary files /dev/null and b/docs/development/assets/ui-validation-64/support-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-64/support-zh-light.jpg b/docs/development/assets/ui-validation-64/support-zh-light.jpg new file mode 100644 index 0000000..788ba0d Binary files /dev/null and b/docs/development/assets/ui-validation-64/support-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-64/update-known-total.jpg b/docs/development/assets/ui-validation-64/update-known-total.jpg new file mode 100644 index 0000000..19273c4 Binary files /dev/null and b/docs/development/assets/ui-validation-64/update-known-total.jpg differ diff --git a/docs/development/assets/ui-validation-64/update-open-failure.jpg b/docs/development/assets/ui-validation-64/update-open-failure.jpg new file mode 100644 index 0000000..7fc8d83 Binary files /dev/null and b/docs/development/assets/ui-validation-64/update-open-failure.jpg differ diff --git a/docs/development/assets/ui-validation-64/update-receipt-notice.jpg b/docs/development/assets/ui-validation-64/update-receipt-notice.jpg new file mode 100644 index 0000000..5b5ba26 Binary files /dev/null and b/docs/development/assets/ui-validation-64/update-receipt-notice.jpg differ diff --git a/docs/development/assets/ui-validation-64/update-unknown-total.jpg b/docs/development/assets/ui-validation-64/update-unknown-total.jpg new file mode 100644 index 0000000..4f812e0 Binary files /dev/null and b/docs/development/assets/ui-validation-64/update-unknown-total.jpg differ diff --git a/docs/development/assets/ui-validation-66/curve-inspector-en-dark.jpg b/docs/development/assets/ui-validation-66/curve-inspector-en-dark.jpg new file mode 100644 index 0000000..e4f3a9a Binary files /dev/null and b/docs/development/assets/ui-validation-66/curve-inspector-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/curve-inspector-zh-light.jpg b/docs/development/assets/ui-validation-66/curve-inspector-zh-light.jpg new file mode 100644 index 0000000..73ce828 Binary files /dev/null and b/docs/development/assets/ui-validation-66/curve-inspector-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/curve-zh-light.jpg b/docs/development/assets/ui-validation-66/curve-zh-light.jpg new file mode 100644 index 0000000..b12681a Binary files /dev/null and b/docs/development/assets/ui-validation-66/curve-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/developer-en-dark.jpg b/docs/development/assets/ui-validation-66/developer-en-dark.jpg new file mode 100644 index 0000000..542ccb7 Binary files /dev/null and b/docs/development/assets/ui-validation-66/developer-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/display-en-dark.jpg b/docs/development/assets/ui-validation-66/display-en-dark.jpg new file mode 100644 index 0000000..58f39fe Binary files /dev/null and b/docs/development/assets/ui-validation-66/display-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/display-zh-light.jpg b/docs/development/assets/ui-validation-66/display-zh-light.jpg new file mode 100644 index 0000000..de03ad7 Binary files /dev/null and b/docs/development/assets/ui-validation-66/display-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/donation-en-dark.jpg b/docs/development/assets/ui-validation-66/donation-en-dark.jpg new file mode 100644 index 0000000..66275cf Binary files /dev/null and b/docs/development/assets/ui-validation-66/donation-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/faq-en-dark.jpg b/docs/development/assets/ui-validation-66/faq-en-dark.jpg new file mode 100644 index 0000000..d4e35d8 Binary files /dev/null and b/docs/development/assets/ui-validation-66/faq-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/feedback-delivered-en-dark.jpg b/docs/development/assets/ui-validation-66/feedback-delivered-en-dark.jpg new file mode 100644 index 0000000..a8d0fdd Binary files /dev/null and b/docs/development/assets/ui-validation-66/feedback-delivered-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/feedback-zh-light.jpg b/docs/development/assets/ui-validation-66/feedback-zh-light.jpg new file mode 100644 index 0000000..ed85d10 Binary files /dev/null and b/docs/development/assets/ui-validation-66/feedback-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/menu-en-dark.jpg b/docs/development/assets/ui-validation-66/menu-en-dark.jpg new file mode 100644 index 0000000..1b429c2 Binary files /dev/null and b/docs/development/assets/ui-validation-66/menu-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/overview-zh-light.jpg b/docs/development/assets/ui-validation-66/overview-zh-light.jpg new file mode 100644 index 0000000..a996e76 Binary files /dev/null and b/docs/development/assets/ui-validation-66/overview-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/power-unsupported-zh-light.jpg b/docs/development/assets/ui-validation-66/power-unsupported-zh-light.jpg new file mode 100644 index 0000000..44a4036 Binary files /dev/null and b/docs/development/assets/ui-validation-66/power-unsupported-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/power-zh-light.jpg b/docs/development/assets/ui-validation-66/power-zh-light.jpg new file mode 100644 index 0000000..22d0bde Binary files /dev/null and b/docs/development/assets/ui-validation-66/power-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/safety-en-dark.jpg b/docs/development/assets/ui-validation-66/safety-en-dark.jpg new file mode 100644 index 0000000..3ff3fd4 Binary files /dev/null and b/docs/development/assets/ui-validation-66/safety-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/safety-zh-light.jpg b/docs/development/assets/ui-validation-66/safety-zh-light.jpg new file mode 100644 index 0000000..3e54694 Binary files /dev/null and b/docs/development/assets/ui-validation-66/safety-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/speed-applied-zh-light.jpg b/docs/development/assets/ui-validation-66/speed-applied-zh-light.jpg new file mode 100644 index 0000000..35076ac Binary files /dev/null and b/docs/development/assets/ui-validation-66/speed-applied-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/speed-stepped-en-dark.jpg b/docs/development/assets/ui-validation-66/speed-stepped-en-dark.jpg new file mode 100644 index 0000000..0e272fa Binary files /dev/null and b/docs/development/assets/ui-validation-66/speed-stepped-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/speed-zh-light.jpg b/docs/development/assets/ui-validation-66/speed-zh-light.jpg new file mode 100644 index 0000000..7b54a1e Binary files /dev/null and b/docs/development/assets/ui-validation-66/speed-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-66/support-en-dark.jpg b/docs/development/assets/ui-validation-66/support-en-dark.jpg new file mode 100644 index 0000000..f0f3906 Binary files /dev/null and b/docs/development/assets/ui-validation-66/support-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/update-complete-en-dark.jpg b/docs/development/assets/ui-validation-66/update-complete-en-dark.jpg new file mode 100644 index 0000000..be0f25f Binary files /dev/null and b/docs/development/assets/ui-validation-66/update-complete-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/update-failed-en-dark.jpg b/docs/development/assets/ui-validation-66/update-failed-en-dark.jpg new file mode 100644 index 0000000..5e1394d Binary files /dev/null and b/docs/development/assets/ui-validation-66/update-failed-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/update-progress-en-dark.jpg b/docs/development/assets/ui-validation-66/update-progress-en-dark.jpg new file mode 100644 index 0000000..e73fb90 Binary files /dev/null and b/docs/development/assets/ui-validation-66/update-progress-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-66/update-unknown-total-zh-light.jpg b/docs/development/assets/ui-validation-66/update-unknown-total-zh-light.jpg new file mode 100644 index 0000000..2e77e96 Binary files /dev/null and b/docs/development/assets/ui-validation-66/update-unknown-total-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-67/menu-manual-en-dark.jpg b/docs/development/assets/ui-validation-67/menu-manual-en-dark.jpg new file mode 100644 index 0000000..2e277b8 Binary files /dev/null and b/docs/development/assets/ui-validation-67/menu-manual-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-67/menu-settings-zh-light.jpg b/docs/development/assets/ui-validation-67/menu-settings-zh-light.jpg new file mode 100644 index 0000000..629f51c Binary files /dev/null and b/docs/development/assets/ui-validation-67/menu-settings-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-67/menu-temperature-en-dark.jpg b/docs/development/assets/ui-validation-67/menu-temperature-en-dark.jpg new file mode 100644 index 0000000..60ceed9 Binary files /dev/null and b/docs/development/assets/ui-validation-67/menu-temperature-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-67/menu-zh-light.jpg b/docs/development/assets/ui-validation-67/menu-zh-light.jpg new file mode 100644 index 0000000..782f608 Binary files /dev/null and b/docs/development/assets/ui-validation-67/menu-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-67/notice-en-dark.jpg b/docs/development/assets/ui-validation-67/notice-en-dark.jpg new file mode 100644 index 0000000..f27eff9 Binary files /dev/null and b/docs/development/assets/ui-validation-67/notice-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-67/notice-en-light.jpg b/docs/development/assets/ui-validation-67/notice-en-light.jpg new file mode 100644 index 0000000..f00fa49 Binary files /dev/null and b/docs/development/assets/ui-validation-67/notice-en-light.jpg differ diff --git a/docs/development/assets/ui-validation-67/notice-zh-dark.jpg b/docs/development/assets/ui-validation-67/notice-zh-dark.jpg new file mode 100644 index 0000000..ea42921 Binary files /dev/null and b/docs/development/assets/ui-validation-67/notice-zh-dark.jpg differ diff --git a/docs/development/assets/ui-validation-68/invitation-opens-feedback-en.jpg b/docs/development/assets/ui-validation-68/invitation-opens-feedback-en.jpg new file mode 100644 index 0000000..f2ecc3c Binary files /dev/null and b/docs/development/assets/ui-validation-68/invitation-opens-feedback-en.jpg differ diff --git a/docs/development/assets/ui-validation-68/invitation-opens-feedback-zh.jpg b/docs/development/assets/ui-validation-68/invitation-opens-feedback-zh.jpg new file mode 100644 index 0000000..b51e464 Binary files /dev/null and b/docs/development/assets/ui-validation-68/invitation-opens-feedback-zh.jpg differ diff --git a/docs/development/assets/ui-validation-68/menu-manual-en-dark.jpg b/docs/development/assets/ui-validation-68/menu-manual-en-dark.jpg new file mode 100644 index 0000000..b9bba26 Binary files /dev/null and b/docs/development/assets/ui-validation-68/menu-manual-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-68/menu-zh-light.jpg b/docs/development/assets/ui-validation-68/menu-zh-light.jpg new file mode 100644 index 0000000..d4b18b1 Binary files /dev/null and b/docs/development/assets/ui-validation-68/menu-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-69/help-en-dark.jpg b/docs/development/assets/ui-validation-69/help-en-dark.jpg new file mode 100644 index 0000000..7e971e6 Binary files /dev/null and b/docs/development/assets/ui-validation-69/help-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-69/help-en-developer.jpg b/docs/development/assets/ui-validation-69/help-en-developer.jpg new file mode 100644 index 0000000..f18afe4 Binary files /dev/null and b/docs/development/assets/ui-validation-69/help-en-developer.jpg differ diff --git a/docs/development/assets/ui-validation-69/help-zh-diagnostics.jpg b/docs/development/assets/ui-validation-69/help-zh-diagnostics.jpg new file mode 100644 index 0000000..d8f3de8 Binary files /dev/null and b/docs/development/assets/ui-validation-69/help-zh-diagnostics.jpg differ diff --git a/docs/development/assets/ui-validation-69/help-zh-light.jpg b/docs/development/assets/ui-validation-69/help-zh-light.jpg new file mode 100644 index 0000000..000143c Binary files /dev/null and b/docs/development/assets/ui-validation-69/help-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-70/ready-update-en-dark.jpg b/docs/development/assets/ui-validation-70/ready-update-en-dark.jpg new file mode 100644 index 0000000..667589c Binary files /dev/null and b/docs/development/assets/ui-validation-70/ready-update-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-70/startup-controls-en-dark.jpg b/docs/development/assets/ui-validation-70/startup-controls-en-dark.jpg new file mode 100644 index 0000000..89e5ead Binary files /dev/null and b/docs/development/assets/ui-validation-70/startup-controls-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-70/startup-controls-zh-light.jpg b/docs/development/assets/ui-validation-70/startup-controls-zh-light.jpg new file mode 100644 index 0000000..47e1ad3 Binary files /dev/null and b/docs/development/assets/ui-validation-70/startup-controls-zh-light.jpg differ diff --git a/docs/development/assets/ui-validation-70/startup-en-dark.jpg b/docs/development/assets/ui-validation-70/startup-en-dark.jpg new file mode 100644 index 0000000..76b8152 Binary files /dev/null and b/docs/development/assets/ui-validation-70/startup-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-70/startup-preferences-en-dark.jpg b/docs/development/assets/ui-validation-70/startup-preferences-en-dark.jpg new file mode 100644 index 0000000..ead954b Binary files /dev/null and b/docs/development/assets/ui-validation-70/startup-preferences-en-dark.jpg differ diff --git a/docs/development/assets/ui-validation-70/startup-zh-light.jpg b/docs/development/assets/ui-validation-70/startup-zh-light.jpg new file mode 100644 index 0000000..6cfd75f Binary files /dev/null and b/docs/development/assets/ui-validation-70/startup-zh-light.jpg differ diff --git a/docs/releases/2.9.10-beta.70.json b/docs/releases/2.9.10-beta.70.json new file mode 100644 index 0000000..0754ba7 --- /dev/null +++ b/docs/releases/2.9.10-beta.70.json @@ -0,0 +1,59 @@ +{ + "schema_version": 1, + "tag": "v2.9.10-beta.70", + "version": "2.9.10", + "build": 70, + "channel": "beta", + "source_hashes": { + "Sources/FanCurveEditor/FanCurveWindow.swift": "88531c31050719d848befd9afbfdc39bd0bd04f9e178af1204f458f19035003c", + "Sources/FanCurveEditor/FanCurveView.swift": "152d2535b91301b994f900ad39f39395dba5f02ed82a7a478e5590432f4fc628", + "Sources/MacFanControl/UpdatePreferences.swift": "dc2ca7248821b63b8062b2dbb3749b42f897f5c53e22ee1ed8c1defa3d6027f3", + "Sources/MacFanControl/SensorCatalog.swift": "cc56f4afb4c92d5dcb9b5b68fd59bee8641b5c1717a993028997ab3b919aeee8", + "Sources/MacFanControl/UpdatePackageDownloader.swift": "a17b6c5465311ad4a3a87490c57e1c5a67ce89e1e335b404e023258c93b0ee8f", + "Sources/MacFanControl/MiniDisplayFormatter.swift": "5e63e8f67996a61c4bbfce06426ad9842939daf254bfa68f22522619770d22ef", + "Sources/MacFanControl/NativeInterface.swift": "276c9920f2328232587448f9105503260f8dd9b53d98622f7b8494e541443eed", + "Sources/MacFanControl/UpdateManifest.swift": "009865d8739a1273eb9db1a9781b9d08816cc312ca30281c2b8f17d4b3d379b5", + "Sources/MacFanControl/UpdateProgressWindow.swift": "25bb00e169fb369524118ec5146ecd2ac21eb0cd4169237a531ea57b9e9af501", + "Sources/MacFanControl/main.swift": "c417f4a4e939e0955a3e6aec253fcf36051c467ff38768fec08d3db92d777920", + "Sources/MacFanControl/InteractionNotice.swift": "82865a5ab883fe68e02f28bcc3d896bccebb772321a9975ed2b1d9cff1e85c60", + "packaging/pkg/scripts/preinstall": "40b9a82c8f6d015a47d30616567f71ee920da5ff52ad92ffb2a8fb8d502b31aa", + "packaging/pkg/scripts/postinstall": "d254053b6341f7fce5b24816462ba92611cf7bd40ec18b4a019e64cb64934bfa", + "Sources/MacFanControl/StartupProgressWindow.swift": "4a5bcfc96ac3aee968058c06c807968d48ab0529310777ca0abeab70acaf6057", + "packaging/app/Info.plist": "96e5bea941d983b527d45c71bf75fca18b6c0c2170f74a71185816e3c2deffb6", + "icon.png": "b460e15c3834479a771bcc200a7a7622c53a5fd84dd62392bb179cc646b52324", + "kentsmc": "56b9b4f7239b4b043b8b69c92ed23f184eaf55a6b7d67c419d899da3a7254605", + "Sources/IOReportBridge/bridge.c": "8a06e4da0d02bccd33220b67fceb05bd3cb6c5606c224308a9a8e3beea30f98d", + "Sources/IOReportBridge/include/bridge.h": "1e03653689f855d2748d1cc2cf9b31211c2d376cd21b199dfb27bfebda7168f9", + "Package.swift": "7368576abd0cb188d9d6af3a6ca0d993a6d742102072f32fab7138532e018916" + }, + "package": { + "url": "https://ifan-59w.pages.dev/iFanControl-beta-2.9.10-70.pkg?build=70", + "sha256": "b446cf03a71c2d3e3652c715f702d7ad14ce4f4c52cc4fe5f53a709090b5cf7a", + "executable_sha256": "c63a0ec8505c2b5b78d395363340bebb8027cb7ed0920f077b591b6d2cf87481" + }, + "publication": { + "deployment": "6066e693-f572-417a-b5b9-cd784c371078", + "url": "https://6066e693.ifan-59w.pages.dev", + "published_at": "2026-10-13T08:39:12.510Z", + "stable_build": 52, + "legacy_build": 52, + "download_route_build": 52, + "prior_non_beta_feed_assets_preserved": 60 + }, + "validation": { + "release_build": true, + "update_contracts_and_real_updater": true, + "native_ui_controller_regressions": true, + "actual_delegate_launch_scenarios": [ + "normal", + "synchronous_probe_wait_6s", + "fanless", + "unlock_failure", + "future_receipt", + "first_language" + ], + "native_ui_inspected": true, + "actual_package_install_performed_by_agent": false + }, + "provenance": "Recorded after publication. Tag matches the preserved Beta70 source/input hashes; it was not embedded in the previously published PKG." +} diff --git a/iFanControl-2.8.27/iFanControl.app/Contents/Info.plist b/iFanControl-2.8.27/iFanControl.app/Contents/Info.plist deleted file mode 100644 index b3a2989..0000000 --- a/iFanControl-2.8.27/iFanControl.app/Contents/Info.plist +++ /dev/null @@ -1,30 +0,0 @@ - - - - - CFBundleDevelopmentRegion - zh_CN - CFBundleDisplayName - iFanControl - CFBundleExecutable - iFanControl - CFBundleIconFile - icon - CFBundleIdentifier - com.ifancontrol.app - CFBundleName - iFanControl - CFBundlePackageType - APPL - CFBundleShortVersionString - 2.8.27 - CFBundleVersion - 27 - LSMinimumSystemVersion - 13.0 - LSUIElement - - NSHighResolutionCapable - - - diff --git a/iFanControl-2.8.27/iFanControl.app/Contents/MacOS/iFanControl b/iFanControl-2.8.27/iFanControl.app/Contents/MacOS/iFanControl deleted file mode 100755 index 422209d..0000000 Binary files a/iFanControl-2.8.27/iFanControl.app/Contents/MacOS/iFanControl and /dev/null differ diff --git a/iFanControl-2.8.27/iFanControl.app/Contents/Resources/config.json b/iFanControl-2.8.27/iFanControl.app/Contents/Resources/config.json deleted file mode 100644 index 16f00ae..0000000 --- a/iFanControl-2.8.27/iFanControl.app/Contents/Resources/config.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "1.0", - "curve": [ - {"temperature": 20, "rpm": 0}, - {"temperature": 47.3547794117647, "rpm": 650}, - {"temperature": 68.21691176470588, "rpm": 1642}, - {"temperature": 86.18520220588235, "rpm": 2997}, - {"temperature": 100, "rpm": 4900} - ], - "mode": "auto", - "autoStart": true, - "maxRPM": 4900, - "manualRPM": 2168 -} \ No newline at end of file diff --git a/iFanControl-2.8.27/iFanControl.app/Contents/Resources/icon.png b/iFanControl-2.8.27/iFanControl.app/Contents/Resources/icon.png deleted file mode 100644 index 5186e56..0000000 Binary files a/iFanControl-2.8.27/iFanControl.app/Contents/Resources/icon.png and /dev/null differ diff --git a/iFanControl-2.8.27/iFanControl.app/Contents/_CodeSignature/CodeResources b/iFanControl-2.8.27/iFanControl.app/Contents/_CodeSignature/CodeResources deleted file mode 100644 index 3d3b60a..0000000 --- a/iFanControl-2.8.27/iFanControl.app/Contents/_CodeSignature/CodeResources +++ /dev/null @@ -1,139 +0,0 @@ - - - - - files - - Resources/config.json - - WsBCzdlOj0GZxVGR49KH1vCHzXA= - - Resources/icon.png - - B9FxiCLiY0oY90L+6tsyjO5Iguk= - - - files2 - - Resources/config.json - - hash2 - - /vXaTenDmZXPjRRLd3+w4l482XqnG9P/eQ/guDRJsOQ= - - - Resources/icon.png - - hash2 - - tGDhXDg0R5p3G8wgCnp2IsU6X9hN1iOSuxecxka1IyQ= - - - - rules - - ^Resources/ - - ^Resources/.*\.lproj/ - - optional - - weight - 1000 - - ^Resources/.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Resources/Base\.lproj/ - - weight - 1010 - - ^version.plist$ - - - rules2 - - .*\.dSYM($|/) - - weight - 11 - - ^(.*/)?\.DS_Store$ - - omit - - weight - 2000 - - ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ - - nested - - weight - 10 - - ^.* - - ^Info\.plist$ - - omit - - weight - 20 - - ^PkgInfo$ - - omit - - weight - 20 - - ^Resources/ - - weight - 20 - - ^Resources/.*\.lproj/ - - optional - - weight - 1000 - - ^Resources/.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Resources/Base\.lproj/ - - weight - 1010 - - ^[^/]+$ - - nested - - weight - 10 - - ^embedded\.provisionprofile$ - - weight - 20 - - ^version\.plist$ - - weight - 20 - - - - diff --git a/iFanControl.app/Contents/MacOS/iFanControl b/iFanControl.app/Contents/MacOS/iFanControl deleted file mode 100755 index 4d1126b..0000000 Binary files a/iFanControl.app/Contents/MacOS/iFanControl and /dev/null differ diff --git a/iFanControl.app/Contents/Resources/icon.png b/iFanControl.app/Contents/Resources/icon.png deleted file mode 100644 index 5186e56..0000000 Binary files a/iFanControl.app/Contents/Resources/icon.png and /dev/null differ diff --git a/iFanControl.app/Contents/_CodeSignature/CodeResources b/iFanControl.app/Contents/_CodeSignature/CodeResources deleted file mode 100644 index b2d7a31..0000000 --- a/iFanControl.app/Contents/_CodeSignature/CodeResources +++ /dev/null @@ -1,128 +0,0 @@ - - - - - files - - Resources/icon.png - - B9FxiCLiY0oY90L+6tsyjO5Iguk= - - - files2 - - Resources/icon.png - - hash2 - - tGDhXDg0R5p3G8wgCnp2IsU6X9hN1iOSuxecxka1IyQ= - - - - rules - - ^Resources/ - - ^Resources/.*\.lproj/ - - optional - - weight - 1000 - - ^Resources/.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Resources/Base\.lproj/ - - weight - 1010 - - ^version.plist$ - - - rules2 - - .*\.dSYM($|/) - - weight - 11 - - ^(.*/)?\.DS_Store$ - - omit - - weight - 2000 - - ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ - - nested - - weight - 10 - - ^.* - - ^Info\.plist$ - - omit - - weight - 20 - - ^PkgInfo$ - - omit - - weight - 20 - - ^Resources/ - - weight - 20 - - ^Resources/.*\.lproj/ - - optional - - weight - 1000 - - ^Resources/.*\.lproj/locversion.plist$ - - omit - - weight - 1100 - - ^Resources/Base\.lproj/ - - weight - 1010 - - ^[^/]+$ - - nested - - weight - 10 - - ^embedded\.provisionprofile$ - - weight - 20 - - ^version\.plist$ - - weight - 20 - - - - diff --git a/iFanControl.app/Contents/Info.plist b/packaging/app/Info.plist similarity index 88% rename from iFanControl.app/Contents/Info.plist rename to packaging/app/Info.plist index a23d36a..c45bcd7 100644 --- a/iFanControl.app/Contents/Info.plist +++ b/packaging/app/Info.plist @@ -17,9 +17,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2.9.9 + 2.9.10 CFBundleVersion - 52 + 70 + IFanControlReleaseChannel + beta LSMinimumSystemVersion 26.0 LSUIElement diff --git a/packaging/app/README.md b/packaging/app/README.md new file mode 100644 index 0000000..a61f04b --- /dev/null +++ b/packaging/app/README.md @@ -0,0 +1,5 @@ +# App源元数据 + +`Info.plist` 是App版本、build、通道和系统要求的提交来源。`scripts/build_app.py` 将它与编译结果及根目录图标组装成 `build/iFanControl.app`,再执行原有ad-hoc签名校验。 + +未来发布先修改并提交此文件。生成的App bundle和签名不入库;根目录原有 `iFanControl.app` 可暂时保留作为本机历史构建,不再作为版本配置来源。 diff --git a/packaging/pkg/README.md b/packaging/pkg/README.md index 6434b58..811fb9a 100644 --- a/packaging/pkg/README.md +++ b/packaging/pkg/README.md @@ -3,8 +3,10 @@ 从 active 工程的 App 与 kentsmc 构建系统安装包,不执行安装。 ```sh -python3 packaging/pkg/build.py --app iFanControl.app --tool kentsmc +python3 scripts/build_app.py +python3 packaging/pkg/build.py --app build/iFanControl.app --tool kentsmc python3 packaging/pkg/verify_channels.py +bash packaging/pkg/test_updates.sh ``` 也支持用 --source-zip /path/archive.zip --manifest /path/legacy-manifest.json 读取经过校验的旧版 ZIP。 @@ -14,3 +16,42 @@ python3 packaging/pkg/verify_channels.py 构建会展开最终安装包并核对文件、脚本、代码签名与校验值。输出中的 build-info.json 记录来源与验证。 旧版 update-manifest.json 和 iFanControl-legacy-2.9.9.zip 永久保留在 2.9.9/build52;legacy-channel.json 固定版本与哈希,verify_channels.py 在发布前检查。2.9.9/build52 开始只读取 update-manifest-pkg.json,后续发布只修改新通道和最新 PKG,绝不覆盖旧通道或旧 ZIP。 + +## 开发者 Beta 通道(2.9.10/build62 起) + +“关于/帮助 → 简介”的大 App 图标在同次开窗中点击十次,解锁持久保存的开发者模式。解锁不会自动启用 Beta;开发者页单独勾选后,手动与启动后的自动检查都读取 `update-manifest-beta.json`,复用正式版完整 PKG 更新链路。关闭 Beta 或退出开发者模式只改变后续检查路线,不降级当前 App。 + +正式和 Beta 共用递增的整数 build。Beta manifest 加 `release_channel: "beta"`,App Info.plist 加 `IFanControlReleaseChannel: "beta"`;系统版本仍为数字 `X.Y.Z`,构建号仍为整数。manifest 的可选 `release_url` 使用对应 GitHub Release/tag 的完整 HTTPS 链接,避免 Beta 更新失败时跳到错误的正式版 tag。 + +Beta 包使用独立且包含 build 的文件名,例如 `iFanControl-beta-3.0.0-63.pkg?build=63`,不能覆盖 `iFanControl-macOS.pkg` 或 legacy ZIP。Beta feed 也可以用 `release_channel: "stable"` 指向正式版本的独立副本;正式发布时同步它,避免 Beta 订阅者错过更高 build 的正式版。两份 native manifest 均需 `Cache-Control: no-store`。官网版本展示及 `/download` 始终保持正式线路。 + +`verify_channels.py` 在 Beta manifest 存在时额外校验隔离包、哈希和 build 顺序。`test_updates.sh` 验证兼容字段、偏好与点击边界,并编译真实 UpdateService 的隔离夹具:网络、弹窗选择、安装器打开和退出替换为测试接口,不执行实际安装或硬件控制。这些检查已接入 CI。 + +2.9.10 种子包在 `output/release-candidate/`,由开发者手动本地安装。用户确认先仅部署 Beta 线路,正式渠道维持 2.9.9/build52;初始 Beta feed 指向同一 build62 种子包的独立副本,`release_channel: "stable"` 表示该包的构建身份,不代表已向正式用户发布。下一 Beta build 必须大于 62,真实在线安装联调使用这个更高 build。 + +首次 Beta 部署准备与校验记录在 `output/beta-bootstrap-62/`。`release-candidate/channel-preview/` 是整套正式发布预览,**不要整体部署到生产做 Beta 测试**。Beta 单独发布只添加/更新 Beta manifest、独立 PKG 和该 feed 的 no-store 规则;不改正式 feed/包、`/download`、官网版本/时间线或正式 GitHub Release。Beta manifest 可省略尚不存在的 `release_url`,客户端失败提示会使用 Releases 首页。 + +2026-10-12 已单独发布2.9.10 Beta/build63,包与核验记录在 `output/beta-63/`,用于62→63在线安装联调。只增加Beta身份并递增build,签名前可执行代码与62一致,资源及安装脚本保留。云端只更新Beta feed和独立63 PKG,上一后端bundle/routes及其余54个资产完全保留;线上包/manifest及正式52线路核验通过。下一Beta或正式build须大于63。 + + +## UI Beta 验收(2026-10-13) + +当前稳定/旧线路52保持;本地64为验收起点,Beta在线65为同代码升级目标。完整原生证据与人工步骤见 `../../docs/development/3.0-ui-delivery.md`。 + +```bash +swift build -c release +bash packaging/pkg/test_updates.sh +python3 packaging/pkg/test_ui.py +``` + +原生控制器测试在macOS运行,编译实际源码并使用临时独立bundle/配置目录,替换反馈网络和功率初始化等外部效果。覆盖曲线精度/边界/最小布局/保存失败与配置保留、展示偏好、功率状态/计时器、反馈草稿/重开/发送状态;不安装、不控制风扇、不发送真实反馈。Beta线上资产冻结保护与PKG展开/签名/脚本验证继续按原发布流程执行。 + +## 启动等待回归(Beta70) + +```sh +python3 packaging/pkg/test_startup.py +``` + +在独立AppKit夹具中运行真实AppDelegate启动流程,替换硬件/特权命令/网络与引导弹窗点击,使用独立偏好、配置和日志。覆盖普通、主线程探测等待6秒、无风扇、解锁失败、未来build回执及首次语言选择;核对先显示等待界面,再初始化菜单/控制循环,收起后才显示更新回执和启动引导。不会安装或控制真实风扇。真实运行时长仍须维护者实际升级后观察。 + +源码元数据已迁到 `../app/Info.plist`,App由根目录 `scripts/build_app.py` 生成,产物不入Git。分支/标签/发布证据见 `../../CONTRIBUTING.md`;Beta70已发布输入哈希保存在 `../../docs/releases/2.9.10-beta.70.json`。 diff --git a/packaging/pkg/native_test_bridge.py b/packaging/pkg/native_test_bridge.py new file mode 100644 index 0000000..b2c04a4 --- /dev/null +++ b/packaging/pkg/native_test_bridge.py @@ -0,0 +1,14 @@ +"""Compile the tracked C bridge inside each isolated fixture, on either Mac arch.""" +from pathlib import Path +import subprocess + +ROOT = Path(__file__).resolve().parents[2] + + +def compile_test_bridge(out): + module = out / 'IOReportBridge.modulemap' + header = ROOT / 'Sources/IOReportBridge/include/bridge.h' + module.write_text(f'module IOReportBridge {{ header "{header}" export * }}\n') + obj = out / 'IOReportBridge.o' + subprocess.run(['xcrun', 'clang', '-I', str(header.parent), '-c', str(ROOT / 'Sources/IOReportBridge/bridge.c'), '-o', str(obj)], check=True) + return module, obj diff --git a/packaging/pkg/test_curve_ui.swift b/packaging/pkg/test_curve_ui.swift new file mode 100644 index 0000000..e05a657 --- /dev/null +++ b/packaging/pkg/test_curve_ui.swift @@ -0,0 +1,52 @@ +// Compiled next to the actual curve controller in an isolated module; no production config writes. +@MainActor extension FanCurvePresetWindowController { + public static func runUIRegression() throws { + let precise = [FanPoint(temperature:20,rpm:600),FanPoint(temperature:41.123456789,rpm:700),FanPoint(temperature:62.654321,rpm:1400),FanPoint(temperature:84.8,rpm:2800),FanPoint(temperature:100,rpm:3500)] + let configDir = UIRegressionPaths.config + let file = configDir.appendingPathComponent("config.json") + try FileManager.default.createDirectory(at:configDir,withIntermediateDirectories:true) + let seed = Config(version:"1.0",curve:precise,mode:"manual",autoStart:false,maxRPM:4900,manualRPM:2345,manualRPMControlMode:"stepped",manualRPMStep:500,safetyFloorRPM:3600,temperatureSourceMode:"fixed",selectedTemperatureSensorKey:"Tp01",curvePresetNames:["A","B","C"],curvePresets:[precise,precise,precise],activeCurvePreset:1) + try JSONEncoder().encode(seed).write(to:file,options:.atomic) + let controller = FanCurvePresetWindowController(fanCurve:precise,maxRPM:4900,presetNames:["A","B","C"],presets:[precise,precise,precise],activePresetIndex:1) + controller.window!.setFrame(NSRect(x:0,y:0,width:900,height:500),display:false) + controller.window!.contentView!.layoutSubtreeIfNeeded() + let root = controller.window!.contentView!.subviews.first! + precondition(root.frame.minY >= 0 && root.frame.maxY <= controller.window!.contentView!.bounds.height, "AppKit minimum layout must keep the editor inside the window") + func read() throws -> Config { try JSONDecoder().decode(Config.self,from:Data(contentsOf:file)) } + precondition(controller.windowShouldClose(controller.window!)) + var stored = try read() + precondition(stored.curve[1].temperature == precise[1].temperature,"Untouched display rounding must not alter persisted temperatures") + precondition(stored.mode==seed.mode && stored.autoStart==seed.autoStart && stored.manualRPM==seed.manualRPM && stored.manualRPMControlMode==seed.manualRPMControlMode && stored.manualRPMStep==seed.manualRPMStep && stored.safetyFloorRPM==seed.safetyFloorRPM && stored.temperatureSourceMode==seed.temperatureSourceMode && stored.selectedTemperatureSensorKey==seed.selectedTemperatureSensorKey) + let rpm = controller.pointFields[1].rpm + rpm.stringValue="1234"; controller.pointEdited(rpm) + precondition(controller.fanCurveView.fanCurve[1].temperature == precise[1].temperature,"RPM-only edit must retain temperature precision") + let temp = controller.pointFields[1].temperature + temp.stringValue="99";controller.pointEdited(temp) + precondition(controller.fanCurveView.fanCurve[1].temperature == precise[2].temperature-1) + rpm.stringValue="99999";controller.pointEdited(rpm) + precondition(controller.fanCurveView.fanCurve[1].rpm==4900) + rpm.stringValue="-1";controller.pointEdited(rpm) + precondition(controller.fanCurveView.fanCurve[1].rpm==0) + let originalBytes=try Data(contentsOf:file) + rpm.stringValue="invalid";controller.pointEdited(rpm) + precondition(!controller.windowShouldClose(controller.window!)) + precondition(!controller.pointStack.isHidden && controller.showNumericEditor, "An invalid hidden editor must be revealed when closing is vetoed") + let afterInvalid = try Data(contentsOf:file) + precondition(afterInvalid==originalBytes) + controller.resetCurve() + precondition(!controller.hasInvalidPointInput) + controller.switchPreset(to:2) + precondition(controller.activePresetIndex==2) + precondition(controller.windowShouldClose(controller.window!)) + stored=try read();precondition(stored.activeCurvePreset==2 && stored.curvePresets?.count==3) + let backup=configDir.appendingPathComponent("config-backup.json") + try FileManager.default.moveItem(at:file,to:backup) + try FileManager.default.createDirectory(at:file,withIntermediateDirectories:false) + precondition(!controller.windowShouldClose(controller.window!),"Write failure must veto window closing") + precondition(NativeUITestAlert.lastMessage == "保存未完成" || NativeUITestAlert.lastMessage == "Save Not Completed") + try FileManager.default.removeItem(at:file) + try FileManager.default.moveItem(at:backup,to:file) + precondition(controller.windowShouldClose(controller.window!)) + print("PASS: curve precision, RPM-only edits, ordered temperatures/RPM bounds, invalid-input retention, preset reset/switch, config-field preservation and save-failure close veto") + } +} diff --git a/packaging/pkg/test_startup.py b/packaging/pkg/test_startup.py new file mode 100644 index 0000000..89266ba --- /dev/null +++ b/packaging/pkg/test_startup.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Exercise actual launch ordering with isolated state and hardware/network seams.""" +from pathlib import Path +import argparse +import plistlib +import subprocess +import tempfile +from native_test_bridge import compile_test_bridge + +ROOT = Path(__file__).resolve().parents[2] + + +def replace_function(source, signature, body): + start = source.index(signature) + opening = source.index("{", start) + depth = 1 + end = opening + 1 + while depth: + depth += (source[end] == "{") - (source[end] == "}") + end += 1 + return source[:opening + 1] + "\n" + body + "\n }" + source[end:] + + +def build(out): + out.mkdir(parents=True, exist_ok=True) + bridge_map, bridge_object = compile_test_bridge(out) + expr = 'FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("MacFanControl")' + config = 'URL(fileURLWithPath: "' + str(out / 'fixture-config') + '", isDirectory: true)' + curve = (ROOT / 'Sources/FanCurveEditor/FanCurveWindow.swift').read_text().replace(expr, config) + (out / 'FanCurveWindow.swift').write_text(curve) + subprocess.run(['swiftc', '-swift-version', '6', '-emit-library', '-emit-module', '-emit-module-path', str(out / 'FanCurveEditor.swiftmodule'), '-module-name', 'FanCurveEditor', str(ROOT / 'Sources/FanCurveEditor/FanCurveView.swift'), str(out / 'FanCurveWindow.swift'), '-o', str(out / 'libFanCurveEditor.dylib')], check=True) + s = (ROOT / 'Sources/MacFanControl/main.swift').read_text().split('// 主函数')[0].replace(expr, config) + s = s.replace('let base = FileManager.default.homeDirectoryForCurrentUser\n .appendingPathComponent("Library")\n .appendingPathComponent("Logs")\n .appendingPathComponent("iFanControl")', 'let base = URL(fileURLWithPath: "' + str(out / 'fixture-logs') + '")') + start = s.index(' private init() {', s.index('final class PowerTelemetryReader')) + end = s.index(' var snapshot:', start) + s = s[:start] + ' private init() {}\n\n' + s[end:] + s = replace_function(s, ' private func executeCommand(args: [String])', ' return (args != ["--unlock-fans"] || !CommandLine.arguments.contains("--unlock-failure"), "", "Fixture failure")') + s = replace_function(s, ' func refreshHardwareProfile()', ''' LaunchEffects.record("probe") + precondition(LaunchEffects.loadingVisible) + precondition(UserDefaults.standard.integer(forKey: "ifancontrol.update.pending_build") > 0) + if CommandLine.arguments.contains("--slow") { Thread.sleep(forTimeInterval: 6) } + fanCount = CommandLine.arguments.contains("--fanless") ? 0 : 1 + fans = fanCount == 0 ? [] : [FanInfo(index: 0, actualKey: "F0Ac", minRPM: 0, maxRPM: 4900)] + availableTemperatureSensors = [TemperatureSensorDefinition(key: "Tp01", name: "CPU")] + currentMaxRPM = 4900 + cachedTemperatures = ["Tp01": 65]; cachedFanRPMs = fanCount == 0 ? [:] : [0: 2400] + hasProbedHardware = true + BackgroundHardwareReader.shared.replaceSnapshotFromMainThread(temperatures: cachedTemperatures, fanRPMs: cachedFanRPMs, reason: "isolated launch fixture")''') + s = replace_function(s, ' func refreshTelemetry(force: Bool = false)', ' // Fixture profile already seeds the cached telemetry.') + s = replace_function(s, ' func refresh(sensors: [TemperatureSensorDefinition], fans: [FanInfo])', ' // Never read real hardware from this test.') + s = s.replace('FanManager.shared.unlockFans()', 'LaunchEffects.record("unlock"); FanManager.shared.unlockFans()') + s = s.replace('self.controlLoopTimer = timer', 'self.controlLoopTimer = timer\n LaunchEffects.record("control_loop")') + s = s.replace('func presentInstalledUpdateIfNeeded() {', 'func presentInstalledUpdateIfNeeded() {\n LaunchEffects.record("receipt")\n precondition(!LaunchEffects.loadingVisible)') + s = s.replace('func maybeShowStartupGuidance() {', 'func maybeShowStartupGuidance() {\n LaunchEffects.record("guidance")\n precondition(!LaunchEffects.loadingVisible)\n DispatchQueue.main.async { LaunchEffects.assertReady() }') + for action in ['startup', 'shutdown', 'tick']: + s = s.replace('PrivacyStatsService.shared.' + action + '()', 'LaunchEffects.stats()') + s = s.replace('NSAlert()', 'LaunchFixtureAlert()') + s += '\n' + (ROOT / 'packaging/pkg/test_startup.swift').read_text().replace('FIXTURE_OUTPUT', str(out)) + (out / 'main.swift').write_text(s) + panel = (ROOT / 'Sources/MacFanControl/StartupProgressWindow.swift').read_text() + panel = panel.replace('progress.startAnimation(nil)', 'progress.startAnimation(nil)\n precondition(progress.isIndeterminate && progress.usesThreadedAnimation)\n LaunchEffects.record("present")') + panel = panel.replace('stageField.stringValue = stage.message', 'stageField.stringValue = stage.message\n LaunchEffects.record("stage:" + String(describing: stage))') + panel = panel.replace('window?.orderOut(nil)', 'window?.orderOut(nil)\n LaunchEffects.record("dismiss")') + panel = panel.replace('window?.displayIfNeeded()', 'window?.displayIfNeeded()\n LaunchEffects.capture(window)') + (out / 'StartupProgressWindow.swift').write_text(panel) + app = out / 'LaunchValidation.app' + (app / 'Contents/MacOS').mkdir(parents=True, exist_ok=True) + (app / 'Contents/Resources').mkdir(parents=True, exist_ok=True) + (app / 'Contents/Resources/icon.png').write_bytes((ROOT / 'icon.png').read_bytes()) + (app / 'Contents/Info.plist').write_bytes(plistlib.dumps({'CFBundleIdentifier': 'com.ifancontrol.startup-test.validation70', 'CFBundleExecutable': 'LaunchValidation', 'CFBundlePackageType': 'APPL', 'CFBundleName': 'iFanControl Startup Validation', 'CFBundleShortVersionString': '2.9.10', 'CFBundleVersion': '70', 'IFanControlReleaseChannel': 'beta'})) + files = [str(out / 'main.swift'), str(out / 'StartupProgressWindow.swift')] + [str(p) for p in (ROOT / 'Sources/MacFanControl').glob('*.swift') if p.name not in ['main.swift', 'StartupProgressWindow.swift']] + executable = app / 'Contents/MacOS/LaunchValidation' + subprocess.run(['swiftc', '-swift-version', '6', '-I', str(out), '-L', str(out), '-lFanCurveEditor', '-Xcc', '-fmodule-map-file=' + str(bridge_map), str(bridge_object), *files, '-framework', 'AppKit', '-framework', 'ServiceManagement', '-lIOReport', '-o', str(executable)], check=True) + return executable + + +def test(executable): + for args in [[], ['--slow'], ['--fanless'], ['--unlock-failure'], ['--future-receipt'], ['--first-launch']]: + subprocess.run([str(executable), *args], check=True, timeout=20) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--output', type=Path) + parser.add_argument('--build-only', action='store_true') + args = parser.parse_args() + if args.output: + executable = build(args.output.resolve()) + if not args.build_only: + test(executable) + else: + with tempfile.TemporaryDirectory(prefix='ifan-launch-tests-') as temp: + test(build(Path(temp))) diff --git a/packaging/pkg/test_startup.swift b/packaging/pkg/test_startup.swift new file mode 100644 index 0000000..2db0cff --- /dev/null +++ b/packaging/pkg/test_startup.swift @@ -0,0 +1,100 @@ +// Only appended to the isolated native launch fixture, never shipped. +@MainActor enum LaunchEffects { + static var events: [String] = [] + static var captures = Set() + static let preview = CommandLine.arguments.contains("--preview") + static var loadingVisible: Bool { + NSApp.windows.contains { $0.isVisible && $0.windowController is StartupProgressWindowController } + } + static func record(_ event: String) { + events.append(event) + print("LAUNCH: \(event)") + } + static func stats() {} + static func capture(_ window: NSWindow?) { + guard let window, window.isVisible, let view = window.contentView else { return } + func fields(_ v: NSView) -> [NSTextField] { [v as? NSTextField].compactMap { $0 } + v.subviews.flatMap { fields($0) } } + let stage = fields(view).first { $0.identifier?.rawValue == "startup-stage" }!.stringValue + let name = (NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? "dark-" : "light-") + stage + guard captures.insert(name).inserted, let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return } + view.cacheDisplay(in: view.bounds, to: bitmap) + try! bitmap.representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: "FIXTURE_OUTPUT/" + name + ".png")) + } + static func assertReady() { + guard !preview else { return } + let ordered = ["present", "probe", "stage:preferences", "stage:controls", "unlock", "control_loop", "dismiss", "receipt", "guidance"] + let indices = ordered.map { events.firstIndex(of: $0)! } + precondition(zip(indices, indices.dropFirst()).allSatisfy { $0 < $1 }, "Launch order changed") + precondition(!loadingVisible) + AppDelegate.shared!.assertLaunchTimers() + MenuBarManager.shared.assertLaunchMenu() + let remaining = UserDefaults.standard.integer(forKey: "ifancontrol.update.pending_build") + precondition(remaining == (CommandLine.arguments.contains("--future-receipt") ? 71 : 0)) + let alertExpected = CommandLine.arguments.contains("--fanless") || CommandLine.arguments.contains("--first-launch") + precondition((LaunchFixtureAlert.count > 0) == alertExpected) + let suffix = CommandLine.arguments.dropFirst().joined(separator: "_") + let report: [String: Any] = ["events": events, "loading_hidden_after_real_initialization": true, "native_threaded_indeterminate_indicator": true, "pending_build_remaining": remaining, "startup_alerts_after_loading": LaunchFixtureAlert.count, "real_hardware_or_network_used": false] + try! JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]).write(to: URL(fileURLWithPath: "FIXTURE_OUTPUT/launch-" + suffix + ".json")) + UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) + print("PASS: actual delegate launch / panel visibility / timers and menu / late receipt and guidance / \(suffix)") + exit(0) + } +} + +@MainActor final class LaunchFixtureAlert: NSAlert { + static var count = 0 + override func runModal() -> NSApplication.ModalResponse { + precondition(!LaunchEffects.loadingVisible, "Startup guidance must not overlap launch UI") + Self.count += 1 + return .alertFirstButtonReturn + } +} + +@MainActor extension AppDelegate { + func assertLaunchTimers() { precondition(controlLoopTimer != nil && statsHeartbeatTimer != nil) } +} +@MainActor extension MenuBarManager { + func assertLaunchMenu() { + precondition(statusItem?.button != nil && menuPollTimer != nil && menu != nil) + let configured = ConfigManager.shared.loadConfig().curve + precondition(currentFanCurve.count == configured.count) + precondition(zip(currentFanCurve, configured).allSatisfy { $0.temperature == $1.temperature && $0.rpm == $1.rpm }) + } +} + +@MainActor final class LaunchPreview: NSObject { + let controller = StartupProgressWindowController() + @objc func hardware() { controller.present() } + @objc func preferences() { controller.setStage(.preferences) } + @objc func controls() { controller.setStage(.controls) } + @objc func finish() { controller.dismiss(); InteractionNotice.shared.show(appL10n("已更新至 2.9.10 Beta (70)", "Updated to 2.9.10 Beta (70)")) } +} + +let application = NSApplication.shared +precondition(Bundle.main.bundleIdentifier!.hasPrefix("com.ifancontrol.startup-test.")) +UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier!) +if !CommandLine.arguments.contains("--first-launch") { + UserDefaults.standard.set(CommandLine.arguments.contains("--en") ? "en" : "zh", forKey: "ifancontrol.ui.language") +} +UserDefaults.standard.set(true, forKey: "ifancontrol.ui.temperature_source_hint_shown") +UserDefaults.standard.set(CommandLine.arguments.contains("--future-receipt") ? 71 : 70, forKey: "ifancontrol.update.pending_build") +application.applicationIconImage = NSImage(contentsOfFile: Bundle.main.resourcePath! + "/icon.png") +application.appearance = NSAppearance(named: CommandLine.arguments.contains("--dark") ? .darkAqua : .aqua) +var fixtureConfig = ConfigManager.shared.loadConfig() +fixtureConfig.autoStart = false +ConfigManager.shared.saveConfig(fixtureConfig) +let delegate = AppDelegate() +let preview = LaunchPreview() +if LaunchEffects.preview { + application.setActivationPolicy(.regular) + let root = NSMenu(); let item = NSMenuItem(); root.addItem(item); let commands = NSMenu(); item.submenu = commands + for (i, pair) in [("识别硬件", #selector(LaunchPreview.hardware)), ("恢复设置", #selector(LaunchPreview.preferences)), ("准备控制", #selector(LaunchPreview.controls)), ("完成启动", #selector(LaunchPreview.finish))].enumerated() { + let command = NSMenuItem(title: pair.0, action: pair.1, keyEquivalent: String(i + 1)); command.keyEquivalentModifierMask = [.command]; command.target = preview; commands.addItem(command) + } + application.mainMenu = root + preview.hardware() + application.activate(ignoringOtherApps: true) +} else { + application.delegate = delegate +} +application.run() diff --git a/packaging/pkg/test_ui.py b/packaging/pkg/test_ui.py new file mode 100644 index 0000000..2d85aee --- /dev/null +++ b/packaging/pkg/test_ui.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Compile real native controllers with isolated config/defaults and external-effect seams.""" +from pathlib import Path +import subprocess, tempfile, plistlib, uuid +from native_test_bridge import compile_test_bridge +ROOT=Path(__file__).resolve().parents[2] +expr='FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!.appendingPathComponent("MacFanControl")' +with tempfile.TemporaryDirectory(prefix='ifan-ui-tests-') as d: + out=Path(d); config=out/'config'; replacement='URL(fileURLWithPath: "'+str(config)+'", isDirectory:true)' + bridge_map,bridge_object=compile_test_bridge(out) + app=out/'UITests.app';(app/'Contents/MacOS').mkdir(parents=True) + domain='com.ifancontrol.ui-regression.'+str(uuid.uuid4()) + (app/'Contents/Info.plist').write_bytes(plistlib.dumps({'CFBundleIdentifier':domain,'CFBundleExecutable':'UITests','CFBundlePackageType':'APPL','CFBundleShortVersionString':'2.9.10','CFBundleVersion':'64'})) + curve=(ROOT/'Sources/FanCurveEditor/FanCurveWindow.swift').read_text().replace(expr,replacement).replace('NSAlert()','NativeUITestAlert()') + curve+='''\n@MainActor enum UIRegressionPaths { static let config = '''+replacement+''' } +@MainActor class NativeUITestAlert: NSAlert { + static var lastMessage = "" + override func runModal() -> NSApplication.ModalResponse { Self.lastMessage=messageText;return .alertFirstButtonReturn } +} +'''+(ROOT/'packaging/pkg/test_curve_ui.swift').read_text() + (out/'FanCurveWindow.swift').write_text(curve) + module=out/'libFanCurveEditor.dylib' + subprocess.run(['swiftc','-swift-version','6','-emit-library','-emit-module','-emit-module-path',str(out/'FanCurveEditor.swiftmodule'),'-module-name','FanCurveEditor',str(ROOT/'Sources/FanCurveEditor/FanCurveView.swift'),str(out/'FanCurveWindow.swift'),'-o',str(module)],check=True) + s=(ROOT/'Sources/MacFanControl/main.swift').read_text().split('// 主函数')[0].replace(expr,replacement) + a=s.index(' private init() {',s.index('final class PowerTelemetryReader'));b=s.index(' var snapshot:',a) + s=s[:a]+' private init() {}\n\n'+s[b:] + a=s.index(' func sendFeedback(content: String, email: String) async -> Bool {');b=s.index('\n}\n\n@MainActor\nclass SafetyFloorWindowController',a) + s=s[:a]+''' func sendFeedback(content: String, email: String) async -> Bool { + try? await Task.sleep(for:.milliseconds(20));return !content.contains("fail") + } +'''+s[b:] + s+='\n'+(ROOT/'packaging/pkg/test_ui.swift').read_text()+''' +let application=NSApplication.shared +application.setActivationPolicy(.accessory) +Task { @MainActor in + let domain=Bundle.main.bundleIdentifier! + precondition(domain.hasPrefix("com.ifancontrol.ui-regression.")) + UserDefaults.standard.removePersistentDomain(forName:domain) + do { + try FanCurvePresetWindowController.runUIRegression() + DisplaySettingsWindowController.runUIRegression() + PowerConsumptionWindowController.runUIRegression() + HelpWindowController.runUIRegression() + try await FeedbackWindowController.runUIRegression() + UserDefaults.standard.removePersistentDomain(forName:domain) + print("PASS: isolated native UI controller regressions") + exit(0) + } catch { print("FAIL: \\(error)");UserDefaults.standard.removePersistentDomain(forName:domain);exit(1) } +} +application.run() +''' + (out/'main.swift').write_text(s) + files=[str(out/'main.swift')]+[str(p) for p in (ROOT/'Sources/MacFanControl').glob('*.swift') if p.name!='main.swift'] + subprocess.run(['swiftc','-swift-version','6','-I',str(out),'-L',str(out),'-lFanCurveEditor','-Xcc','-fmodule-map-file='+str(bridge_map),str(bridge_object),*files,'-framework','AppKit','-framework','ServiceManagement','-lIOReport','-o',str(app/'Contents/MacOS/UITests')],check=True) + subprocess.run([str(app/'Contents/MacOS/UITests'),'-ifancontrol.ui.language','en'],check=True,timeout=20) diff --git a/packaging/pkg/test_ui.swift b/packaging/pkg/test_ui.swift new file mode 100644 index 0000000..9ccf7df --- /dev/null +++ b/packaging/pkg/test_ui.swift @@ -0,0 +1,109 @@ +// AppKit controller regressions. All state, network and config use a throwaway test bundle. +@MainActor extension DisplaySettingsWindowController { + static func runUIRegression() { + UserDefaults.standard.set("mini",forKey:"ifancontrol.ui.display_mode") + UserDefaults.standard.set("power,rpm,power,invalid",forKey:"ifancontrol.ui.mini_top_source") + let c=DisplaySettingsWindowController() + precondition(c.currentMiniTopLine.sources.map(\.rawValue)==["power","rpm"]) + precondition(c.previewField.stringValue.hasPrefix("12.3W|2400 RPM|A")) + c.currentMiniTopLine=([.temperature],.value) + c.syncMiniLineControls() + let button=c.topSourceCheckboxes["temperature"]!;button.state = .off;c.topSourceChanged(button) + precondition(button.state == .on && c.currentMiniTopLine.sources.count==1) + for key in ["power","rpm"] {let button=c.topSourceCheckboxes[key]!;button.state = .on;c.topSourceChanged(button)} + precondition(c.currentMiniTopLine.sources.map(\.rawValue)==["temperature","power","rpm"]) + c.currentMiniBottomLine=([.rpm],.unit) + c.swapLines(c) + let reopened=DisplaySettingsWindowController() + precondition(reopened.currentMiniBottomLine.sources.map(\.rawValue)==["temperature","power","rpm"]) + precondition(reopened.previewField.stringValue=="2400 RPM\n65|12.3|2400") + for (style,prefix,accent) in [("value","65|2400",""),("unit","65℃|2400 RPM",""),("mode","65|2400|","M"),("full","65℃|2400 RPM|","M")] { + let line=MiniDisplayFormatter.line(values:["65","2400"],units:["65℃","2400 RPM"],style:style,mode:"M") + precondition(line.primary==prefix && line.accent==accent) + } + c.modeControl.selectedSegment = 1; c.modeSelectionChanged(c.modeControl) + precondition(c.currentDisplayMode == "compact" && c.miniOptionsStack.isHidden && c.previewField.stringValue == "65|2400|A") + c.modeControl.selectedSegment = 2; c.modeSelectionChanged(c.modeControl) + c.topStylePopup.selectItem(at: 0); c.topPopupChanged(c.topStylePopup) + precondition(c.currentMiniTopLine.style.rawValue == "value" && c.previewField.stringValue.hasPrefix("2400\n")) + c.window?.close();reopened.window?.close() + print("PASS: legacy display preferences, ordered deduplication, last-source guard, shared formatting, swap and reopening persistence") + } +} +@MainActor extension PowerTelemetryReader { + func testSnapshot(_ supported:Bool, _ updated:Date?) { + _snapshot=PowerTelemetrySnapshot(cpuWatts:6.4,gpuWatts:2.1,aneWatts:0,ramWatts:1.2,pciWatts:0.3,dcInWatts:18.4,lastUpdatedAt:updated,isSupported:supported) + } +} +@MainActor extension PowerConsumptionWindowController { + static func runUIRegression() { + PowerTelemetryReader.shared.testSnapshot(true,nil) + let c=PowerConsumptionWindowController();c.refreshSnapshot() + precondition(c.valueLabels.values.allSatisfy{$0.stringValue=="--"}) + precondition(c.samplingStatus.stringValue.contains("读取") || c.samplingStatus.stringValue.contains("Reading")) + PowerTelemetryReader.shared.testSnapshot(true,Date());c.refreshSnapshot() + precondition(c.valueLabels["total"]?.stringValue=="18.40 W") + PowerTelemetryReader.shared.testSnapshot(false,Date());c.refreshSnapshot() + precondition(c.valueLabels.values.allSatisfy{$0.stringValue=="--"}) + c.startTimerIfNeeded();precondition(c.updateTimer != nil) + c.windowWillClose(Notification(name:NSWindow.willCloseNotification,object:c.window)) + precondition(c.updateTimer==nil) + c.startTimerIfNeeded();precondition(c.updateTimer != nil) + c.windowWillClose(Notification(name:NSWindow.willCloseNotification,object:c.window)) + print("PASS: power loading/data/unsupported states and close/reopen timer lifecycle") + } +} +@MainActor extension FeedbackWindowController { + private func waitForSendOutcome() async throws { + let deadline = Date().addingTimeInterval(3) + while !submittedSuccessfully && sendButton?.isEnabled == false && Date() < deadline { + try await Task.sleep(for: .milliseconds(10)) + } + precondition(submittedSuccessfully || sendButton?.isEnabled == true, "Feedback operation did not finish within the test deadline") + } + + static func runUIRegression() async throws { + let c=FeedbackWindowController() + c.sendFeedback();precondition(c.statusLabel?.stringValue == appL10n("请输入内容","Please enter something")) + c.contentTextView?.string="draft" + UserDefaults.standard.set(Array(repeating:Date().timeIntervalSince1970,count:5),forKey:"ifancontrol.feedback.send_timestamps") + c.sendFeedback();precondition(c.statusLabel?.stringValue.contains("5")==true) + UserDefaults.standard.removeObject(forKey:"ifancontrol.feedback.send_timestamps") + c.contentTextView?.string="fail retained draft";c.emailField?.stringValue="qa@example.invalid" + c.sendFeedback();precondition(c.sendButton?.isEnabled==false && c.statusLabel?.alphaValue==0) + try await c.waitForSendOutcome() + precondition(c.sendButton?.isEnabled==true && c.contentTextView?.string=="fail retained draft") + c.window?.close();c.showWindow(nil) + precondition(c.contentTextView?.string=="fail retained draft" && c.emailField?.stringValue=="qa@example.invalid") + c.contentTextView?.string="success";c.emailField?.stringValue="" + c.sendFeedback();try await c.waitForSendOutcome() + precondition(c.submittedSuccessfully && !(c.statusLabel?.stringValue.contains("reply") ?? false) && !(c.statusLabel?.stringValue.contains("邮箱") ?? false)) + c.window?.close();c.showWindow(nil) + precondition(c.sendButton?.isEnabled==true && c.contentTextView?.string=="" && c.closeAfterSuccess==nil) + c.contentTextView?.string="new draft" + try await Task.sleep(for:.milliseconds(2150)) + precondition(c.window?.isVisible==true && c.contentTextView?.string=="new draft","Prior success close must not close a reopened editor") + c.emailField?.stringValue="qa@example.invalid";c.sendFeedback();try await c.waitForSendOutcome() + precondition(c.statusLabel?.stringValue.contains(appL10n("邮箱","email"))==true) + c.closeAfterSuccess?.cancel();c.window?.close() + print("PASS: empty/limited/sending/failure draft persistence, optional-email success wording, resend and prior-close cancellation") + } +} + +@MainActor extension HelpWindowController { + static func runUIRegression() { + let c = HelpWindowController() + for page in [Section.overview, .diagnostics, .faq, .donation] { + c.select(section: page); c.window?.contentView?.layoutSubtreeIfNeeded() + let document = c.sectionContainerView as! NSStackView + precondition(document.arrangedSubviews.count == 1 && document.arrangedSubviews[0] === c.sectionViews[page]) + precondition(c.window!.frame.height <= 550, "Hidden help pages must not enlarge the help window") + } + UpdatePreferences.shared.unlockDeveloperMode(); c.addDeveloperSectionIfNeeded(); c.select(section:.developer) + precondition(c.sidebarButtons[.developer] != nil && c.betaUpdateCheckbox?.state == .off) + c.exitDeveloperMode() + precondition(c.currentSection == .overview && c.sidebarButtons[.developer] == nil && !UpdatePreferences.shared.betaEnabled) + c.window?.close() + print("PASS: isolated active help-page layout, bounded window, developer availability and exit") + } +} diff --git a/packaging/pkg/test_update_channels.swift b/packaging/pkg/test_update_channels.swift new file mode 100644 index 0000000..0cb6dc9 --- /dev/null +++ b/packaging/pkg/test_update_channels.swift @@ -0,0 +1,67 @@ +import Foundation + +@main +struct UpdateChannelTests { + @MainActor + static func main() throws { + let suite = "com.ifancontrol.update-tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let preferences = UpdatePreferences(defaults: defaults) + precondition(!preferences.developerModeEnabled && !preferences.betaEnabled) + precondition(preferences.channel == .stable) + precondition(preferences.channel.manifestURL == UpdateManifest.channelURL) + precondition(UpdateChannel.beta.manifestURL.path == "/update-manifest-beta.json") + + preferences.setBetaEnabled(true) + precondition(preferences.channel == .stable, "Hidden mode must not enable Beta") + var counter = DeveloperModeUnlockCounter() + for _ in 1...9 { precondition(!counter.registerClick()) } + counter.reset() // A closed help window must discard the first nine clicks. + precondition(!counter.registerClick()) + for _ in 2...9 { precondition(!counter.registerClick()) } + precondition(counter.registerClick(), "The tenth click must unlock") + preferences.unlockDeveloperMode() + precondition(preferences.developerModeEnabled && preferences.channel == .stable) + + let originalStableRequest = preferences.selection + preferences.setBetaEnabled(true) + let betaRequest = preferences.selection + precondition(betaRequest.channel == .beta && betaRequest != originalStableRequest) + let restarted = UpdatePreferences(defaults: UserDefaults(suiteName: suite)!) + precondition(restarted.developerModeEnabled && restarted.betaEnabled, "Restart must preserve both preferences") + preferences.setBetaEnabled(false) + precondition(preferences.channel == .stable && preferences.selection != originalStableRequest) + preferences.setBetaEnabled(true) + precondition(preferences.selection != betaRequest, "Switching away and back must invalidate the old request") + let currentRequest = preferences.selection + preferences.setBetaEnabled(true) + precondition(preferences.selection == currentRequest, "Unchanged selection must not invalidate work") + preferences.exitDeveloperMode() + precondition(!preferences.developerModeEnabled && preferences.channel == .stable) + preferences.unlockDeveloperMode() + precondition(!preferences.betaEnabled, "Unlocking again must not restore a previous Beta subscription") + + func manifest(channel: String? = nil, releaseURL: String? = nil) throws -> UpdateManifest { + var json: [String: Any] = ["latest_version": "3.0.0", "latest_build": 63] + json["release_channel"] = channel + json["release_url"] = releaseURL + return try JSONDecoder().decode(UpdateManifest.self, from: JSONSerialization.data(withJSONObject: json)) + } + let legacySchema = try manifest() + precondition(legacySchema.isCompatible(with: .stable) && legacySchema.versionDisplay == "3.0.0") + let stable = try manifest(channel: "stable") + precondition(stable.isCompatible(with: .stable) && stable.isCompatible(with: .beta)) + let beta = try manifest(channel: "beta", releaseURL: "https://github.com/PureMilkchun/iFanControl/releases/tag/v3.0.0-beta.1") + precondition(!beta.isCompatible(with: .stable) && beta.isCompatible(with: .beta)) + precondition(beta.versionDisplay == "3.0.0 Beta" && beta.betaReleaseURL != nil) + let unknownChannel = try manifest(channel: "unknown") + precondition(!unknownChannel.isCompatible(with: .beta)) + for badURL in ["http://github.com/PureMilkchun/iFanControl/releases/tag/v3.0.0-beta.1", + "https://example.com/release", "https://github.com/other/project/releases/tag/test"] { + let invalidLink = try manifest(channel: "beta", releaseURL: badURL) + precondition(invalidLink.betaReleaseURL == nil) + } + print("PASS: stable default, ten clicks/reset, explicit Beta opt-in, persistence/exit, stale requests and feed isolation") + } +} diff --git a/packaging/pkg/test_update_service.py b/packaging/pkg/test_update_service.py new file mode 100644 index 0000000..b027a7e --- /dev/null +++ b/packaging/pkg/test_update_service.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Compile the actual updater with only network/UI/Installer/quit test seams; never install or control fans.""" +from pathlib import Path +import plistlib +import subprocess +import tempfile +import uuid + +ROOT = Path(__file__).resolve().parents[2] +source = (ROOT / "Sources/MacFanControl/main.swift").read_text() +service = source.split("// 自动更新服务\n", 1)[1].split("// 菜单栏管理\n", 1)[0] +assert service.count("URLSession.shared") == 1 +assert service.count("NSApp.terminate(nil)") == 1 +service = service.replace("URLSession.shared", "updateFixtureSession") +service = service.replace("UpdatePackageDownloader()", "UpdatePackageDownloader(configuration: updateFixtureSession.configuration)") +service = service.replace("NSAlert()", "UpdateFixtureAlert()") +service = service.replace("NSWorkspace.shared.open", "UpdateFixtureUI.open") +service = service.replace("NSApp.terminate(nil)", "UpdateFixtureUI.terminate()") +# Completion counters observe the existing task boundaries, removing timing-dependent assertions. +service = service.replace("isChecking = true", "isChecking = true\n UpdateFixtureUI.checksInFlight += 1") +service = service.replace("defer { isChecking = false; checkTask = nil }", "defer { isChecking = false; checkTask = nil; UpdateFixtureUI.checksInFlight -= 1 }") +service = service.replace("isDownloading = true", "isDownloading = true\n UpdateFixtureUI.downloadsInFlight += 1") +service = service.replace("defer { isDownloading = false; isTransferring = false; downloader = nil; downloadTask = nil }", "defer { isDownloading = false; isTransferring = false; downloader = nil; downloadTask = nil; UpdateFixtureUI.downloadsInFlight -= 1 }") +with tempfile.TemporaryDirectory(prefix="ifan-service-tests-") as temp: + work = Path(temp) + app = work / "UpdaterTests.app" + (app / "Contents/MacOS").mkdir(parents=True) + info = {"CFBundleIdentifier": "com.ifancontrol.update-service-test." + str(uuid.uuid4()), + "CFBundleExecutable": "UpdaterTests", "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": "2.9.10", "CFBundleVersion": "62"} + (app / "Contents/Info.plist").write_bytes(plistlib.dumps(info)) + generated = work / "UpdateService.swift" + generated.write_text('import AppKit\nimport Foundation\nimport OSLog\n' + 'private let appSubsystem = "com.ifancontrol.update-service-tests"\n' + 'private func appL10n(_ zh: String, _ en: String) -> String { zh }\n' + service) + executable = app / "Contents/MacOS/UpdaterTests" + subprocess.run(["swiftc", "-swift-version", "6", str(generated), + str(ROOT / "Sources/MacFanControl/UpdateManifest.swift"), + str(ROOT / "Sources/MacFanControl/UpdatePreferences.swift"), + str(ROOT / "Sources/MacFanControl/UpdatePackageDownloader.swift"), + str(ROOT / "packaging/pkg/test_update_service.swift"), "-o", str(executable)], check=True) + subprocess.run([str(executable)], check=True, timeout=20) diff --git a/packaging/pkg/test_update_service.swift b/packaging/pkg/test_update_service.swift new file mode 100644 index 0000000..aa162b3 --- /dev/null +++ b/packaging/pkg/test_update_service.swift @@ -0,0 +1,332 @@ +import AppKit +import CryptoKit +import Foundation + +struct FixtureResponse: Sendable { + let data: Data + let status: Int + let delay: TimeInterval + var stream = false + var knownLength = true +} + +final class UpdateNetworkFixtures: @unchecked Sendable { + static let shared = UpdateNetworkFixtures() + private let lock = NSLock() + private var responses: [String: FixtureResponse] = [:] + private var requests: [URL] = [] + + func reset(_ responses: [String: FixtureResponse]) { + lock.withLock { self.responses = responses; requests = [] } + } + func response(for url: URL) -> FixtureResponse { + lock.withLock { + requests.append(url) + return responses[url.path] ?? FixtureResponse(data: Data(), status: 404, delay: 0) + } + } + var paths: [String] { lock.withLock { requests.map(\.path) } } +} + +final class UpdateFixtureProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + private let stateLock = NSLock() + private var stopped = false + override func startLoading() { + let url = request.url! + let fixture = UpdateNetworkFixtures.shared.response(for: url) + DispatchQueue.global().asyncAfter(deadline: .now() + fixture.delay) { [self] in + guard !stateLock.withLock({ stopped }) else { return } + client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: fixture.status, httpVersion: nil, headerFields: fixture.knownLength ? ["Content-Length": "\(fixture.data.count)"] : nil)!, cacheStoragePolicy: .notAllowed) + if fixture.stream { + let middle = fixture.data.count / 2 + client?.urlProtocol(self, didLoad: fixture.data.prefix(middle)) + DispatchQueue.global().asyncAfter(deadline: .now() + 0.15) { [self] in + guard !stateLock.withLock({ stopped }) else { return } + client?.urlProtocol(self, didLoad: fixture.data.suffix(fixture.data.count - middle)) + client?.urlProtocolDidFinishLoading(self) + } + } else { + client?.urlProtocol(self, didLoad: fixture.data) + client?.urlProtocolDidFinishLoading(self) + } + } + } + override func stopLoading() { stateLock.withLock { stopped = true } } +} + +let updateFixtureSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [UpdateFixtureProtocol.self] + return URLSession(configuration: config) +}() + +@MainActor +enum UpdateFixtureUI { + static var progressStages: [String] = [] + static var transferAmounts: [(Int64, Int64)] = [] + static var cancelAction: (() -> Void)? + static var retryAction: (() -> Void)? + static var messages: [String] = [] + static var notices: [String] = [] + static var openedPackages: [URL] = [] + static var terminationCount = 0 + static var acceptUpdate = false + static var openSucceeds = true + static var checksInFlight = 0 + static var downloadsInFlight = 0 + + static func reset(accept: Bool = false, openSucceeds: Bool = true) { + UserDefaults.standard.removeObject(forKey: "ifancontrol.update.pending_build") + progressStages = []; transferAmounts = []; cancelAction = nil; retryAction = nil + messages = []; notices = []; openedPackages = []; terminationCount = 0 + acceptUpdate = accept; self.openSucceeds = openSucceeds + } + @discardableResult + static func open(_ url: URL) -> Bool { openedPackages.append(url); return openSucceeds } + static func terminate() { terminationCount += 1 } +} + +@MainActor +final class UpdateFixtureAlert: NSAlert { + @discardableResult + override func runModal() -> NSApplication.ModalResponse { + UpdateFixtureUI.messages.append(messageText) + return UpdateFixtureUI.acceptUpdate && buttons.first?.title == "立即更新" + ? .alertFirstButtonReturn : .alertSecondButtonReturn + } +} + +@MainActor +final class InteractionNotice { + static let shared = InteractionNotice() + func show(_ message: String) { UpdateFixtureUI.notices.append(message) } +} + +@MainActor +final class UpdateProgressWindowController { + func present(title: String, detail: String, cancel: (() -> Void)? = nil, retry: (() -> Void)? = nil, manual: (() -> Void)? = nil) { + UpdateFixtureUI.progressStages.append(title) + UpdateFixtureUI.cancelAction = cancel + UpdateFixtureUI.retryAction = retry + if title.contains("失败") || title.contains("未完成") { UpdateFixtureUI.messages.append(title) } + } + func updateTransfer(received: Int64, total: Int64) { UpdateFixtureUI.transferAmounts.append((received, total)) } + func bringForward() {} + func dismiss() {} +} + +@main +struct UpdateServiceTests { + @MainActor + static func main() async throws { + _ = NSApplication.shared + let domain = Bundle.main.bundleIdentifier! + precondition(domain.hasPrefix("com.ifancontrol.update-service-test.")) + UserDefaults.standard.removePersistentDomain(forName: domain) + defer { + for url in UpdateFixtureUI.openedPackages { + try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) + } + UserDefaults.standard.removePersistentDomain(forName: domain) + } + let service = UpdateService.shared + let preferences = UpdatePreferences.shared + let payload = Data("installer verification fixture".utf8) + let digest = SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined() + func feed(_ build: Int, channel: String? = nil) throws -> Data { + var json: [String: Any] = ["latest_version": "2.9.10", "latest_build": build, + "assets": ["macos_arm64_pkg_url": "https://ifan-59w.pages.dev/fixture.pkg?build=\(build)", "pkg_sha256": digest]] + json["release_channel"] = channel + return try JSONSerialization.data(withJSONObject: json) + } + func setup(_ data: Data, package: Data? = nil, status: Int = 200, delay: TimeInterval = 0.01) { + UpdateNetworkFixtures.shared.reset([ + "/update-manifest-pkg.json": FixtureResponse(data: data, status: 200, delay: delay), + "/update-manifest-beta.json": FixtureResponse(data: data, status: 200, delay: delay), + "/fixture.pkg": FixtureResponse(data: package ?? payload, status: status, delay: delay) + ]) + } + func finishCheck() async throws { + let deadline = Date().addingTimeInterval(5) + while UpdateFixtureUI.checksInFlight > 0 || UpdateFixtureUI.downloadsInFlight > 0 { + precondition(Date() < deadline, "Updater fixture timed out") + try await Task.sleep(for: .milliseconds(10)) + } + } + + service.automaticChecksEnabled = false + setup(try feed(62)) + service.checkForUpdates(triggeredByUser: false) + try await finishCheck() + precondition(UpdateNetworkFixtures.shared.paths.isEmpty) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateNetworkFixtures.shared.paths == ["/update-manifest-pkg.json"]) + precondition(UpdateFixtureUI.messages == ["已是最新版本"]) + precondition(service.currentVersionDisplay == "2.9.10 (62)") + + preferences.unlockDeveloperMode() + UpdateFixtureUI.reset() + setup(try feed(999)) + service.automaticChecksEnabled = true + service.checkForUpdates(triggeredByUser: false) + try await finishCheck() + precondition(UpdateNetworkFixtures.shared.paths == ["/update-manifest-pkg.json"]) + precondition(UpdateFixtureUI.messages == ["发现新版本 2.9.10"]) + precondition(UpdateFixtureUI.openedPackages.isEmpty, "Later must not download or install") + + preferences.setBetaEnabled(true) + UpdateFixtureUI.reset(accept: true) + setup(try feed(999, channel: "beta"), package: Data("corrupt".utf8)) + service.checkForUpdates(triggeredByUser: false) + try await finishCheck() + precondition(UpdateNetworkFixtures.shared.paths == ["/update-manifest-beta.json", "/fixture.pkg"]) + precondition(UpdateFixtureUI.messages == ["发现新版本 2.9.10 Beta", "更新未完成"]) + precondition(UpdateFixtureUI.openedPackages.isEmpty && UpdateFixtureUI.terminationCount == 0) + + UpdateFixtureUI.reset(accept: true) + setup(try feed(999, channel: "beta")) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateFixtureUI.openedPackages.count == 1 && UpdateFixtureUI.terminationCount == 1) + let downloadedPackage = UpdateFixtureUI.openedPackages[0] + let downloadedBytes = try Data(contentsOf: downloadedPackage) + precondition(downloadedBytes == payload && downloadedPackage.pathExtension == "pkg") + try FileManager.default.removeItem(at: downloadedPackage.deletingLastPathComponent()) + + // A normal stable package uses the same download, verification and termination path. + preferences.setBetaEnabled(false) + UpdateFixtureUI.reset(accept: true) + setup(try feed(999)) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateNetworkFixtures.shared.paths == ["/update-manifest-pkg.json", "/fixture.pkg"]) + precondition(UpdateFixtureUI.openedPackages.count == 1 && UpdateFixtureUI.terminationCount == 1) + try FileManager.default.removeItem(at: UpdateFixtureUI.openedPackages[0].deletingLastPathComponent()) + + UpdateFixtureUI.reset(accept: true, openSucceeds: false) + setup(try feed(999)) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateFixtureUI.messages.last == "更新未完成" && UpdateFixtureUI.terminationCount == 0) + try FileManager.default.removeItem(at: UpdateFixtureUI.openedPackages[0].deletingLastPathComponent()) + + UpdateFixtureUI.reset(accept: true) + setup(try feed(999), status: 503) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateFixtureUI.messages.last == "更新未完成" && UpdateFixtureUI.openedPackages.isEmpty) + + UpdateFixtureUI.reset() + setup(try feed(999, channel: "beta")) + service.checkForUpdates(triggeredByUser: false) + try await finishCheck() + precondition(UpdateFixtureUI.messages.isEmpty, "Stable must quietly reject a misplaced Beta feed during automatic checks") + + preferences.setBetaEnabled(true) + UpdateFixtureUI.reset() + setup(try feed(999, channel: "beta"), delay: 0.3) + service.checkForUpdates(triggeredByUser: true) + try await Task.sleep(for: .milliseconds(50)) + preferences.setBetaEnabled(false) + preferences.setBetaEnabled(true) + try await finishCheck() + precondition(UpdateFixtureUI.messages.isEmpty, "An old result is invalid even after switching back to Beta") + + UpdateFixtureUI.reset(accept: true) + UpdateNetworkFixtures.shared.reset([ + "/update-manifest-beta.json": FixtureResponse(data: try feed(999, channel: "beta"), status: 200, delay: 0.01), + "/fixture.pkg": FixtureResponse(data: payload, status: 200, delay: 0.3) + ]) + service.checkForUpdates(triggeredByUser: true) + let downloadDeadline = Date().addingTimeInterval(5) + while UpdateNetworkFixtures.shared.paths.count < 2 { + precondition(Date() < downloadDeadline) + try await Task.sleep(for: .milliseconds(10)) + } + preferences.setBetaEnabled(false) + try await finishCheck() + precondition(UpdateFixtureUI.openedPackages.isEmpty && UpdateFixtureUI.terminationCount == 0, + "Switching routes during a download must prevent installation of the old result") + + UpdateFixtureUI.reset() + setup(Data("unavailable".utf8)) + service.checkForUpdates(triggeredByUser: false) + try await finishCheck() + precondition(UpdateFixtureUI.messages.isEmpty) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateFixtureUI.messages == ["检查更新失败"]) + UpdateFixtureUI.reset(accept: true) + setup(try feed(999), delay: 0.3) + service.checkForUpdates(triggeredByUser: true) + try await Task.sleep(for: .milliseconds(40)) + UpdateFixtureUI.cancelAction?() + try await finishCheck() + precondition(UpdateFixtureUI.messages.isEmpty && UpdateFixtureUI.openedPackages.isEmpty) + + UpdateFixtureUI.reset(accept: true) + UpdateNetworkFixtures.shared.reset([ + "/update-manifest-pkg.json": FixtureResponse(data: try feed(999), status: 200, delay: 0.01), + "/fixture.pkg": FixtureResponse(data: payload, status: 200, delay: 0.3) + ]) + service.checkForUpdates(triggeredByUser: true) + while UpdateNetworkFixtures.shared.paths.count < 2 { try await Task.sleep(for: .milliseconds(10)) } + UpdateFixtureUI.cancelAction?() + try await finishCheck() + precondition(UpdateFixtureUI.openedPackages.isEmpty && UpdateFixtureUI.terminationCount == 0) + precondition(!UpdateFixtureUI.progressStages.contains("正在验证安装包")) + + UpdateFixtureUI.reset(accept: true) + UpdateNetworkFixtures.shared.reset([ + "/update-manifest-pkg.json": FixtureResponse(data: try feed(999), status: 200, delay: 0.01), + "/fixture.pkg": FixtureResponse(data: payload, status: 200, delay: 0.01, stream: true) + ]) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateFixtureUI.progressStages == ["正在检查更新", "正在下载安装包", "正在验证安装包", "即将打开系统安装器"]) + precondition(!UpdateFixtureUI.transferAmounts.isEmpty, "Known-size transfer must report actual received bytes") + precondition(UpdateFixtureUI.transferAmounts.allSatisfy { $0.0 <= $0.1 && $0.1 == payload.count }) + precondition(UserDefaults.standard.integer(forKey: "ifancontrol.update.pending_build") == 999) + service.presentInstalledUpdateIfNeeded() + precondition(UserDefaults.standard.integer(forKey: "ifancontrol.update.pending_build") == 999, "Opening Installer is not successful installation") + precondition(UpdateFixtureUI.notices.isEmpty) + UserDefaults.standard.set(62, forKey: "ifancontrol.update.pending_build") + service.presentInstalledUpdateIfNeeded() + precondition(UpdateFixtureUI.notices == ["已更新至 2.9.10 (62)"]) + precondition(UserDefaults.standard.object(forKey: "ifancontrol.update.pending_build") == nil) + try FileManager.default.removeItem(at: UpdateFixtureUI.openedPackages[0].deletingLastPathComponent()) + + UpdateFixtureUI.reset(accept: true) + UpdateNetworkFixtures.shared.reset([ + "/update-manifest-pkg.json": FixtureResponse(data: try feed(999), status: 200, delay: 0.01), + "/fixture.pkg": FixtureResponse(data: payload, status: 200, delay: 0.01, stream: true, knownLength: false) + ]) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(!UpdateFixtureUI.transferAmounts.isEmpty && UpdateFixtureUI.transferAmounts.allSatisfy { $0.1 <= 0 }, "Unknown-size transfer must not invent a total") + try FileManager.default.removeItem(at: UpdateFixtureUI.openedPackages[0].deletingLastPathComponent()) + + UpdateFixtureUI.reset(accept: true) + setup(try feed(999), status: 503) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateFixtureUI.retryAction != nil) + setup(try feed(999)) + UpdateFixtureUI.retryAction?() + try await finishCheck() + precondition(UpdateFixtureUI.terminationCount == 1 && UpdateFixtureUI.openedPackages.count == 1) + try FileManager.default.removeItem(at: UpdateFixtureUI.openedPackages[0].deletingLastPathComponent()) + + UpdateFixtureUI.reset() + setup(try feed(62), delay: 0.2) + service.checkForUpdates(triggeredByUser: false) + service.checkForUpdates(triggeredByUser: true) + try await finishCheck() + precondition(UpdateNetworkFixtures.shared.paths.count == 1 && UpdateFixtureUI.messages == ["已是最新版本"], "Manual check during an automatic check must surface its result") + + print("PASS: real updater routing, live byte progress/unknown totals, cancellation, retry, SHA256/HTTP/open failures, receipt truthfulness and stale-result handling (external effects stubbed)") + } +} diff --git a/packaging/pkg/test_updates.sh b/packaging/pkg/test_updates.sh new file mode 100644 index 0000000..4c66dc9 --- /dev/null +++ b/packaging/pkg/test_updates.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -eu +TASK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +TASK_TEMP="$(mktemp -d "${TMPDIR:-/tmp}/ifan-update-tests.XXXXXX")" +trap 'rm -rf "$TASK_TEMP"' EXIT +for test in test_update_manifest test_update_channels; do + swiftc -swift-version 6 \ + "$TASK_ROOT/Sources/MacFanControl/UpdateManifest.swift" \ + "$TASK_ROOT/Sources/MacFanControl/UpdatePreferences.swift" \ + "$TASK_ROOT/packaging/pkg/$test.swift" -o "$TASK_TEMP/$test" + "$TASK_TEMP/$test" +done +python3 "$TASK_ROOT/packaging/pkg/test_update_service.py" diff --git a/packaging/pkg/verify_channels.py b/packaging/pkg/verify_channels.py index caad0a8..5495cfe 100644 --- a/packaging/pkg/verify_channels.py +++ b/packaging/pkg/verify_channels.py @@ -18,6 +18,7 @@ def verify(docs, pin): assert legacy[key] == pin[key], f"Frozen legacy channel changed: {key}" assert legacy["latest_version"] == "2.9.9" and legacy["latest_build"] == 52 assert latest["latest_build"] >= 52 + assert latest.get("release_channel", "stable") == "stable", "Stable feed must not offer Beta" assert "macos_arm64_zip_url" not in latest["assets"], "Native channel must only offer PKG" assert "macos_arm64_pkg_url" not in legacy["assets"], "Keep legacy channel isolated" @@ -36,6 +37,19 @@ def verify(docs, pin): assert package.suffix == ".pkg" assert hashlib.sha256(package.read_bytes()).hexdigest() == latest["assets"]["pkg_sha256"] assert f'?build={latest["latest_build"]}' in latest["assets"]["macos_arm64_pkg_url"] + beta_path = docs / "update-manifest-beta.json" + if beta_path.is_file(): + beta = json.loads(beta_path.read_text()) + assert beta.get("release_channel") in ("stable", "beta") + assert beta["latest_build"] >= latest["latest_build"], "Promote the newest stable release into the Beta feed too" + assert "macos_arm64_zip_url" not in beta["assets"] + beta_url = urlparse(beta["assets"]["macos_arm64_pkg_url"]) + assert beta_url.scheme == "https" and beta_url.netloc + beta_package = docs / Path(beta_url.path).name + assert beta_package.suffix == ".pkg" and beta_package.name != "iFanControl-macOS.pkg", "Use a separate immutable Beta artifact" + assert hashlib.sha256(beta_package.read_bytes()).hexdigest() == beta["assets"]["pkg_sha256"] + assert f'?build={beta["latest_build"]}' in beta["assets"]["macos_arm64_pkg_url"] + print("PASS: Beta feed isolated; package SHA256 and shared build sequence valid") print("PASS: legacy channel frozen at 2.9.9/build52; native PKG channel and both artifact hashes valid") diff --git a/scripts/build_app.py b/scripts/build_app.py new file mode 100644 index 0000000..d7d3055 --- /dev/null +++ b/scripts/build_app.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Build an ad-hoc signed App from tracked inputs; never launch or install it.""" +from pathlib import Path +import argparse +import hashlib +import json +import plistlib +import re +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def run(*args): + subprocess.run([str(a) for a in args], cwd=ROOT, check=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, default=ROOT / 'build/iFanControl.app') + parser.add_argument('--version') + parser.add_argument('--build', type=int) + parser.add_argument('--channel', choices=['stable', 'beta']) + args = parser.parse_args() + output = args.output.resolve() + if output.name != 'iFanControl.app' or not output.is_relative_to(ROOT / 'build'): + parser.error('--output must be an iFanControl.app inside this checkout\'s build/ directory') + template = ROOT / 'packaging/app/Info.plist' + info = plistlib.loads(template.read_bytes()) + if args.version: + if not re.fullmatch(r'\d+\.\d+\.\d+', args.version): + parser.error('--version must be X.Y.Z') + info['CFBundleShortVersionString'] = args.version + if args.build is not None: + if args.build < 1: + parser.error('--build must be positive') + info['CFBundleVersion'] = str(args.build) + if args.channel == 'beta': + info['IFanControlReleaseChannel'] = 'beta' + elif args.channel == 'stable': + info.pop('IFanControlReleaseChannel', None) + run('swift', 'build', '-c', 'release') + bin_path = Path(subprocess.check_output(['swift', 'build', '-c', 'release', '--show-bin-path'], cwd=ROOT, text=True).strip()) + if output.exists(): + shutil.rmtree(output) # Only this generated bundle under build/ is replaced. + (output / 'Contents/MacOS').mkdir(parents=True) + (output / 'Contents/Resources').mkdir() + shutil.copy2(bin_path / 'MacFanControl', output / 'Contents/MacOS/iFanControl') + shutil.copy2(ROOT / 'icon.png', output / 'Contents/Resources/icon.png') + if args.version is None and args.build is None and args.channel is None: + shutil.copy2(template, output / 'Contents/Info.plist') + else: + (output / 'Contents/Info.plist').write_bytes(plistlib.dumps(info)) + run('codesign', '--force', '--sign', '-', output) + run('codesign', '--verify', '--strict', output) + record = { + 'source_commit': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(), + 'source_dirty': bool(subprocess.check_output(['git', 'status', '--porcelain'], cwd=ROOT, text=True)), + 'version': info['CFBundleShortVersionString'], + 'build': int(info['CFBundleVersion']), + 'channel': info.get('IFanControlReleaseChannel', 'stable'), + 'app': str(output.relative_to(ROOT)), + 'executable_sha256': hashlib.sha256((output / 'Contents/MacOS/iFanControl').read_bytes()).hexdigest(), + 'info_plist_sha256': hashlib.sha256((output / 'Contents/Info.plist').read_bytes()).hexdigest(), + 'icon_sha256': hashlib.sha256((ROOT / 'icon.png').read_bytes()).hexdigest(), + } + (output.parent / 'app-build.json').write_text(json.dumps(record, indent=2) + '\n') + print(f"Built {record['version']} / {record['channel']} / build {record['build']}: {output}") + print('No application launched or installed.') + + +if __name__ == '__main__': + main() diff --git a/scripts/verify_release_source.py b/scripts/verify_release_source.py new file mode 100644 index 0000000..9747ec5 --- /dev/null +++ b/scripts/verify_release_source.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Verify a committed release record against current files or a Git revision.""" +from pathlib import Path +import argparse +import hashlib +import json +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('record', type=Path) + parser.add_argument('--ref', help='Git commit or release tag; otherwise checks working files') + args = parser.parse_args() + record = json.loads(args.record.read_text()) + for name, expected in record['source_hashes'].items(): + data = subprocess.check_output(['git', 'show', f'{args.ref}:{name}'], cwd=ROOT) if args.ref else (ROOT / name).read_bytes() + if hashlib.sha256(data).hexdigest() != expected: + raise SystemExit(f'Release source differs: {name}') + print(f"PASS: {record['tag']} / {len(record['source_hashes'])} tracked inputs match recorded release") + + +if __name__ == '__main__': + main()