diff --git a/CMakeLists.txt b/CMakeLists.txt index 63455f1..3d2e8ce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(QtDesktopPet VERSION 0.1.0 LANGUAGES CXX) +project(QtDesktopPet VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -89,6 +89,8 @@ qt_add_executable(QtDesktopPet src/weather/WeatherConfig.h src/weather/WeatherManager.h src/weather/WeatherManager.cpp + src/weather/WeatherLocationResolver.h + src/weather/WeatherLocationResolver.cpp src/weather/WeatherParser.h src/weather/WeatherParser.cpp src/weather/WeatherStore.h diff --git a/README.dev.md b/README.dev.md index 4fa01cf..79f85b6 100644 --- a/README.dev.md +++ b/README.dev.md @@ -382,6 +382,7 @@ dist/QtDesktopPet--windows-x64.zip ```text QtDesktopPet.exe Qt runtime +MSVC x64 runtime(MSVC 构建时采用应用本地部署) resources/characters/ resources/icons/ resources/sounds/ @@ -397,6 +398,17 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` -WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe ``` +对于 MSVC 构建,脚本会从 Visual Studio Redistributable 目录复制 x64 CRT DLL,确保 ZIP 和安装版无需预装 Visual C++ 运行库即可启动。建议从 Visual Studio Developer PowerShell/Command Prompt 运行;也可以显式传入运行库目录: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` + -ExePath build/release/QtDesktopPet.exe ` + -WindeployQtPath D:\Qt\6.x.x\msvcXXXX_64\bin\windeployqt.exe ` + -MsvcRuntimeDir "D:\VisualStudio\VC\Redist\MSVC\\x64\Microsoft.VC143.CRT" +``` + +MSVC 运行库目录缺失或不完整时,脚本会在删除旧发布目录前终止,不生成可能无法在干净电脑启动的产物。 + 生成 Inno Setup 安装器: ```powershell diff --git a/README.md b/README.md index c5b804c..d3154fe 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ - 天气查询: - Open-Meteo Forecast API - Open-Meteo Geocoding API - - 默认城市、公网 IP 定位兜底、多候选提示 + - 默认城市、可信同名候选提示,以及常用海外城市中文名解析 + - 公网 IP 定位依次尝试 ipapi.co 和 ipwho.is,单个服务故障时自动切换 - Windows 发布脚本和 Inno Setup 安装器脚本。 ## Platform @@ -118,6 +119,8 @@ powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 ` -BuildInstaller ``` +For MSVC builds, run the package script from a Visual Studio developer shell or pass `-MsvcRuntimeDir`. The script deploys the x64 MSVC runtime beside the application and fails early when the required runtime files are unavailable. + The installer supports optional desktop shortcut creation and optional Windows startup launch. Both are disabled by default. ## Runtime Data diff --git a/docs/QtDesktopPet_测试清单与验收标准.md b/docs/QtDesktopPet_测试清单与验收标准.md index d0a07c4..7ad602a 100644 --- a/docs/QtDesktopPet_测试清单与验收标准.md +++ b/docs/QtDesktopPet_测试清单与验收标准.md @@ -169,7 +169,7 @@ Qt 版本: powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-StaticChecks.ps1 ``` -当前自动化覆盖意图分发、提醒解析和类型、天气解析和模板、联网能力检测、聊天历史兼容,以及源码登记、废弃模块残留、默认资源和 GitHub 导出隔离。真实窗口、托盘通知、音效、网络 Provider、安装卸载仍按手测执行。 +当前自动化覆盖意图分发、提醒解析和类型、天气解析/城市别名/IP 定位响应/模板、联网能力检测、聊天历史兼容、配置损坏恢复与 AI 诊断脱敏。静态检查覆盖源码登记、废弃模块残留、默认资源、GitHub 导出隔离、发布包必要输入和安装器关键安全项。真实窗口、托盘通知、音效、外部网络服务、Release 二进制和安装卸载仍按手测执行。 ## 发布与性能 @@ -177,7 +177,7 @@ powershell -NoProfile -ExecutionPolicy Bypass -File internal/qa/scripts/Invoke-S | 用例 | 步骤 | 预期结果 | 结论 | | --- | --- | --- | --- | | Release 构建 | 用户手动构建 Release | 构建成功,无新增编译错误 | TODO | -| 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、resources、README、LICENSE | TODO | +| 打包 | 运行 `tools/package_release.ps1` | 包含 exe、Qt runtime、MSVC x64 runtime、resources、README、LICENSE;MSVC 运行库缺失时打包直接失败 | TODO | | 安装器 | 生成并安装 Inno Setup 包 | 无 Qt 开发环境机器可运行 | TODO | | 安装器自启动选项 | 安装页面勾选/不勾选开机自启动分别安装 | 勾选时写入当前用户 Run 项;不勾选时不新增自启动项;选项默认不勾选 | TODO | | 卸载清理自启动 | 已启用开机自启动后卸载 | 当前用户 Run 项被清理,不残留开机启动入口 | TODO | @@ -211,4 +211,4 @@ UI 状态: | 历史 | 设置页聊天页和 `ConversationManager/ConversationStore` | 新消息写入 `timestamp/provider/model`;旧 role/content 历史兼容读取;支持关键词、Provider、模型筛选;Markdown/JSON 导出 | 本地历史关闭时只显示内存记录;损坏历史备份后忽略;导出路径不可写时提示错误 | 超大历史由保存上限和筛选 UI 控制;导出文件由用户选择路径 | 历史管理补全完成,不导出 API Key,不记录完整聊天日志 | | 本地能力收缩 | `CommandDispatcher -> Reminder / Weather / Chat` | 本地文件、应用启动和工作区 Agent 文本不再进入系统工具链,且旧配置只被忽略 | AI 仍可能解释如何手动操作,但不得声称已访问文件、启动程序或执行命令 | 重点防止残留入口、旧配置读取和意图枚举引用 | 三个本地高权限模块已从当前产品中移除 | | 联网模式 | `ChatInputDialog -> PetWindow::submitWebChatMessage() -> WebChatManager` | 输入框联网开关开启后,支持 OpenAI 官方 Web Search 和 Gemini Google Search grounding;记住开关状态时用户切换会立即保存 | AI 未配置、Provider 不支持、超时、网络失败、无来源均明确提示;Web 忙时不启动第二个请求 | 不再维护旧搜索源聚合或 SearXNG 旧配置 | 联网模式完成,结构化搜索 API/自建后端延期 | -| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;性能文档覆盖静置、AI、提醒、天气和联网模式 | `windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 验收入口已补齐,本轮不运行 CMake 或构建命令 | +| 发布/性能 | `tools/package_release.ps1` 和 `docs/performance_stability_check.md` | 发布脚本复制 exe、Qt runtime、角色/图标/音效资源、README、LICENSE;静态规则验证发布输入、内部 QA 隔离、安装器自启动与卸载清理声明 | 产品 Release、`windeployqt`、Inno Setup、安装/卸载验证需用户手动执行并记录 | 未经手动构建和实机安装不能确认二进制运行 | 内部 QA 已独立验证;产品 Release 与安装器等待用户构建输入 | diff --git a/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md b/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md index 4119177..b757d05 100644 --- a/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md +++ b/docs/test-records/2026-07-17_pre-release_d0d1de4-dirty.md @@ -4,12 +4,12 @@ | 项目 | 内容 | | --- | --- | -| 测试日期 | 2026-07-17 | -| 版本 / 发布阶段 | 当前开发版 / 预发布回归进行中 | -| 测试源码基线 | `d0d1de4-dirty`;包含已确认的产品能力收缩、无用代码清理和内部 QA 基础设施 | -| 工作树说明 | 用户手测后只新增测试记录,未再修改产品业务代码 | -| 构建类型 | 用户未反馈,待补充 | -| 编译器 / Qt | 待补充 | +| 测试日期 | 2026-07-17 至 2026-07-18;2026-09-08 至 2026-09-09 复测 | +| 版本 / 发布阶段 | `0.2.0` / 发布候选验收完成 | +| 测试源码基线 | `90262aa` + 本记录所在提交;`90262aa` 已推送开发仓库 | +| 工作树说明 | 连接测试、提醒按钮定位、天气城市/IP 定位、诊断脱敏、QA 扩展、中文安装器和发布脚本已完成收口 | +| 构建类型 | Release | +| 编译器 / Qt | Qt 6.5.3 / MSVC 19.44 | | 操作系统 | Windows,具体版本待补充 | | 显示器 / DPI | 待补充 | | 测试人 | 用户手动测试 | @@ -18,8 +18,8 @@ | 检查 | 结果 | 摘要 | | --- | --- | --- | -| 核心逻辑 C++ 测试 | NOT_RUN | 已建立 28 个用例;尚未手动构建内部 QA 工程 | -| 静态检查 | PASS | 13 项通过,0 项失败 | +| 核心逻辑 C++ 测试 | PASS | 2026-09-08 使用 Qt 6.5.3 / MSVC 19.44 构建并执行隔离 QA,43 项通过,0 项失败 | +| 静态检查 | PASS | 14 项通过,0 项失败;覆盖发布包、MSVC 运行库、中文安装器和隔离规则 | | 性能工具链自检 | PASS | 脱敏 fixture 和 1 秒临时进程采样均成功生成 PASS 报告;不代表产品性能 | | GitHub 导出隔离 | PASS | 静态确认公开导出排除 `docs/internal/reports`,发布构建和打包不依赖内部 QA | @@ -34,21 +34,114 @@ | 单实例唤醒 | PASS | 用户确认通过 | | 设置页居中 / 置前 | PASS | 用户确认通过 | | 应用设置保存及重启保持 | PASS | 用户确认通过 | +| 开机自启动 | WAIVED | 用户于 2026-09-08 决定免测并接受当前实现,不作为技术验证 PASS | +| 导入合法角色包 | PASS | 用户于 2026-07-18 确认通过 | +| 切换用户角色并重启保持 | PASS | 用户于 2026-07-18 确认通过 | +| 同 ID 用户角色取消覆盖 | PASS | 用户于 2026-07-18 确认通过 | +| 拒绝缺少 `character.json` 的角色包 | PASS | 用户于 2026-07-18 确认通过 | +| 拒绝缺少 `idle` 状态的角色包 | PASS | 用户于 2026-07-18 确认通过 | +| 内置角色删除保护 | PASS | 用户于 2026-07-18 确认通过 | +| 导出角色及文件完整性 | PASS | 用户于 2026-07-18 确认通过 | +| 导出目标冲突确认与取消保护 | PASS | 用户于 2026-07-18 确认通过 | +| 打开用户角色目录 | PASS | 用户于 2026-07-18 确认通过 | +| 删除当前用户角色并回退默认角色 | PASS | 用户于 2026-07-18 确认通过 | +| AI 配置保存并重启保持 | PASS | 用户于 2026-07-18 确认通过 | +| 正确配置连接测试 | PASS | `AI-CONNECT-001` 修复后,用户于 2026-09-08 使用原配置复测通过 | +| 错误 Key / Base URL 错误提示 | PASS | 用户于 2026-07-18 确认通过 | +| 普通聊天 | PASS | 用户于 2026-07-18 确认通过 | +| 流式回复 | PASS | 用户于 2026-07-18 确认通过 | +| AI 忙时拒绝第二个请求 | PASS | 用户于 2026-07-18 确认通过 | +| 取消 AI 请求 | PASS | 用户于 2026-07-18 确认通过 | +| 本地历史保存和重启恢复 | PASS | 用户于 2026-07-18 确认通过 | +| 历史关键词搜索 | PASS | 用户于 2026-07-18 确认通过 | +| Provider / 模型筛选 | PASS | 用户于 2026-07-18 确认通过 | +| 导出 Markdown | PASS | 用户于 2026-07-18 确认通过 | +| 导出 JSON | PASS | 用户于 2026-07-18 确认通过 | +| 关闭历史保存后不持久化新消息 | PASS | 用户于 2026-07-18 确认通过 | +| 清空聊天历史及二次确认 | PASS | 用户于 2026-07-18 确认通过 | +| 一次性提醒 | PASS | 用户于 2026-09-08 确认通过 | +| 中文相对时间 | PASS | 用户于 2026-09-08 确认通过 | +| 绝对日期 | PASS | 用户于 2026-09-08 确认通过 | +| 每天 / 每周 / 每月重复提醒 | PASS | 用户于 2026-09-08 确认通过 | +| 复杂重复规则拒绝 | PASS | 用户于 2026-09-08 确认通过 | +| 提醒列表 | PASS | 用户于 2026-09-08 确认通过 | +| 聊天取消提醒 | PASS | 用户于 2026-09-08 确认通过 | +| 设置页编辑 pending | PASS | 用户于 2026-09-08 确认通过 | +| 桌宠可见时触发 | PASS | 动态定位修复后,用户于 2026-09-08 复测通过 | +| 桌宠隐藏时触发 | PASS | 用户于 2026-09-08 确认通过 | +| AI 忙时触发 | PASS | 用户于 2026-09-08 确认通过 | +| 拖动中触发 | PASS | 用户于 2026-09-08 确认通过 | +| 多提醒同时触发 | PASS | 用户于 2026-09-08 确认通过 | +| 知道了 | PASS | 用户于 2026-09-08 确认通过 | +| 5分钟后再提醒 | PASS | 用户于 2026-09-08 确认通过 | +| 20天历史清理 | PASS | 用户于 2026-09-08 确认通过 | +| 自定义音效导入 / 试听 / 删除 | PASS | 用户于 2026-09-08 确认通过 | +| 内置音效保护 | PASS | 用户于 2026-09-08 确认通过 | +| 睡眠与系统时间变化 | PASS | 用户于 2026-09-08 确认通过 | +| 提醒保存失败回滚与重试 | PASS | 用户于 2026-09-08 确认通过 | +| 洛杉矶天气 | PASS | 用户于 2026-09-08 确认可识别并返回天气 | +| 华盛顿天气 | PASS | 用户于 2026-09-08 确认可识别并返回天气 | +| 西安天气候选 | PASS | 低价值同名候选过滤修复后,用户于 2026-09-08 完成 T06 最小清单复测 | +| 纽约天气 | PASS | 中文海外城市别名修复后,用户于 2026-09-08 完成 T06 最小清单复测 | +| 东京天气 | PASS | 中文别名与候选过滤修复后,用户于 2026-09-08 完成 T06 最小清单复测 | +| 日本东京天气 | PASS | 国家限定解析修复后,用户于 2026-09-08 完成 T06 最小清单复测 | +| 公网 IP 定位 | PASS | `ipapi.co` 失败后切换 `ipwho.is` 的备用链路通过用户复测 | +| 文件操作已移除 | PASS | 用户于 2026-09-08 确认不再弹文件选择框或访问本地文件 | +| 应用启动已移除 | PASS | 用户于 2026-09-08 确认不再弹 exe 选择/确认框或启动程序 | +| 工作区 Agent 已移除 | PASS | 用户于 2026-09-08 确认不再扫描目录或打开 Agent 窗口 | +| 本地能力菜单入口清理 | PASS | 用户于 2026-09-08 确认右键与托盘入口已清理 | +| 本地能力设置页清理 | PASS | 用户于 2026-09-08 确认应用启动和 Agent 页面已清理 | +| Agent 快捷键清理 | PASS | 用户于 2026-09-08 确认 `Alt+A` 不再呼出 Agent | +| 旧本地能力配置兼容 | PASS | 用户于 2026-09-08 确认保留旧配置启动不崩溃且不恢复旧能力 | +| `0.2.0` Release 构建 | PASS | 用户于 2026-09-08 使用 Qt 6.5.3 / MSVC Release Kit 完成构建 | +| 中文安装器界面 | PASS | 用户于 2026-09-09 确认安装器实机验收通过 | +| 桌面快捷方式和开机自启默认未选 | PASS | 用户于 2026-09-09 确认默认选项符合预期 | +| 安装完成并启动 | PASS | 用户于 2026-09-09 确认通过 | +| 覆盖安装 | PASS | 用户于 2026-09-09 确认通过 | +| 卸载时保留用户数据 | PASS | 用户于 2026-09-09 确认通过 | +| 卸载时删除用户数据 | PASS | 用户于 2026-09-09 确认通过 | + +## T09 自动化验证 + +| 用例 | 结果 | 实际结果 / 备注 | +| --- | --- | --- | +| App 配置损坏 | PASS | 损坏文件被备份,加载默认 AppConfig | +| AI 配置损坏 | PASS | 损坏文件被备份,回退未配置的 Custom Provider | +| 天气配置损坏 | PASS | 损坏文件被备份,回退 Open-Meteo 默认配置 | +| 联网配置损坏 | PASS | 损坏文件被备份,回退默认联网开关配置 | +| 提醒数据损坏 | PASS | 损坏文件被备份,返回空提醒列表 | +| 历史数据损坏 | PASS | 既有 ConversationStore 自动化验证备份并忽略损坏历史 | +| 日志隐私 | PASS | 诊断上下文、JSON 摘要和错误摘要均不泄露 Key、Bearer、token、message/content 内容 | +| 同秒重复损坏 | PASS | 连续损坏生成带后缀的独立备份,不覆盖前一份 | + +## T10 发布与安装验收 + +| 检查 | 结果 | 实际结果 / 备注 | +| --- | --- | --- | +| PowerShell / JSON 语法 | PASS | QA、性能、发布和 GitHub 导出脚本均可解析 | +| 发布包输入 | PASS | 包含 exe、Qt runtime、应用本地 MSVC x64 runtime、characters/icons/sounds、README、LICENSE | +| 发布隔离 | PASS | 发布脚本不复制 `docs/internal/reports`,产品 CMake/打包脚本不依赖内部 QA | +| 安装器结构 | PASS | 简体中文、per-user 安装、x64、文件递归、自启动默认未选、Run 项和卸载清理逻辑均已验证 | +| MSVC 依赖闭合 | PASS | `MSVCP140/140_1/140_2` 与 `VCRUNTIME140/140_1` 均随包部署,依赖扫描无缺失 | +| ZIP 发布包 | PASS | `QtDesktopPet-0.2.0-windows-x64.zip`,SHA256 `A247A6AB7EC467B2FC1EA2439611660E9E269E573860EDD587E7D65243768661` | +| 中文安装器 | PASS | `QtDesktopPet-0.2.0-windows-x64-setup.exe`,SHA256 `0FE7E215C649E4C2175736C8798BF25F1FDEB34C1B45632CD8824F3F6DDEE709` | +| Release / 实际打包 / 安装卸载 | PASS | 用户完成 Release 构建,并于 2026-09-09 确认中文安装、覆盖安装、启动和卸载数据选项通过 | +| 数字签名 | ACCEPTED_LIMITATION | 当前安装器未签名,Windows SmartScreen 可能显示“未知发布者”;不作为 `0.2.0` 预发布阻断项 | ## 批次状态 | 批次 | 范围 | 结果 | 备注 | | --- | --- | --- | --- | | T01 | 基础窗口与托盘 | PASS | 6 项基础窗口/托盘用例通过 | -| T02 | 应用设置与开机自启动 | IN_PROGRESS | 应用设置持久化通过;开机自启动尚未测试 | -| T03 | 角色管理 | NOT_RUN | | -| T04 | AI 与聊天历史 | NOT_RUN | | -| T05 | 定时提醒 | NOT_RUN | | -| T06 | 天气查询 | NOT_RUN | | -| T07 | 联网模式 | NOT_RUN | | -| T08 | 已移除能力与安全边界 | NOT_RUN | | -| T09 | 配置损坏与隐私 | NOT_RUN | | -| T10 | 打包、安装与卸载 | NOT_RUN | | +| T02 | 应用设置与开机自启动 | ACCEPTED | 应用设置持久化通过;开机自启动由用户决定免测并接受 | +| T03 | 角色管理 | PASS | 10 项全部通过 | +| T04 | AI 与聊天历史 | PASS | 连接测试修复后复测通过;本批次全部通过 | +| T05 | 定时提醒 | PASS | 动态按钮定位复测通过,提醒批次关闭 | +| T06 | 天气查询 | PASS | 海外中文城市、候选过滤、国家限定和 IP 备用服务均通过最小清单复测 | +| T07 | 联网模式 | BLOCKED | 当前没有可用的 OpenAI/Gemini 官方 Key;不是功能失败 | +| T08 | 已移除能力与安全边界 | PASS | 用户确认全部 7 项通过 | +| T09 | 配置损坏与隐私 | PASS | 8 项自动化验证通过,测试仅使用 Qt 测试专用配置目录 | +| T10 | 打包、安装与卸载 | PASS | `0.2.0` 中文安装器、运行库闭合、安装启动、覆盖安装及卸载均通过 | ## 性能状态 @@ -56,11 +149,16 @@ ## 缺陷与复测 -当前已执行项目未发现缺陷。 +| 缺陷 ID | 首次现象 | 严重级别 | 修复状态 | 复测结果 | +| --- | --- | --- | --- | --- | +| AI-CONNECT-001 | 正常聊天可用,但连接测试返回 `HTTP 200: AI response content is empty.` | Medium | 已移除测试请求的 16 token 限制,并补充 token 截断提示 | PASS / CLOSED(2026-09-08) | +| REMINDER-UI-001 | 到点提醒的“知道了”和“5分钟后再提醒”按钮距角色主体偏远 | Low | 已改为按当前角色帧 Alpha 可见边界和可见尺寸动态定位,并保留屏幕边缘回退 | PASS / CLOSED(2026-09-08) | +| WEATHER-GEO-001 | 西安出现低价值候选,纽约/东京中文名无法稳定解析,国家限定失败 | Medium | 增加集中式中文城市别名、国家限定、人口/地点等级候选过滤,并修复中文被误判为两位国家码 | PASS / CLOSED(2026-09-08) | +| WEATHER-IP-001 | ipapi.co 服务端错误或超时导致公网 IP 定位失败 | Medium | 改为 ipapi.co 失败或 5 秒超时后自动尝试 ipwho.is,全部失败才提示配置默认城市 | PASS / CLOSED(2026-09-08) | ## 当前结论 -- 已执行手测:7 项 `PASS`。 -- 已执行静态检查:13 项 `PASS`。 -- 未执行项目仍较多,本记录状态为 `IN_PROGRESS`。 -- 当前不能据此判定可发布。 +- 已执行手测:72 项 `PASS`,0 项 `FAIL`,1 项 `WAIVED`。 +- 已执行自动化:核心逻辑 C++ 测试 43 项 `PASS`,静态检查 14 项 `PASS`。 +- T07 因缺少官方联网 Key 处于 `BLOCKED`,不是已发现的功能失败;产品性能场景尚未执行。 +- `0.2.0` 可作为预发布版本发布;已知限制为联网模式未实测、安装器未数字签名,公开分发时应明确说明。 diff --git a/docs/test-records/index.md b/docs/test-records/index.md index f032e7d..8d53092 100644 --- a/docs/test-records/index.md +++ b/docs/test-records/index.md @@ -2,7 +2,7 @@ | 日期 | 版本 / 阶段 | Commit | 构建 | 自动化 | 手测 | 性能 | 发布结论 | 记录 | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 2026-07-17 | 预发布回归进行中 | `d0d1de4-dirty` | 用户构建,详情待补 | 静态 13/13;C++ NOT_RUN | 7 PASS,其余 NOT_RUN | NOT_RUN | IN_PROGRESS | `2026-07-17_pre-release_d0d1de4-dirty.md` | +| 2026-07-17 至 2026-07-18;2026-09-08 至 2026-09-09 复测 | `0.2.0` 发布候选 | 本记录所在提交 | Release / Qt 6.5.3 / MSVC 19.44 | 静态 14/14;C++ 43/43 | 72 PASS / 0 FAIL / 1 WAIVED;T07 无 Key 未测 | NOT_RUN | PASS_WITH_LIMITATIONS | `2026-07-17_pre-release_d0d1de4-dirty.md` | | 2026-07-17 | QA 基础设施 | `d0d1de4-dirty` | 未构建 | 静态 13/13;C++ NOT_RUN | NOT_RUN | fixture PASS | NOT EVALUATED | `2026-07-17_qa-infrastructure_d0d1de4-dirty.md` | 正式记录按时间倒序添加。模板和维护规则分别见 `TEMPLATE.md` 与 `README.md`。 diff --git a/installer/QtDesktopPet.iss b/installer/QtDesktopPet.iss index b1bf57b..a6d30be 100644 --- a/installer/QtDesktopPet.iss +++ b/installer/QtDesktopPet.iss @@ -1,9 +1,9 @@ #define AppName "QtDesktopPet" #ifndef AppVersion -#define AppVersion "0.1.0" +#define AppVersion "0.2.0" #endif #ifndef SourceDir -#define SourceDir "..\dist\QtDesktopPet-0.1.0-windows-x64" +#define SourceDir "..\dist\QtDesktopPet-0.2.0-windows-x64" #endif #ifndef OutputDir #define OutputDir "..\dist\installer" @@ -22,15 +22,19 @@ OutputBaseFilename=QtDesktopPet-{#AppVersion}-windows-x64-setup Compression=lzma2 SolidCompression=yes WizardStyle=modern +ShowLanguageDialog=no PrivilegesRequired=lowest ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible SetupIconFile=..\resources\icons\app_icon.ico UninstallDisplayIcon={app}\QtDesktopPet.exe +[Languages] +Name: "chinesesimplified"; MessagesFile: ".\languages\ChineseSimplified.isl" + [Tasks] -Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked -Name: "startup"; Description: "开机自启动"; GroupDescription: "Additional options:"; Flags: unchecked +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加快捷方式:"; Flags: unchecked +Name: "startup"; Description: "开机自启动"; GroupDescription: "附加选项:"; Flags: unchecked [Files] Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs @@ -40,11 +44,11 @@ Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: [Icons] Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}" -Name: "{group}\Uninstall QtDesktopPet"; Filename: "{uninstallexe}" +Name: "{group}\卸载 QtDesktopPet"; Filename: "{uninstallexe}" Name: "{userdesktop}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"; Tasks: desktopicon [Run] -Filename: "{app}\QtDesktopPet.exe"; Description: "Launch QtDesktopPet"; Flags: nowait postinstall skipifsilent +Filename: "{app}\QtDesktopPet.exe"; Description: "启动 QtDesktopPet"; Flags: nowait postinstall skipifsilent [Code] var diff --git a/installer/languages/ChineseSimplified.isl b/installer/languages/ChineseSimplified.isl new file mode 100644 index 0000000..30d9973 --- /dev/null +++ b/installer/languages/ChineseSimplified.isl @@ -0,0 +1,417 @@ +; *** Inno Setup version 6.5.0+ Chinese Simplified messages *** +; +; To download user-contributed translations of this file, go to: +; https://jrsoftware.org/files/istrans/ +; +; Note: When translating this text, do not add periods (.) to the end of +; messages that didn't have them already, because on those messages Inno +; Setup adds the periods automatically (appending a period would result in +; two periods being displayed). +; +; Maintainer: Zhenghan Yang (Kira) +; Email: 847320916@QQ.com +; Github: https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation +; Encoding: UTF-8 +; Translation based on network resource +; + +[LangOptions] +; The following three entries are very important. Be sure to read and +; understand the '[LangOptions] section' topic in the help file. +LanguageName=简体中文 +; About LanguageID, to reference link: +; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c +LanguageID=$0804 +; LanguageCodePage should always be set if possible, even if this file is Unicode +; For English it's set to zero anyway because English only uses ASCII characters +LanguageCodePage=936 +; If the language you are translating to requires special font faces or +; sizes, uncomment any of the following entries and change them accordingly. +;DialogFontName= +;DialogFontSize=9 +;DialogFontBaseScaleWidth=7 +;DialogFontBaseScaleHeight=15 +;WelcomeFontName=Segoe UI +;WelcomeFontSize=14 + +[Messages] + +; *** Application titles +SetupAppTitle=安装 +SetupWindowTitle=安装 - %1 +UninstallAppTitle=卸载 +UninstallAppFullTitle=%1 卸载 + +; *** Misc. common +InformationTitle=信息 +ConfirmTitle=确认 +ErrorTitle=错误 + +; *** SetupLdr messages +SetupLdrStartupMessage=现在将安装 %1。您想要继续吗? +LdrCannotCreateTemp=无法创建临时文件。安装程序已中止 +LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止 +HelpTextNote= + +; *** Startup error messages +LastErrorMessage=%1。%n%n错误 %2: %3 +SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。 +SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。 +SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。 +InvalidParameter=无效的命令行参数:%n%n%1 +SetupAlreadyRunning=安装程序已在运行。 +WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。 +WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。 +NotOnThisPlatform=此程序不能在 %1 上运行。 +OnlyOnThisPlatform=此程序只能在 %1 上运行。 +OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1 +WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。 +WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。 +AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。 +PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或高级用户组身份登录。 +SetupAppRunningError=安装程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 +UninstallAppRunningError=卸载程序检测到 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。 + +; *** Startup questions +PrivilegesRequiredOverrideTitle=选择安装程序安装模式 +PrivilegesRequiredOverrideInstruction=选择安装模式 +PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。 +PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。 +PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A) +PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A)(推荐) +PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M) +PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M)(推荐) + +; *** Misc. errors +ErrorCreatingDir=安装程序无法创建目录“%1” +ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件。 + +; *** Setup common messages +ExitSetupTitle=退出安装程序 +ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗? +AboutSetupMenuItem=关于安装程序(&A)... +AboutSetupTitle=关于安装程序 +AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4 +AboutSetupNote= +TranslatorNote=简体中文翻译由 Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +; *** Buttons +ButtonBack=< 上一步(&B) +ButtonNext=下一步(&N) > +ButtonInstall=安装(&I) +ButtonOK=确定 +ButtonCancel=取消 +ButtonYes=是(&Y) +ButtonYesToAll=全是(&A) +ButtonNo=否(&N) +ButtonNoToAll=全否(&O) +ButtonFinish=完成(&F) +ButtonBrowse=浏览(&B)... +ButtonWizardBrowse=浏览(&R)... +ButtonNewFolder=新建文件夹(&M) + +; *** "Select Language" dialog messages +SelectLanguageTitle=选择安装语言 +SelectLanguageLabel=选择安装时使用的语言。 + +; *** Common wizard text +ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。 +BeveledLabel= +BrowseDialogTitle=浏览文件夹 +BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。 +NewFolderName=新建文件夹 + +; *** "Welcome" wizard page +WelcomeLabel1=欢迎使用 [name] 安装向导 +WelcomeLabel2=即将在您的计算机上安装 [name/ver]。%n%n建议您在继续安装前关闭所有其他应用程序。 + +; *** "Password" wizard page +WizardPassword=密码 +PasswordLabel1=此安装程序需要密码验证。 +PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。 +PasswordEditLabel=密码(&P): +IncorrectPassword=您输入的密码不正确,请重新输入。 + +; *** "License Agreement" wizard page +WizardLicense=许可协议 +LicenseLabel=请在继续安装前阅读以下重要信息。 +LicenseLabel3=请阅读下列许可协议。在继续安装前您必须同意这些协议条款。 +LicenseAccepted=我同意此协议(&A) +LicenseNotAccepted=我不同意此协议(&D) + +; *** "Information" wizard pages +WizardInfoBefore=信息 +InfoBeforeLabel=请在继续安装前阅读以下重要信息。 +InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。 +WizardInfoAfter=信息 +InfoAfterLabel=请在继续安装前阅读以下重要信息。 +InfoAfterClickLabel=准备好继续安装后,点击“下一步”。 + +; *** "User Information" wizard page +WizardUserInfo=用户信息 +UserInfoDesc=请输入您的信息。 +UserInfoName=用户名(&U): +UserInfoOrg=组织(&O): +UserInfoSerial=序列号(&S): +UserInfoNameRequired=请输入用户名。 + +; *** "Select Destination Location" wizard page +WizardSelectDir=选择目标位置 +SelectDirDesc=您想将 [name] 安装在哪里? +SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。 +SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。 +DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。 +CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。 +CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。 +InvalidPath=您必须输入一个带驱动器盘符的完整路径,例如:%n%nC:\App%n%n或UNC路径:%n%n\\server\share +InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。 +DiskSpaceWarningTitle=磁盘空间不足 +DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您确定要继续吗? +DirNameTooLong=文件夹名称或路径太长。 +InvalidDirName=文件夹名称无效。 +BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1 +DirExistsTitle=文件夹已存在 +DirExists=文件夹:%n%n%1%n%n已经存在。您确定安装到这个文件夹中吗? +DirDoesntExistTitle=文件夹不存在 +DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗? + +; *** "Select Components" wizard page +WizardSelectComponents=选择组件 +SelectComponentsDesc=您想安装哪些程序组件? +SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。 +FullInstallation=完全安装 +; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language) +CompactInstallation=简洁安装 +CustomInstallation=自定义安装 +NoUninstallWarningTitle=组件已存在 +NoUninstallWarning=安装程序检测到下列组件已安装在您的计算机中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n您确定要继续吗? +ComponentSize1=%1 KB +ComponentSize2=%1 MB +ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。 +ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。 + +; *** "Select Additional Tasks" wizard page +WizardSelectTasks=选择附加任务 +SelectTasksDesc=您想要安装程序执行哪些附加任务? +SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。 + +; *** "Select Start Menu Folder" wizard page +WizardSelectProgramGroup=选择开始菜单文件夹 +SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式? +SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。 +SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。 +MustEnterGroupName=您必须输入一个文件夹名称。 +GroupNameTooLong=文件夹名称或路径太长。 +InvalidGroupName=文件夹名称无效。 +BadGroupName=文件夹名称不能包含下列任何字符:%n%n%1 +NoProgramGroupCheck2=不创建开始菜单文件夹(&D) + +; *** "Ready to Install" wizard page +WizardReady=准备安装 +ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的计算机。 +ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新查看或修改任何设置,点击“上一步”。 +ReadyLabel2b=点击“安装”继续此安装程序。 +ReadyMemoUserInfo=用户信息: +ReadyMemoDir=目标位置: +ReadyMemoType=安装类型: +ReadyMemoComponents=已选择组件: +ReadyMemoGroup=开始菜单文件夹: +ReadyMemoTasks=附加任务: + +; *** TDownloadWizardPage wizard page and DownloadTemporaryFile +DownloadingLabel2=正在下载文件... +ButtonStopDownload=停止下载(&S) +StopDownload=您确定要停止下载吗? +ErrorDownloadAborted=下载已中止。 +ErrorDownloadFailed=下载失败:%1 %2。 +ErrorDownloadSizeFailed=获取大小失败:%1 %2。 +ErrorProgress=无效的进度:%1 / %2。 +ErrorFileSize=文件大小错误:预期 %1,实际 %2。 + +; *** TExtractionWizardPage wizard page and ExtractArchive +ExtractingLabel=正在提取文件... +ButtonStopExtraction=停止提取(&S) +StopExtraction=您确定要停止提取吗? +ErrorExtractionAborted=提取已中止。 +ErrorExtractionFailed=提取失败:%1 + +; *** Archive extraction failure details +ArchiveIncorrectPassword=密码不正确。 +ArchiveIsCorrupted=压缩包已损坏。 +ArchiveUnsupportedFormat=不支持的压缩包格式。 + +; *** "Preparing to Install" wizard page +WizardPreparing=正在准备安装 +PreparingDesc=安装程序正在准备安装 [name] 到您的计算机。 +PreviousInstallNotCompleted=先前的程序安装或卸载未完成,需要您重启计算机以完成该安装。%n%n在重启计算机后,再次运行安装程序以完成 [name] 的安装。 +CannotContinue=安装程序不能继续。请点击“取消”退出。 +ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。 +ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。 +CloseApplications=自动关闭应用程序(&A) +DontCloseApplications=不要关闭应用程序(&D) +ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。 +PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n要立即重启吗? + +; *** "Installing" wizard page +WizardInstalling=正在安装 +InstallingLabel=安装程序正在安装 [name] 到您的计算机,请稍候。 + +; *** "Setup Completed" wizard page +FinishedHeadingLabel=完成 [name] 安装向导 +FinishedLabelNoIcons=安装程序已在您的计算机中安装了 [name]。 +FinishedLabel=安装程序已在您的计算机中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。 +ClickFinish=点击“完成”退出安装程序。 +FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的计算机。要立即重启吗? +FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的计算机。%n%n要立即重启吗? +ShowReadmeCheck=是,我想查阅自述文件 +YesRadio=是,立即重启计算机(&Y) +NoRadio=否,稍后重启计算机(&N) +; used for example as 'Run MyProg.exe' +RunEntryExec=运行 %1 +; used for example as 'View Readme.txt' +RunEntryShellExec=查阅 %1 + +; *** "Setup Needs the Next Disk" stuff +ChangeDiskTitle=安装程序需要下一张磁盘 +SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。 +PathLabel=路径(&P): +FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。 +SelectDirectoryLabel=请指定下一张磁盘的位置。 + +; *** Installation phase messages +SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。 +AbortRetryIgnoreSelectAction=选择操作 +AbortRetryIgnoreRetry=重试(&T) +AbortRetryIgnoreIgnore=忽略错误并继续(&I) +AbortRetryIgnoreCancel=取消安装 +RetryCancelSelectAction=选择操作 +RetryCancelRetry=重试(&T) +RetryCancelCancel=取消 + +; *** Installation status messages +StatusClosingApplications=正在关闭应用程序... +StatusCreateDirs=正在创建目录... +StatusExtractFiles=正在提取文件... +StatusDownloadFiles=正在下载文件... +StatusCreateIcons=正在创建快捷方式... +StatusCreateIniEntries=正在创建 INI 条目... +StatusCreateRegistryEntries=正在创建注册表条目... +StatusRegisterFiles=正在注册文件... +StatusSavingUninstall=正在保存卸载信息... +StatusRunProgram=正在完成安装... +StatusRestartingApplications=正在重启应用程序... +StatusRollback=正在撤销更改... + +; *** Misc. errors +ErrorInternal2=内部错误:%1。 +ErrorFunctionFailedNoCode=%1 失败。 +ErrorFunctionFailed=%1 失败;错误代码 %2。 +ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2。%n%3 +ErrorExecutingProgram=无法执行文件:%n%1 + +; *** Registry errors +ErrorRegOpenKey=打开注册表项时出错:%n%1\%2 +ErrorRegCreateKey=创建注册表项时出错:%n%1\%2 +ErrorRegWriteKey=写入注册表项时出错:%n%1\%2 + +; *** INI errors +ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。 + +; *** File copying errors +FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S)(不推荐) +FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I)(不推荐) +SourceIsCorrupted=源文件已损坏。 +SourceDoesntExist=源文件“%1”不存在。 +SourceVerificationFailed=源文件验证失败:%1 +VerificationSignatureDoesntExist=签名文件“%1”不存在。 +VerificationSignatureInvalid=签名文件“%1”无效。 +VerificationKeyNotFound=签名文件“%1”使用了未知的密钥。 +VerificationFileNameIncorrect=文件名不正确。 +VerificationFileTagIncorrect=文件标签不正确。 +VerificationFileSizeIncorrect=文件大小不正确。 +VerificationFileHashIncorrect=文件哈希值不正确。 +ExistingFileReadOnly2=无法替换已存在的文件,它是只读的。 +ExistingFileReadOnlyRetry=移除只读属性并重试(&R) +ExistingFileReadOnlyKeepExisting=保留已存在的文件(&K) +ErrorReadingExistingDest=尝试读取已存在的文件时出错: +FileExistsSelectAction=选择操作 +FileExists2=文件已经存在。 +FileExistsOverwriteExisting=覆盖已存在的文件(&O) +FileExistsKeepExisting=保留已存在的文件(&K) +FileExistsOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D) +ExistingFileNewerSelectAction=选择操作 +ExistingFileNewer2=已存在的文件比安装程序将要安装的文件还要新。 +ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O) +ExistingFileNewerKeepExisting=保留已存在的文件(&K)(推荐) +ExistingFileNewerOverwriteOrKeepAll=为接下来的冲突文件执行此操作(&D) +ErrorChangingAttr=尝试更改下列已存在的文件属性时出错: +ErrorCreatingTemp=尝试在目标目录创建文件时出错: +ErrorReadingSource=尝试读取下列源文件时出错: +ErrorCopying=尝试复制下列文件时出错: +ErrorDownloading=尝试下载文件时出错: +ErrorExtracting=尝试提取压缩包时出错: +ErrorReplacingExistingFile=尝试替换已存在的文件时出错: +ErrorRestartReplace=重启并替换失败: +ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错: +ErrorRegisterServer=无法注册 DLL/OCX:%1 +ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1。 +ErrorRegisterTypeLib=无法注册类型库:%1 + +; *** Uninstall display name markings +; used for example as 'My Program (32-bit)' +UninstallDisplayNameMark=%1 (%2) +; used for example as 'My Program (32-bit, All users)' +UninstallDisplayNameMarks=%1 (%2, %3) +UninstallDisplayNameMark32Bit=32 位 +UninstallDisplayNameMark64Bit=64 位 +UninstallDisplayNameMarkAllUsers=所有用户 +UninstallDisplayNameMarkCurrentUser=当前用户 + +; *** Post-installation errors +ErrorOpeningReadme=尝试打开自述文件时出错。 +ErrorRestartingComputer=安装程序无法重启计算机,请手动重启。 + +; *** Uninstaller messages +UninstallNotFound=文件“%1”不存在。无法卸载。 +UninstallOpenError=文件“%1”不能被打开。无法卸载 +UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载。 +UninstallUnknownEntry=卸载日志中遇到一个未知条目(%1)。 +ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗? +UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。 +OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。 +UninstallStatusLabel=正在从您的计算机中移除 %1,请稍候。 +UninstalledAll=已顺利从您的计算机中移除 %1。 +UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。 +UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的计算机。%n%n要立即重启吗? +UninstallDataCorrupted=文件“%1”已损坏。无法卸载。 + +; *** Uninstallation phase messages +ConfirmDeleteSharedFileTitle=删除共享文件? +ConfirmDeleteSharedFile2=系统表示下列共享文件已不再有任何程序使用。您希望卸载程序删除此共享文件吗?%n%n如果仍有程序正在使用此文件,删除后这些程序可能无法正常运行。如果您不能确定,请选择“否”,保留此文件在系统中不会造成任何损害。 +SharedFileNameLabel=文件名: +SharedFileLocationLabel=位置: +WizardUninstalling=卸载状态 +StatusUninstalling=正在卸载 %1... + +; *** Shutdown block reasons +ShutdownBlockReasonInstallingApp=正在安装 %1。 +ShutdownBlockReasonUninstallingApp=正在卸载 %1。 + +; The custom messages below aren't used by Setup itself, but if you make +; use of them in your scripts, you'll want to translate them. + +[CustomMessages] + +NameAndVersion=%1 版本 %2 +AdditionalIcons=附加快捷方式: +CreateDesktopIcon=创建桌面快捷方式(&D) +CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q) +ProgramOnTheWeb=%1 网站 +UninstallProgram=卸载 %1 +LaunchProgram=运行 %1 +AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A) +AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联... +AutoStartProgramGroupDescription=启动: +AutoStartProgram=自动启动 %1 +AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您确定要继续吗? diff --git a/installer/languages/LICENSE.txt b/installer/languages/LICENSE.txt new file mode 100644 index 0000000..4322df8 --- /dev/null +++ b/installer/languages/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 - 2020 kirakira + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/installer/languages/README.md b/installer/languages/README.md new file mode 100644 index 0000000..95a1ef0 --- /dev/null +++ b/installer/languages/README.md @@ -0,0 +1,7 @@ +# Inno Setup 简体中文翻译 + +`ChineseSimplified.isl` 来源: + +https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation + +该翻译适用于 Inno Setup 6.5 及以上版本,按同目录 `LICENSE.txt` 中的 MIT 许可证使用。 diff --git a/internal/qa/CMakeLists.txt b/internal/qa/CMakeLists.txt index 8847a6e..9bc27a7 100644 --- a/internal/qa/CMakeLists.txt +++ b/internal/qa/CMakeLists.txt @@ -14,7 +14,7 @@ if (NOT EXISTS "${PET_SOURCE_ROOT}/src" OR NOT EXISTS "${PET_SOURCE_ROOT}/CMakeL message(FATAL_ERROR "PET_SOURCE_ROOT does not point to a QtDesktopPet source tree: ${PET_SOURCE_ROOT}") endif() -find_package(Qt6 REQUIRED COMPONENTS Core) +find_package(Qt6 REQUIRED COMPONENTS Core Network) add_executable(QtDesktopPetCoreTests include/TestHarness.h @@ -24,6 +24,8 @@ add_executable(QtDesktopPetCoreTests tests/weather/WeatherTests.cpp tests/web/WebCapabilityTests.cpp tests/ai/ConversationStoreTests.cpp + tests/ai/AIDiagnosticsTests.cpp + tests/config/ConfigRecoveryTests.cpp ${PET_SOURCE_ROOT}/src/assistant/IntentRouter.h ${PET_SOURCE_ROOT}/src/assistant/IntentRouter.cpp ${PET_SOURCE_ROOT}/src/assistant/CommandDispatcher.h @@ -35,6 +37,10 @@ add_executable(QtDesktopPetCoreTests ${PET_SOURCE_ROOT}/src/reminder/ReminderTypes.cpp ${PET_SOURCE_ROOT}/src/weather/WeatherParser.h ${PET_SOURCE_ROOT}/src/weather/WeatherParser.cpp + ${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.h + ${PET_SOURCE_ROOT}/src/weather/WeatherLocationResolver.cpp + ${PET_SOURCE_ROOT}/src/weather/WeatherManager.h + ${PET_SOURCE_ROOT}/src/weather/WeatherManager.cpp ${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.h ${PET_SOURCE_ROOT}/src/weather/WeatherSummaryFormatter.cpp ${PET_SOURCE_ROOT}/src/weather/WeatherTypes.h @@ -45,9 +51,20 @@ add_executable(QtDesktopPetCoreTests ${PET_SOURCE_ROOT}/src/web/WebConfig.h ${PET_SOURCE_ROOT}/src/config/AIConfig.h ${PET_SOURCE_ROOT}/src/config/AIConfig.cpp + ${PET_SOURCE_ROOT}/src/config/AppConfig.h + ${PET_SOURCE_ROOT}/src/config/ConfigManager.h + ${PET_SOURCE_ROOT}/src/config/ConfigManager.cpp + ${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.h + ${PET_SOURCE_ROOT}/src/ai/AIDiagnostics.cpp ${PET_SOURCE_ROOT}/src/ai/ConversationStore.h ${PET_SOURCE_ROOT}/src/ai/ConversationStore.cpp ${PET_SOURCE_ROOT}/src/ai/LLMTypes.h + ${PET_SOURCE_ROOT}/src/weather/WeatherStore.h + ${PET_SOURCE_ROOT}/src/weather/WeatherStore.cpp + ${PET_SOURCE_ROOT}/src/web/WebStore.h + ${PET_SOURCE_ROOT}/src/web/WebStore.cpp + ${PET_SOURCE_ROOT}/src/reminder/ReminderStore.h + ${PET_SOURCE_ROOT}/src/reminder/ReminderStore.cpp ${PET_SOURCE_ROOT}/src/util/Logger.h ${PET_SOURCE_ROOT}/src/util/Logger.cpp ) @@ -58,7 +75,7 @@ target_include_directories(QtDesktopPetCoreTests "${PET_SOURCE_ROOT}" ) -target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core) +target_link_libraries(QtDesktopPetCoreTests PRIVATE Qt6::Core Qt6::Network) include(CTest) add_test(NAME QtDesktopPetCoreTests COMMAND QtDesktopPetCoreTests) diff --git a/internal/qa/README.md b/internal/qa/README.md index de2f3ea..233eebc 100644 --- a/internal/qa/README.md +++ b/internal/qa/README.md @@ -27,6 +27,8 @@ ctest --test-dir build/internal-qa --output-on-failure The test process enables Qt test mode and uses temporary directories for file fixtures. It must not read or modify the normal QtDesktopPet user configuration. +To isolate a suite while diagnosing failures, run the test executable with a +case-insensitive filter, for example `QtDesktopPetCoreTests.exe --filter=Weather`. ## Static Checks diff --git a/internal/qa/config/static-checks.json b/internal/qa/config/static-checks.json index 115495d..2391f0a 100644 --- a/internal/qa/config/static-checks.json +++ b/internal/qa/config/static-checks.json @@ -40,6 +40,8 @@ "resources/characters/shiroko/preview.png", "resources/icons/app_icon.ico", "resources/sounds/reminders/reminder_default.wav", + "installer/languages/ChineseSimplified.isl", + "installer/languages/LICENSE.txt", "tools/package_release.ps1", "tools/prepare_github_export.ps1" ], @@ -55,5 +57,52 @@ "releaseForbiddenPatterns": [ "internal/qa", "internal\\\\qa" - ] + ], + "releaseValidation": { + "powershellParseFiles": [ + "internal/qa/scripts/Invoke-StaticChecks.ps1", + "internal/qa/scripts/New-PerformanceReport.ps1", + "internal/qa/scripts/perf_sample.ps1", + "tools/package_release.ps1", + "tools/prepare_github_export.ps1" + ], + "packageScript": { + "path": "tools/package_release.ps1", + "requiredPatterns": [ + { "name": "Qt deployment tool", "pattern": "windeployqt" }, + { "name": "release runtime deployment", "pattern": "--release" }, + { "name": "compiler runtime deployment", "pattern": "--compiler-runtime" }, + { "name": "MSVC app-local deployment", "pattern": "Copy-MsvcRuntime" }, + { "name": "MSVC runtime input", "pattern": "MsvcRuntimeDir" }, + { "name": "MSVC runtime validation", "pattern": "vcruntime140_1\\.dll" }, + { "name": "character resources", "pattern": "resources\\\\characters" }, + { "name": "icon resources", "pattern": "resources\\\\icons" }, + { "name": "sound resources", "pattern": "resources\\\\sounds" }, + { "name": "README", "pattern": "README\\.md" }, + { "name": "LICENSE", "pattern": "LICENSE" }, + { "name": "installer build switch", "pattern": "BuildInstaller" }, + { "name": "Inno Setup compiler", "pattern": "ISCC\\.exe" } + ], + "forbiddenPatterns": [ + { "name": "internal QA copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*internal[\\\\/]qa" }, + { "name": "development docs copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*docs" }, + { "name": "test reports copy", "pattern": "Copy-(?:Item|DirectoryFresh)[^\\r\\n]*reports" } + ] + }, + "installerScript": { + "path": "installer/QtDesktopPet.iss", + "requiredPatterns": [ + { "name": "per-user install", "pattern": "PrivilegesRequired=lowest" }, + { "name": "x64 compatibility", "pattern": "ArchitecturesAllowed=x64compatible" }, + { "name": "Simplified Chinese installer", "pattern": "ChineseSimplified\\.isl" }, + { "name": "language dialog disabled", "pattern": "ShowLanguageDialog=no" }, + { "name": "unchecked startup option", "pattern": "Name:\\s*\"startup\"[^\\r\\n]*Flags:\\s*unchecked" }, + { "name": "startup registry entry", "pattern": "Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run" }, + { "name": "recursive package files", "pattern": "recursesubdirs\\s+createallsubdirs" }, + { "name": "uninstall data prompt", "pattern": "InitializeUninstall" }, + { "name": "optional user data cleanup", "pattern": "DeleteUserDataAfterUninstall" }, + { "name": "startup registry cleanup", "pattern": "RegDeleteValue" } + ] + } + } } diff --git a/internal/qa/scripts/Invoke-StaticChecks.ps1 b/internal/qa/scripts/Invoke-StaticChecks.ps1 index 605a2a0..13e5cc8 100644 --- a/internal/qa/scripts/Invoke-StaticChecks.ps1 +++ b/internal/qa/scripts/Invoke-StaticChecks.ps1 @@ -258,8 +258,36 @@ Add-Result -Name "Production build and packaging do not depend on internal QA" ` -Passed ($releaseIsolationMatches.Count -eq 0) ` -Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " })) +$releaseValidationProblems = New-Object System.Collections.Generic.List[string] +$packageValidation = $config.releaseValidation.packageScript +$packageScriptPath = Resolve-AbsolutePath -Path ([string]$packageValidation.path) -BasePath $resolvedRoot +$packageScriptText = Get-Content -LiteralPath $packageScriptPath -Raw -Encoding UTF8 +foreach ($rule in $packageValidation.requiredPatterns) { + if ($packageScriptText -notmatch ([string]$rule.pattern)) { + $releaseValidationProblems.Add("package missing $($rule.name)") + } +} +foreach ($rule in $packageValidation.forbiddenPatterns) { + if ($packageScriptText -match ([string]$rule.pattern)) { + $releaseValidationProblems.Add("package contains $($rule.name)") + } +} + +$installerValidation = $config.releaseValidation.installerScript +$installerScriptPath = Resolve-AbsolutePath -Path ([string]$installerValidation.path) -BasePath $resolvedRoot +$installerScriptText = Get-Content -LiteralPath $installerScriptPath -Raw -Encoding UTF8 +foreach ($rule in $installerValidation.requiredPatterns) { + if ($installerScriptText -notmatch ([string]$rule.pattern)) { + $releaseValidationProblems.Add("installer missing $($rule.name)") + } +} +Add-Result -Name "Release packaging and installer contain required safeguards" ` + -Passed ($releaseValidationProblems.Count -eq 0) ` + -Details ($(if ($releaseValidationProblems.Count -eq 0) { "Package inputs, QA isolation, startup option, and uninstall cleanup are declared" } else { $releaseValidationProblems -join "; " })) + $infrastructureProblems = New-Object System.Collections.Generic.List[string] -foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) { +foreach ($relativeScriptPath in $config.releaseValidation.powershellParseFiles) { + $scriptFile = Get-Item -LiteralPath (Resolve-AbsolutePath -Path ([string]$relativeScriptPath) -BasePath $resolvedRoot) $tokens = $null $parseErrors = $null [System.Management.Automation.Language.Parser]::ParseFile( diff --git a/internal/qa/tests/TestMain.cpp b/internal/qa/tests/TestMain.cpp index feef44e..eac344a 100644 --- a/internal/qa/tests/TestMain.cpp +++ b/internal/qa/tests/TestMain.cpp @@ -15,14 +15,32 @@ int main(int argc, char *argv[]) QTextStream output(stdout); QTextStream errors(stderr); + QString filter; + for (int index = 1; index < argc; ++index) + { + const QString argument = QString::fromLocal8Bit(argv[index]); + if (argument.startsWith(QStringLiteral("--filter="))) + { + filter = argument.mid(QStringLiteral("--filter=").size()).trimmed(); + } + } + int failedCount = 0; + int executedCount = 0; for (const Qa::TestCase &test : Qa::registry()) { + const QString testName = QString::fromStdString(test.name); + if (!filter.isEmpty() && !testName.contains(filter, Qt::CaseInsensitive)) + { + continue; + } + + ++executedCount; try { test.function(); - output << "[PASS] " << QString::fromStdString(test.name) << Qt::endl; + output << "[PASS] " << testName << Qt::endl; } catch (const std::exception &exception) { @@ -38,7 +56,7 @@ int main(int argc, char *argv[]) } } - output << "Executed " << Qa::registry().size() << " tests; " + output << "Executed " << executedCount << " tests; " << failedCount << " failed." << Qt::endl; return failedCount == 0 ? 0 : 1; } diff --git a/internal/qa/tests/ai/AIDiagnosticsTests.cpp b/internal/qa/tests/ai/AIDiagnosticsTests.cpp new file mode 100644 index 0000000..7004514 --- /dev/null +++ b/internal/qa/tests/ai/AIDiagnosticsTests.cpp @@ -0,0 +1,53 @@ +#include "TestHarness.h" + +#include "src/ai/AIDiagnostics.h" + +#include +#include + +QA_TEST(AIDiagnostics, RedactsCredentialsFromContextAndFreeText) +{ + AIConfig config; + config.provider = QStringLiteral("custom"); + config.protocol = QStringLiteral("openai-compatible"); + config.model = QStringLiteral("test-model"); + config.apiKey = QStringLiteral("sk-super-secret-value"); + + const QString context = AIDiagnostics::diagnosticContext( + config, + QUrl(QStringLiteral("https://example.test/v1/chat?api_key=query-secret&trace=visible"))); + const QString freeText = AIDiagnostics::safeTextSummary( + QStringLiteral("Authorization: Bearer bearer-secret token=token-secret sk-another-secret-value")); + + QA_EXPECT(!context.contains(QStringLiteral("sk-super-secret-value"))); + QA_EXPECT(!context.contains(QStringLiteral("query-secret"))); + QA_EXPECT(context.contains(QStringLiteral("trace=visible"))); + QA_EXPECT(!freeText.contains(QStringLiteral("bearer-secret"))); + QA_EXPECT(!freeText.contains(QStringLiteral("token-secret"))); + QA_EXPECT(!freeText.contains(QStringLiteral("sk-another-secret-value"))); +} + +QA_TEST(AIDiagnostics, RedactsConversationAndCredentialFieldsFromJson) +{ + const QByteArray body = QByteArrayLiteral( + R"({"safe":"visible","apiKey":"api-secret","authorization":"Bearer header-secret","messages":[{"content":"private prompt"}],"nested":{"token":"token-secret"}})"); + + const QString summary = AIDiagnostics::responseBodySummary(body); + QA_EXPECT(summary.contains(QStringLiteral("visible"))); + QA_EXPECT(!summary.contains(QStringLiteral("api-secret"))); + QA_EXPECT(!summary.contains(QStringLiteral("header-secret"))); + QA_EXPECT(!summary.contains(QStringLiteral("private prompt"))); + QA_EXPECT(!summary.contains(QStringLiteral("token-secret"))); +} + +QA_TEST(AIDiagnostics, KeepsUsefulErrorMetadataWithoutCredentialLeakage) +{ + const QByteArray body = QByteArrayLiteral( + R"({"error":{"message":"API key sk-super-secret-value is invalid","code":"invalid_api_key","type":"authentication_error"},"content":"private response"})"); + + const QString error = AIDiagnostics::errorMessageFromBody(body); + QA_EXPECT(error.contains(QStringLiteral("invalid"))); + QA_EXPECT(error.contains(QStringLiteral("invalid_api_key"))); + QA_EXPECT(!error.contains(QStringLiteral("sk-super-secret-value"))); + QA_EXPECT(!error.contains(QStringLiteral("private response"))); +} diff --git a/internal/qa/tests/config/ConfigRecoveryTests.cpp b/internal/qa/tests/config/ConfigRecoveryTests.cpp new file mode 100644 index 0000000..8b2cb81 --- /dev/null +++ b/internal/qa/tests/config/ConfigRecoveryTests.cpp @@ -0,0 +1,153 @@ +#include "TestHarness.h" + +#include "src/config/ConfigManager.h" +#include "src/reminder/ReminderStore.h" +#include "src/weather/WeatherStore.h" +#include "src/web/WebStore.h" + +#include +#include +#include +#include + +namespace +{ +bool writeBrokenJson(const QString &filePath) +{ + QDir().mkpath(QFileInfo(filePath).absolutePath()); + QFile file(filePath); + return file.open(QIODevice::WriteOnly | QIODevice::Truncate) + && file.write(QByteArrayLiteral("{broken")) == 7; +} + +QString qaConfigDirectory() +{ + return QFileInfo(ConfigManager().appConfigPath()).absolutePath(); +} + +bool isQaConfigDirectory(const QString &path) +{ + return path.contains(QStringLiteral("QtDesktopPetInternalQA"), Qt::CaseInsensitive) + || path.contains(QStringLiteral("qttest"), Qt::CaseInsensitive); +} + +void clearQaConfigFiles() +{ + const QString path = qaConfigDirectory(); + if (!isQaConfigDirectory(path)) + { + return; + } + + QDir directory(path); + const QStringList filters = { + QStringLiteral("app_config.json"), + QStringLiteral("app_config.broken.*.json"), + QStringLiteral("ai_config.json"), + QStringLiteral("ai_config.broken.*.json"), + QStringLiteral("weather_config.json"), + QStringLiteral("weather_config.broken.*.json"), + QStringLiteral("web_config.json"), + QStringLiteral("web_config.broken.*.json"), + QStringLiteral("reminders.json"), + QStringLiteral("reminders.broken.*.json"), + }; + for (const QString &fileName : directory.entryList(filters, QDir::Files)) + { + directory.remove(fileName); + } +} + +int backupCount(const QString &pattern) +{ + return QDir(qaConfigDirectory()).entryList({pattern}, QDir::Files).size(); +} + +class QaConfigFiles +{ +public: + QaConfigFiles() + { + QA_EXPECT(isQaConfigDirectory(qaConfigDirectory())); + clearQaConfigFiles(); + } + + ~QaConfigFiles() + { + clearQaConfigFiles(); + } +}; +} + +QA_TEST(ConfigRecovery, AppConfigFallsBackAndBacksUpBrokenJson) +{ + QaConfigFiles files; + ConfigManager manager; + QA_EXPECT(writeBrokenJson(manager.appConfigPath())); + + const AppConfig config = manager.loadAppConfig(); + QA_EXPECT_EQ(config.windowPosition, QPoint(100, 100)); + QA_EXPECT(config.alwaysOnTop); + QA_EXPECT(!QFile::exists(manager.appConfigPath())); + QA_EXPECT_EQ(backupCount(QStringLiteral("app_config.broken.*.json")), 1); +} + +QA_TEST(ConfigRecovery, AiConfigFallsBackAndBacksUpBrokenJson) +{ + QaConfigFiles files; + ConfigManager manager; + QA_EXPECT(writeBrokenJson(manager.aiConfigPath())); + + const AIConfigStore store = manager.loadAIConfigStore(); + QA_EXPECT_EQ(store.activeProvider, QStringLiteral("custom")); + QA_EXPECT(store.providers.isEmpty()); + QA_EXPECT(!QFile::exists(manager.aiConfigPath())); + QA_EXPECT_EQ(backupCount(QStringLiteral("ai_config.broken.*.json")), 1); +} + +QA_TEST(ConfigRecovery, WeatherAndWebConfigsFallBackAndBackUpBrokenJson) +{ + QaConfigFiles files; + WeatherStore weatherStore; + WebStore webStore; + QA_EXPECT(writeBrokenJson(weatherStore.storePath())); + QA_EXPECT(writeBrokenJson(webStore.storePath())); + + QString weatherError; + QString webError; + const WeatherConfig weather = weatherStore.load(&weatherError); + const WebConfig web = webStore.load(&webError); + QA_EXPECT_EQ(weather.provider, QStringLiteral("open-meteo")); + QA_EXPECT(weather.autoLocateWhenNoDefault); + QA_EXPECT(web.enabled); + QA_EXPECT(!web.defaultToggleOn); + QA_EXPECT(!weatherError.isEmpty()); + QA_EXPECT(!webError.isEmpty()); + QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 1); + QA_EXPECT_EQ(backupCount(QStringLiteral("web_config.broken.*.json")), 1); +} + +QA_TEST(ConfigRecovery, ReminderStoreFallsBackToEmptyAndBacksUpBrokenJson) +{ + QaConfigFiles files; + ReminderStore store; + QA_EXPECT(writeBrokenJson(store.storePath())); + + QString error; + QA_EXPECT(store.load(&error).isEmpty()); + QA_EXPECT(!error.isEmpty()); + QA_EXPECT(!QFile::exists(store.storePath())); + QA_EXPECT_EQ(backupCount(QStringLiteral("reminders.broken.*.json")), 1); +} + +QA_TEST(ConfigRecovery, SameSecondBackupsNeverOverwriteEarlierFiles) +{ + QaConfigFiles files; + WeatherStore store; + QA_EXPECT(writeBrokenJson(store.storePath())); + store.load(); + QA_EXPECT(writeBrokenJson(store.storePath())); + store.load(); + + QA_EXPECT_EQ(backupCount(QStringLiteral("weather_config.broken.*.json")), 2); +} diff --git a/internal/qa/tests/weather/WeatherTests.cpp b/internal/qa/tests/weather/WeatherTests.cpp index 2048f72..0588dc7 100644 --- a/internal/qa/tests/weather/WeatherTests.cpp +++ b/internal/qa/tests/weather/WeatherTests.cpp @@ -1,8 +1,34 @@ #include "TestHarness.h" #include "src/weather/WeatherParser.h" +#include "src/weather/WeatherLocationResolver.h" #include "src/weather/WeatherSummaryFormatter.h" +namespace +{ +WeatherLocationCandidate candidate( + const QString &city, + const QString &admin, + const QString &country, + const QString &countryCode, + const QString &featureCode, + qint64 population, + double latitude, + double longitude) +{ + WeatherLocationCandidate value; + value.cityName = city; + value.adminName = admin; + value.countryName = country; + value.countryCode = countryCode; + value.featureCode = featureCode; + value.population = population; + value.latitude = latitude; + value.longitude = longitude; + return value; +} +} + QA_TEST(WeatherParser, ExtractsExplicitCitiesAndDates) { const WeatherParser parser; @@ -38,6 +64,120 @@ QA_TEST(WeatherParser, RejectsUnsupportedQueries) QA_EXPECT(!parser.parse(QStringLiteral("明天穿什么")).unsupportedReason.isEmpty()); } +QA_TEST(WeatherLocationResolver, ResolvesChineseInternationalCities) +{ + const WeatherLocationSearch newYork = WeatherLocationResolver::resolveSearch(QStringLiteral("纽约")); + const WeatherLocationSearch tokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京")); + const WeatherLocationSearch japanTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京")); + const WeatherLocationSearch qualifiedTokyo = WeatherLocationResolver::resolveSearch(QStringLiteral("东京,日本")); + + QA_EXPECT_EQ(newYork.queryName, QStringLiteral("New York")); + QA_EXPECT_EQ(newYork.countryCode, QStringLiteral("US")); + QA_EXPECT_EQ(tokyo.queryName, QStringLiteral("Tokyo")); + QA_EXPECT_EQ(tokyo.countryCode, QStringLiteral("JP")); + QA_EXPECT_EQ(japanTokyo.queryName, QStringLiteral("Tokyo")); + QA_EXPECT_EQ(japanTokyo.countryCode, QStringLiteral("JP")); + QA_EXPECT_EQ(qualifiedTokyo.queryName, QStringLiteral("Tokyo")); + QA_EXPECT_EQ(qualifiedTokyo.countryCode, QStringLiteral("JP")); +} + +QA_TEST(WeatherLocationResolver, PreservesDomesticCityAndCountryQualifier) +{ + const WeatherLocationSearch xian = WeatherLocationResolver::resolveSearch(QStringLiteral("西安市")); + const WeatherLocationSearch chinaXian = WeatherLocationResolver::resolveSearch(QStringLiteral("中国西安")); + + QA_EXPECT_EQ(xian.queryName, QStringLiteral("西安")); + QA_EXPECT(xian.countryCode.isEmpty()); + QA_EXPECT_EQ(chinaXian.queryName, QStringLiteral("西安")); + QA_EXPECT_EQ(chinaXian.countryCode, QStringLiteral("CN")); +} + +QA_TEST(WeatherLocationResolver, SuppressesLowValueAlternatives) +{ + const QVector candidates = { + candidate(QStringLiteral("西安"), QStringLiteral("陕西"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPLA"), 6500000, 34.26, 108.93), + candidate(QStringLiteral("西安"), QStringLiteral("湖南"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), 1200, 27.10, 111.20), + candidate(QStringLiteral("西安"), QStringLiteral("广东"), QStringLiteral("中国"), QStringLiteral("CN"), QStringLiteral("PPL"), -1, 23.10, 113.20), + }; + + const QVector selected = WeatherLocationResolver::selectRelevantCandidates( + candidates, + WeatherLocationResolver::resolveSearch(QStringLiteral("西安"))); + QA_EXPECT_EQ(selected.size(), 1); + QA_EXPECT(!WeatherLocationResolver::hasAmbiguity(selected)); +} + +QA_TEST(WeatherLocationResolver, KeepsPlausibleAmbiguity) +{ + const QVector candidates = { + candidate(QStringLiteral("Springfield"), QStringLiteral("Missouri"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA2"), 170000, 37.21, -93.29), + candidate(QStringLiteral("Springfield"), QStringLiteral("Illinois"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPLA"), 114000, 39.80, -89.64), + candidate(QStringLiteral("Springfield"), QStringLiteral("Georgia"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 2700, 32.37, -81.31), + }; + + const QVector selected = WeatherLocationResolver::selectRelevantCandidates( + candidates, + WeatherLocationResolver::resolveSearch(QStringLiteral("Springfield"))); + QA_EXPECT_EQ(selected.size(), 2); + QA_EXPECT(WeatherLocationResolver::hasAmbiguity(selected)); +} + +QA_TEST(WeatherLocationResolver, AppliesExplicitCountryFilter) +{ + const QVector candidates = { + candidate(QStringLiteral("Tokyo"), QStringLiteral("Tokyo"), QStringLiteral("Japan"), QStringLiteral("JP"), QStringLiteral("PPLC"), 8300000, 35.68, 139.69), + candidate(QStringLiteral("Tokyo"), QStringLiteral("Texas"), QStringLiteral("United States"), QStringLiteral("US"), QStringLiteral("PPL"), 1000, 33.90, -102.50), + }; + + const QVector selected = WeatherLocationResolver::selectRelevantCandidates( + candidates, + WeatherLocationResolver::resolveSearch(QStringLiteral("日本东京"))); + QA_EXPECT_EQ(selected.size(), 1); + QA_EXPECT_EQ(selected.first().countryCode, QStringLiteral("JP")); +} + +QA_TEST(WeatherLocationResolver, ParsesBothIpLocationSchemas) +{ + const QByteArray ipApiPayload = QByteArrayLiteral( + R"({"city":"Xi'an","region":"Shaanxi","country_name":"China","latitude":34.26,"longitude":108.94,"timezone":"Asia/Shanghai"})"); + const QByteArray ipWhoIsPayload = QByteArrayLiteral( + R"({"success":true,"city":"Tokyo","region":"Tokyo","country":"Japan","latitude":35.68,"longitude":139.69,"timezone":{"id":"Asia/Tokyo"}})"); + + WeatherLocation ipApiLocation; + WeatherLocation ipWhoIsLocation; + QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse( + ipApiPayload, + WeatherIpLocationProvider::IpApiCo, + &ipApiLocation)); + QA_EXPECT(WeatherLocationResolver::parseIpLocationResponse( + ipWhoIsPayload, + WeatherIpLocationProvider::IpWhoIs, + &ipWhoIsLocation)); + QA_EXPECT_EQ(ipApiLocation.cityName, QStringLiteral("Xi'an")); + QA_EXPECT_EQ(ipApiLocation.countryName, QStringLiteral("China")); + QA_EXPECT_EQ(ipApiLocation.timezone, QStringLiteral("Asia/Shanghai")); + QA_EXPECT_EQ(ipWhoIsLocation.cityName, QStringLiteral("Tokyo")); + QA_EXPECT_EQ(ipWhoIsLocation.countryName, QStringLiteral("Japan")); + QA_EXPECT_EQ(ipWhoIsLocation.timezone, QStringLiteral("Asia/Tokyo")); +} + +QA_TEST(WeatherLocationResolver, RejectsBrokenIpLocationResponses) +{ + WeatherLocation location; + QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse( + QByteArrayLiteral("{broken"), + WeatherIpLocationProvider::IpApiCo, + &location)); + QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse( + QByteArrayLiteral(R"({"success":false,"message":"rate limited"})"), + WeatherIpLocationProvider::IpWhoIs, + &location)); + QA_EXPECT(!WeatherLocationResolver::parseIpLocationResponse( + QByteArrayLiteral(R"({"city":"Tokyo","latitude":35.68})"), + WeatherIpLocationProvider::IpWhoIs, + &location)); +} + QA_TEST(WeatherFormatter, MapsWeatherCodesAndWindDirections) { QA_EXPECT_EQ(WeatherSummaryFormatter::weatherCodeText(0), QStringLiteral("晴")); diff --git a/src/ai/AIDiagnostics.cpp b/src/ai/AIDiagnostics.cpp index 181b2a8..5d35fd8 100644 --- a/src/ai/AIDiagnostics.cpp +++ b/src/ai/AIDiagnostics.cpp @@ -5,14 +5,34 @@ #include #include #include +#include #include #include +#include #include namespace { constexpr int MaxBodySummaryLength = 360; +QString redactSensitiveText(QString text) +{ + static const QVector credentialPatterns = { + QRegularExpression(QStringLiteral("\\bBearer\\s+[^\\s,;]+"), QRegularExpression::CaseInsensitiveOption), + QRegularExpression(QStringLiteral("\\bsk-[A-Za-z0-9_-]{8,}"), QRegularExpression::CaseInsensitiveOption), + QRegularExpression(QStringLiteral("\\bAIza[A-Za-z0-9_-]{20,}")), + QRegularExpression( + QStringLiteral("\\b(api[_-]?key|token|authorization|password|secret)\\s*[:=]\\s*[^\\s,;]+"), + QRegularExpression::CaseInsensitiveOption), + }; + + for (const QRegularExpression &pattern : credentialPatterns) + { + text.replace(pattern, QStringLiteral("")); + } + return text; +} + bool isSensitiveName(const QString &name) { const QString lowerName = name.toLower(); @@ -96,7 +116,7 @@ QString oneLine(QString text) text.replace(QLatin1Char('\r'), QLatin1Char(' ')); text.replace(QLatin1Char('\n'), QLatin1Char(' ')); text.replace(QLatin1Char('\t'), QLatin1Char(' ')); - return text.simplified(); + return redactSensitiveText(text.simplified()); } QString safeTextSummary(const QString &text, int maxLength) diff --git a/src/ai/OpenAICompatibleProvider.cpp b/src/ai/OpenAICompatibleProvider.cpp index 52992ec..78ecb89 100644 --- a/src/ai/OpenAICompatibleProvider.cpp +++ b/src/ai/OpenAICompatibleProvider.cpp @@ -361,14 +361,25 @@ ChatResponse OpenAICompatibleProvider::parseResponse(QNetworkReply *reply, const return {false, {}, QStringLiteral("AI response has no choices."), httpStatus}; } - const QJsonObject message = choices.first().toObject().value(QStringLiteral("message")).toObject(); + const QJsonObject choice = choices.first().toObject(); + const QJsonObject message = choice.value(QStringLiteral("message")).toObject(); const QString content = message.value(QStringLiteral("content")).toString(); if (content.isEmpty()) { + const QString finishReason = choice.value(QStringLiteral("finish_reason")).toString().trimmed(); Logger::warning(QStringLiteral("AI response content is empty: %1 httpStatus=%2 bodySummary=\"%3\"") .arg(AIDiagnostics::diagnosticContext(m_config, reply->request().url())) .arg(httpStatus) .arg(AIDiagnostics::responseBodySummary(body))); + if (finishReason == QStringLiteral("length")) + { + return { + false, + {}, + QStringLiteral("AI response reached the token limit before producing content. Increase Max Tokens."), + httpStatus, + }; + } return {false, {}, QStringLiteral("AI response content is empty."), httpStatus}; } diff --git a/src/ui/PetWindow.cpp b/src/ui/PetWindow.cpp index 19c393d..c98f9f9 100644 --- a/src/ui/PetWindow.cpp +++ b/src/ui/PetWindow.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -66,6 +67,51 @@ constexpr int ChatFinishedReturnDelayMs = 1500; constexpr int StandardPrewarmIntervalMs = 800; constexpr int LowPowerPrewarmIntervalMs = 1500; constexpr qint64 BytesPerMegabyte = 1024 * 1024; +constexpr int ReminderVisibleAlphaThreshold = 24; + +QRect visiblePixmapRect(const QPixmap &pixmap) +{ + if (pixmap.isNull()) + { + return {}; + } + + QImage image = pixmap.toImage(); + if (!image.hasAlphaChannel()) + { + return image.rect(); + } + + image = image.convertToFormat(QImage::Format_ARGB32); + int left = image.width(); + int top = image.height(); + int right = -1; + int bottom = -1; + + for (int y = 0; y < image.height(); ++y) + { + const QRgb *line = reinterpret_cast(image.constScanLine(y)); + for (int x = 0; x < image.width(); ++x) + { + if (qAlpha(line[x]) < ReminderVisibleAlphaThreshold) + { + continue; + } + + left = qMin(left, x); + top = qMin(top, y); + right = qMax(right, x); + bottom = qMax(bottom, y); + } + } + + if (right < left || bottom < top) + { + return {}; + } + + return QRect(QPoint(left, top), QPoint(right, bottom)); +} int boundedAnimationTargetSide(double sideLength) { @@ -1102,21 +1148,22 @@ void PetWindow::updateReminderActionPosition() m_reminderActionPanel->adjustSize(); const QSize panelSize = m_reminderActionPanel->sizeHint(); - const QRect petGeometry = frameGeometry(); - constexpr int PanelSideSpacing = 8; + const QRect petGeometry = visiblePetGeometry(); + const int petReferenceSide = qMin(petGeometry.width(), petGeometry.height()); + const int panelSideSpacing = qBound(4, qRound(static_cast(petReferenceSide) * 0.015), 10); QPoint position( - petGeometry.right() + PanelSideSpacing, + petGeometry.right() + 1 + panelSideSpacing, petGeometry.center().y() - panelSize.height() / 2); if (QScreen *screen = screenForPopup(this)) { const QRect availableGeometry = screen->availableGeometry(); - const int maxX = qMax(availableGeometry.left(), availableGeometry.right() - panelSize.width()); - const int maxY = qMax(availableGeometry.top(), availableGeometry.bottom() - panelSize.height()); + const int maxX = qMax(availableGeometry.left(), availableGeometry.right() - panelSize.width() + 1); + const int maxY = qMax(availableGeometry.top(), availableGeometry.bottom() - panelSize.height() + 1); if (position.x() > maxX) { - position.setX(petGeometry.left() - panelSize.width() - PanelSideSpacing); + position.setX(petGeometry.left() - panelSize.width() - panelSideSpacing); } position.setX(qBound( @@ -1708,6 +1755,33 @@ QPoint PetWindow::bubbleAnchorPosition() const return frameGeometry().topLeft() + QPoint(qRound(localAnchor.x()), qRound(localAnchor.y())); } +QRect PetWindow::visiblePetGeometry() const +{ + if (m_petView == nullptr) + { + return frameGeometry(); + } + + const QPixmap pixmap = m_petView->pixmap(); + if (pixmap.isNull()) + { + return frameGeometry(); + } + + if (m_visiblePetRectPixmapKey != pixmap.cacheKey()) + { + m_visiblePetRectPixmapKey = pixmap.cacheKey(); + m_visiblePetRectCache = visiblePixmapRect(pixmap); + } + + if (!m_visiblePetRectCache.isValid()) + { + return frameGeometry(); + } + + return m_visiblePetRectCache.translated(frameGeometry().topLeft()); +} + void PetWindow::playState(const QString &stateName, bool centerWindow) { playResolvedState(m_stateMachine.requestState(stateName), centerWindow); diff --git a/src/ui/PetWindow.h b/src/ui/PetWindow.h index 9cb5991..ca06902 100644 --- a/src/ui/PetWindow.h +++ b/src/ui/PetWindow.h @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -108,6 +109,7 @@ private: QPoint chatInputAnchorPosition() const; void updateBubblePosition(); QPoint bubbleAnchorPosition() const; + QRect visiblePetGeometry() const; void playState(const QString &stateName, bool centerWindow); void playResolvedState(const QString &stateName, bool centerWindow); QSize animationTargetSize() const; @@ -170,4 +172,6 @@ private: bool m_streamingTalkStarted = false; bool m_settingsFallbackInContextMenuEnabled = true; bool m_hasActiveTriggeredReminder = false; + mutable qint64 m_visiblePetRectPixmapKey = 0; + mutable QRect m_visiblePetRectCache; }; diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp index 0c4782b..9167f7d 100644 --- a/src/ui/SettingsDialog.cpp +++ b/src/ui/SettingsDialog.cpp @@ -1571,7 +1571,6 @@ AIConfig SettingsDialog::runtimeConfigFromForm(const QString &provider) const config.stream = false; config.temperature = 0.0; - config.maxTokens = qMin(config.maxTokens, 16); return config; } diff --git a/src/weather/WeatherLocationResolver.cpp b/src/weather/WeatherLocationResolver.cpp new file mode 100644 index 0000000..9e4678a --- /dev/null +++ b/src/weather/WeatherLocationResolver.cpp @@ -0,0 +1,390 @@ +#include "WeatherLocationResolver.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +struct CityAlias +{ + QString queryName; + QString countryCode; +}; + +QString compactKey(QString text) +{ + text = text.trimmed().toLower(); + text.remove(QRegularExpression(QStringLiteral("[\\s,,、。.]+"))); + return text; +} + +QString cityAliasKey(QString text) +{ + text = compactKey(text); + if (text.size() > 2 && text.endsWith(QStringLiteral("市"))) + { + text.chop(1); + } + return text; +} + +const QVector> &countryAliases() +{ + static const QVector> aliases = { + {QStringLiteral("中华人民共和国"), QStringLiteral("CN")}, + {QStringLiteral("United States"), QStringLiteral("US")}, + {QStringLiteral("United Kingdom"), QStringLiteral("GB")}, + {QStringLiteral("澳大利亚"), QStringLiteral("AU")}, + {QStringLiteral("马来西亚"), QStringLiteral("MY")}, + {QStringLiteral("印度尼西亚"), QStringLiteral("ID")}, + {QStringLiteral("新西兰"), QStringLiteral("NZ")}, + {QStringLiteral("新加坡"), QStringLiteral("SG")}, + {QStringLiteral("加拿大"), QStringLiteral("CA")}, + {QStringLiteral("菲律宾"), QStringLiteral("PH")}, + {QStringLiteral("俄罗斯"), QStringLiteral("RU")}, + {QStringLiteral("西班牙"), QStringLiteral("ES")}, + {QStringLiteral("阿联酋"), QStringLiteral("AE")}, + {QStringLiteral("土耳其"), QStringLiteral("TR")}, + {QStringLiteral("墨西哥"), QStringLiteral("MX")}, + {QStringLiteral("意大利"), QStringLiteral("IT")}, + {QStringLiteral("Australia"), QStringLiteral("AU")}, + {QStringLiteral("Malaysia"), QStringLiteral("MY")}, + {QStringLiteral("Indonesia"), QStringLiteral("ID")}, + {QStringLiteral("Singapore"), QStringLiteral("SG")}, + {QStringLiteral("Canada"), QStringLiteral("CA")}, + {QStringLiteral("Philippines"), QStringLiteral("PH")}, + {QStringLiteral("Russia"), QStringLiteral("RU")}, + {QStringLiteral("Germany"), QStringLiteral("DE")}, + {QStringLiteral("France"), QStringLiteral("FR")}, + {QStringLiteral("Britain"), QStringLiteral("GB")}, + {QStringLiteral("England"), QStringLiteral("GB")}, + {QStringLiteral("Japan"), QStringLiteral("JP")}, + {QStringLiteral("中国"), QStringLiteral("CN")}, + {QStringLiteral("美国"), QStringLiteral("US")}, + {QStringLiteral("英国"), QStringLiteral("GB")}, + {QStringLiteral("法国"), QStringLiteral("FR")}, + {QStringLiteral("德国"), QStringLiteral("DE")}, + {QStringLiteral("日本"), QStringLiteral("JP")}, + {QStringLiteral("韩国"), QStringLiteral("KR")}, + {QStringLiteral("印度"), QStringLiteral("IN")}, + {QStringLiteral("泰国"), QStringLiteral("TH")}, + {QStringLiteral("越南"), QStringLiteral("VN")}, + {QStringLiteral("巴西"), QStringLiteral("BR")}, + {QStringLiteral("埃及"), QStringLiteral("EG")}, + {QStringLiteral("China"), QStringLiteral("CN")}, + {QStringLiteral("USA"), QStringLiteral("US")}, + {QStringLiteral("Korea"), QStringLiteral("KR")}, + {QStringLiteral("India"), QStringLiteral("IN")}, + {QStringLiteral("Thailand"), QStringLiteral("TH")}, + {QStringLiteral("Vietnam"), QStringLiteral("VN")}, + {QStringLiteral("Brazil"), QStringLiteral("BR")}, + {QStringLiteral("Mexico"), QStringLiteral("MX")}, + {QStringLiteral("Egypt"), QStringLiteral("EG")}, + {QStringLiteral("Turkey"), QStringLiteral("TR")}, + }; + return aliases; +} + +QString countryCodeForText(const QString &text) +{ + const QString key = compactKey(text); + const auto isAsciiLetter = [](QChar character) { + const ushort value = character.unicode(); + return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z'); + }; + if (key.size() == 2 && isAsciiLetter(key.at(0)) && isAsciiLetter(key.at(1))) + { + return key.toUpper(); + } + + for (const auto &alias : countryAliases()) + { + if (key == compactKey(alias.first)) + { + return alias.second; + } + } + return {}; +} + +const QHash &cityAliases() +{ + static const QHash aliases = { + {QStringLiteral("纽约"), {QStringLiteral("New York"), QStringLiteral("US")}}, + {QStringLiteral("东京"), {QStringLiteral("Tokyo"), QStringLiteral("JP")}}, + {QStringLiteral("伦敦"), {QStringLiteral("London"), QStringLiteral("GB")}}, + {QStringLiteral("巴黎"), {QStringLiteral("Paris"), QStringLiteral("FR")}}, + {QStringLiteral("柏林"), {QStringLiteral("Berlin"), QStringLiteral("DE")}}, + {QStringLiteral("罗马"), {QStringLiteral("Rome"), QStringLiteral("IT")}}, + {QStringLiteral("马德里"), {QStringLiteral("Madrid"), QStringLiteral("ES")}}, + {QStringLiteral("莫斯科"), {QStringLiteral("Moscow"), QStringLiteral("RU")}}, + {QStringLiteral("首尔"), {QStringLiteral("Seoul"), QStringLiteral("KR")}}, + {QStringLiteral("曼谷"), {QStringLiteral("Bangkok"), QStringLiteral("TH")}}, + {QStringLiteral("吉隆坡"), {QStringLiteral("Kuala Lumpur"), QStringLiteral("MY")}}, + {QStringLiteral("雅加达"), {QStringLiteral("Jakarta"), QStringLiteral("ID")}}, + {QStringLiteral("河内"), {QStringLiteral("Hanoi"), QStringLiteral("VN")}}, + {QStringLiteral("马尼拉"), {QStringLiteral("Manila"), QStringLiteral("PH")}}, + {QStringLiteral("新德里"), {QStringLiteral("New Delhi"), QStringLiteral("IN")}}, + {QStringLiteral("孟买"), {QStringLiteral("Mumbai"), QStringLiteral("IN")}}, + {QStringLiteral("悉尼"), {QStringLiteral("Sydney"), QStringLiteral("AU")}}, + {QStringLiteral("墨尔本"), {QStringLiteral("Melbourne"), QStringLiteral("AU")}}, + {QStringLiteral("奥克兰"), {QStringLiteral("Auckland"), QStringLiteral("NZ")}}, + {QStringLiteral("多伦多"), {QStringLiteral("Toronto"), QStringLiteral("CA")}}, + {QStringLiteral("温哥华"), {QStringLiteral("Vancouver"), QStringLiteral("CA")}}, + {QStringLiteral("墨西哥城"), {QStringLiteral("Mexico City"), QStringLiteral("MX")}}, + {QStringLiteral("圣保罗"), {QStringLiteral("Sao Paulo"), QStringLiteral("BR")}}, + {QStringLiteral("里约热内卢"), {QStringLiteral("Rio de Janeiro"), QStringLiteral("BR")}}, + {QStringLiteral("开罗"), {QStringLiteral("Cairo"), QStringLiteral("EG")}}, + {QStringLiteral("伊斯坦布尔"), {QStringLiteral("Istanbul"), QStringLiteral("TR")}}, + {QStringLiteral("迪拜"), {QStringLiteral("Dubai"), QStringLiteral("AE")}}, + }; + return aliases; +} + +QString candidateKey(const WeatherLocationCandidate &candidate) +{ + return compactKey(candidate.cityName) + + QLatin1Char('|') + + compactKey(candidate.adminName) + + QLatin1Char('|') + + compactKey(candidate.countryCode) + + QLatin1Char('|') + + QString::number(candidate.latitude, 'f', 4) + + QLatin1Char('|') + + QString::number(candidate.longitude, 'f', 4); +} + +int placeRank(const QString &featureCode) +{ + const QString code = featureCode.trimmed().toUpper(); + if (code == QStringLiteral("PPLC")) + { + return 5; + } + if (code == QStringLiteral("PPLA")) + { + return 4; + } + if (code == QStringLiteral("PPLA2")) + { + return 3; + } + if (code == QStringLiteral("PPLA3") || code == QStringLiteral("PPLA4")) + { + return 2; + } + if (code == QStringLiteral("PPL")) + { + return 1; + } + return 0; +} + +bool isPlausibleAlternative( + const WeatherLocationCandidate &selected, + const WeatherLocationCandidate &candidate) +{ + if (selected.population > 0 && candidate.population > 0) + { + const qint64 populationFloor = qMax(50000, selected.population / 5); + if (candidate.population >= populationFloor) + { + return true; + } + } + + const int selectedRank = placeRank(selected.featureCode); + const int candidateRank = placeRank(candidate.featureCode); + if (candidateRank >= 3 && candidateRank >= selectedRank - 1) + { + return true; + } + + return selected.population <= 0 + && candidate.population <= 0 + && qAbs(candidateRank - selectedRank) <= 1; +} +} + +WeatherLocationSearch WeatherLocationResolver::resolveSearch(const QString &cityText) +{ + WeatherLocationSearch result; + result.originalName = cityText.trimmed(); + QString cityName = result.originalName.simplified(); + + const QStringList qualifiedParts = cityName.split( + QRegularExpression(QStringLiteral("[,,]")), + Qt::SkipEmptyParts); + if (qualifiedParts.size() >= 2) + { + const QString trailingCountryCode = countryCodeForText(qualifiedParts.last()); + const QString leadingCountryCode = countryCodeForText(qualifiedParts.first()); + if (!trailingCountryCode.isEmpty()) + { + result.countryCode = trailingCountryCode; + cityName = qualifiedParts.first().trimmed(); + } + else if (!leadingCountryCode.isEmpty()) + { + result.countryCode = leadingCountryCode; + cityName = qualifiedParts.last().trimmed(); + } + } + + if (result.countryCode.isEmpty()) + { + const QString compactCityName = compactKey(cityName); + for (const auto &alias : countryAliases()) + { + const QString aliasKey = compactKey(alias.first); + if (compactCityName.startsWith(aliasKey) && compactCityName.size() > aliasKey.size() + 1) + { + result.countryCode = alias.second; + cityName = compactCityName.mid(aliasKey.size()); + break; + } + if (compactCityName.endsWith(aliasKey) && compactCityName.size() > aliasKey.size() + 1) + { + result.countryCode = alias.second; + cityName = compactCityName.left(compactCityName.size() - aliasKey.size()); + break; + } + } + } + + const QString aliasKey = cityAliasKey(cityName); + const auto alias = cityAliases().constFind(aliasKey); + if (alias != cityAliases().constEnd()) + { + result.queryName = alias->queryName; + if (result.countryCode.isEmpty()) + { + result.countryCode = alias->countryCode; + } + } + else + { + result.queryName = cityName.trimmed(); + if (result.queryName.size() > 2 && result.queryName.endsWith(QStringLiteral("市"))) + { + result.queryName.chop(1); + } + } + + result.countryCode = result.countryCode.trimmed().toUpper(); + return result; +} + +bool WeatherLocationResolver::parseIpLocationResponse( + const QByteArray &payload, + WeatherIpLocationProvider provider, + WeatherLocation *location) +{ + if (location == nullptr) + { + return false; + } + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + return false; + } + + const QJsonObject root = document.object(); + if (root.value(QStringLiteral("error")).toBool(false) + || (root.contains(QStringLiteral("success")) && !root.value(QStringLiteral("success")).toBool(true))) + { + return false; + } + + const QJsonValue latitude = root.value(QStringLiteral("latitude")); + const QJsonValue longitude = root.value(QStringLiteral("longitude")); + const QString cityName = root.value(QStringLiteral("city")).toString().trimmed(); + if (!latitude.isDouble() || !longitude.isDouble() || cityName.isEmpty()) + { + return false; + } + + WeatherLocation parsed; + parsed.cityName = cityName; + parsed.adminName = root.value(QStringLiteral("region")).toString().trimmed(); + parsed.countryName = provider == WeatherIpLocationProvider::IpApiCo + ? root.value(QStringLiteral("country_name")).toString().trimmed() + : root.value(QStringLiteral("country")).toString().trimmed(); + const QJsonValue timezone = root.value(QStringLiteral("timezone")); + parsed.timezone = timezone.isObject() + ? timezone.toObject().value(QStringLiteral("id")).toString().trimmed() + : timezone.toString().trimmed(); + parsed.latitude = latitude.toDouble(); + parsed.longitude = longitude.toDouble(); + parsed.source = WeatherLocationSource::IpFallback; + *location = parsed; + return true; +} + +QVector WeatherLocationResolver::selectRelevantCandidates( + const QVector &candidates, + const WeatherLocationSearch &search) +{ + QVector uniqueCandidates; + QSet seen; + for (const WeatherLocationCandidate &candidate : candidates) + { + if (!search.countryCode.isEmpty() + && !candidate.countryCode.isEmpty() + && candidate.countryCode.compare(search.countryCode, Qt::CaseInsensitive) != 0) + { + continue; + } + + const QString key = candidateKey(candidate); + if (seen.contains(key)) + { + continue; + } + seen.insert(key); + uniqueCandidates.append(candidate); + } + + if (uniqueCandidates.size() <= 1) + { + return uniqueCandidates; + } + + QVector relevantCandidates; + relevantCandidates.append(uniqueCandidates.first()); + for (int index = 1; index < uniqueCandidates.size(); ++index) + { + if (isPlausibleAlternative(uniqueCandidates.first(), uniqueCandidates.at(index))) + { + relevantCandidates.append(uniqueCandidates.at(index)); + } + } + return relevantCandidates; +} + +bool WeatherLocationResolver::hasAmbiguity(const QVector &candidates) +{ + if (candidates.size() <= 1) + { + return false; + } + + const QString firstKey = candidateKey(candidates.first()); + for (int index = 1; index < candidates.size(); ++index) + { + if (candidateKey(candidates.at(index)) != firstKey) + { + return true; + } + } + return false; +} diff --git a/src/weather/WeatherLocationResolver.h b/src/weather/WeatherLocationResolver.h new file mode 100644 index 0000000..9606994 --- /dev/null +++ b/src/weather/WeatherLocationResolver.h @@ -0,0 +1,19 @@ +#pragma once + +#include "WeatherTypes.h" + +#include + +class WeatherLocationResolver +{ +public: + static WeatherLocationSearch resolveSearch(const QString &cityText); + static bool parseIpLocationResponse( + const QByteArray &payload, + WeatherIpLocationProvider provider, + WeatherLocation *location); + static QVector selectRelevantCandidates( + const QVector &candidates, + const WeatherLocationSearch &search); + static bool hasAmbiguity(const QVector &candidates); +}; diff --git a/src/weather/WeatherManager.cpp b/src/weather/WeatherManager.cpp index 0d8aea4..6f32f10 100644 --- a/src/weather/WeatherManager.cpp +++ b/src/weather/WeatherManager.cpp @@ -1,6 +1,7 @@ #include "WeatherManager.h" #include "../util/Logger.h" +#include "WeatherLocationResolver.h" #include #include @@ -18,6 +19,8 @@ namespace { constexpr int ForecastDays = 4; constexpr int GeocodingCandidateCount = 5; +constexpr int IpLocationProviderCount = 2; +constexpr int IpLocationAttemptTimeoutMs = 5000; QString normalizedProvider(const WeatherConfig &config) { @@ -82,38 +85,6 @@ QJsonArray arrayValue(const QJsonObject &object, const QString &key) return value.isArray() ? value.toArray() : QJsonArray(); } -QString normalizedCandidateText(const QString &text) -{ - return text.trimmed().toLower(); -} - -QString candidateKey(const WeatherLocationCandidate &candidate) -{ - return normalizedCandidateText(candidate.cityName) - + QLatin1Char('|') - + normalizedCandidateText(candidate.adminName) - + QLatin1Char('|') - + normalizedCandidateText(candidate.countryName); -} - -bool hasCandidateAmbiguity(const QVector &candidates) -{ - if (candidates.size() <= 1) - { - return false; - } - - const QString firstKey = candidateKey(candidates.first()); - for (int index = 1; index < candidates.size(); ++index) - { - if (candidateKey(candidates.at(index)) != firstKey) - { - return true; - } - } - return false; -} - WeatherLocation locationFromCandidate(const WeatherLocationCandidate &candidate, WeatherLocationSource source) { WeatherLocation location; @@ -149,6 +120,13 @@ QVector candidatesFromGeocodingResults(const QJsonArra candidate.adminName = city.value(QStringLiteral("admin1")).toString(); candidate.countryName = city.value(QStringLiteral("country")).toString(); candidate.timezone = city.value(QStringLiteral("timezone")).toString(); + candidate.countryCode = city.value(QStringLiteral("country_code")).toString().trimmed().toUpper(); + candidate.featureCode = city.value(QStringLiteral("feature_code")).toString().trimmed().toUpper(); + const QJsonValue population = city.value(QStringLiteral("population")); + if (population.isDouble()) + { + candidate.population = qRound64(population.toDouble()); + } candidates.append(candidate); } return candidates; @@ -159,6 +137,16 @@ WeatherManager::WeatherManager() { m_timeoutTimer.setSingleShot(true); QObject::connect(&m_timeoutTimer, &QTimer::timeout, [this]() { + if (m_ipLocationAttemptActive) + { + Logger::warning(QStringLiteral("Weather IP location provider timed out: index=%1") + .arg(m_ipLocationProviderIndex)); + m_ipLocationAttemptActive = false; + clearReply(); + startNextIpLocationRequest(); + return; + } + if (m_locationTestOnly) { finishLocationTestWithError(QStringLiteral("城市测试超时,请稍后再试。")); @@ -293,6 +281,9 @@ void WeatherManager::cancel() m_locationTestOnly = false; m_locationCandidates.clear(); m_hasLocationAmbiguity = false; + m_locationSearch = {}; + m_ipLocationAttemptActive = false; + m_ipLocationProviderIndex = -1; clearReply(); m_busy = false; } @@ -302,13 +293,23 @@ void WeatherManager::startGeocodingRequest(const QString &cityName, WeatherLocat m_location = {}; m_location.source = source; m_locationTestOnly = locationTestOnly; + m_locationSearch = WeatherLocationResolver::resolveSearch(cityName); + if (m_locationSearch.queryName.trimmed().isEmpty()) + { + finishGeocodingWithError(QStringLiteral("没有识别到有效城市名称。")); + return; + } QUrl url(QStringLiteral("https://geocoding-api.open-meteo.com/v1/search")); QUrlQuery query; - query.addQueryItem(QStringLiteral("name"), cityName); + query.addQueryItem(QStringLiteral("name"), m_locationSearch.queryName); query.addQueryItem(QStringLiteral("count"), QString::number(GeocodingCandidateCount)); query.addQueryItem(QStringLiteral("language"), m_config.language); query.addQueryItem(QStringLiteral("format"), QStringLiteral("json")); + if (!m_locationSearch.countryCode.isEmpty()) + { + query.addQueryItem(QStringLiteral("countryCode"), m_locationSearch.countryCode); + } url.setQuery(query); startRequest(url, [this](QNetworkReply *reply) { @@ -322,11 +323,32 @@ void WeatherManager::startIpLocationRequest() m_location.source = WeatherLocationSource::IpFallback; m_locationCandidates.clear(); m_hasLocationAmbiguity = false; + m_ipLocationProviderIndex = -1; + m_ipLocationAttemptActive = false; + startNextIpLocationRequest(); +} - QUrl url(QStringLiteral("https://ipapi.co/json/")); - startRequest(url, [this](QNetworkReply *reply) { - finishIpLocationRequest(reply); - }); +void WeatherManager::startNextIpLocationRequest() +{ + ++m_ipLocationProviderIndex; + if (m_ipLocationProviderIndex >= IpLocationProviderCount) + { + m_ipLocationAttemptActive = false; + finishWithError(QStringLiteral("公网 IP 定位服务暂不可用,请在设置页填写默认城市后重试。")); + return; + } + + const WeatherIpLocationProvider provider = m_ipLocationProviderIndex == 0 + ? WeatherIpLocationProvider::IpApiCo + : WeatherIpLocationProvider::IpWhoIs; + const QUrl url(provider == WeatherIpLocationProvider::IpApiCo + ? QStringLiteral("https://ipapi.co/json/") + : QStringLiteral("https://ipwho.is/")); + + m_ipLocationAttemptActive = true; + startRequest(url, [this, provider](QNetworkReply *reply) { + finishIpLocationRequest(reply, provider); + }, qMin(m_config.timeoutMs, IpLocationAttemptTimeoutMs)); } void WeatherManager::startForecastRequest() @@ -352,7 +374,10 @@ void WeatherManager::finishGeocodingRequest(QNetworkReply *reply) const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); if (reply->error() != QNetworkReply::NoError) { - finishGeocodingWithError(QStringLiteral("城市解析失败:%1。").arg(reply->errorString())); + Logger::warning(QStringLiteral("Weather geocoding request failed: networkError=%1 http=%2") + .arg(static_cast(reply->error())) + .arg(httpStatus)); + finishGeocodingWithError(QStringLiteral("城市解析服务暂不可用,请稍后再试。")); return; } @@ -365,14 +390,16 @@ void WeatherManager::finishGeocodingRequest(QNetworkReply *reply) } const QJsonArray results = document.object().value(QStringLiteral("results")).toArray(); - m_locationCandidates = candidatesFromGeocodingResults(results); + m_locationCandidates = WeatherLocationResolver::selectRelevantCandidates( + candidatesFromGeocodingResults(results), + m_locationSearch); if (m_locationCandidates.isEmpty()) { finishGeocodingWithError(QStringLiteral("没有找到匹配的城市。")); return; } - m_hasLocationAmbiguity = hasCandidateAmbiguity(m_locationCandidates); + m_hasLocationAmbiguity = WeatherLocationResolver::hasAmbiguity(m_locationCandidates); m_location = locationFromCandidate(m_locationCandidates.first(), m_location.source); if (m_locationTestOnly) @@ -391,38 +418,33 @@ void WeatherManager::finishGeocodingRequest(QNetworkReply *reply) startForecastRequest(); } -void WeatherManager::finishIpLocationRequest(QNetworkReply *reply) +void WeatherManager::finishIpLocationRequest(QNetworkReply *reply, WeatherIpLocationProvider provider) { + m_ipLocationAttemptActive = false; const QByteArray body = reply->readAll(); if (reply->error() != QNetworkReply::NoError) { - finishWithError(QStringLiteral("公网 IP 定位失败:%1。").arg(reply->errorString())); + Logger::warning(QStringLiteral("Weather IP location request failed: provider=%1 networkError=%2 http=%3") + .arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois")) + .arg(static_cast(reply->error())) + .arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt())); + startNextIpLocationRequest(); return; } - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(body, &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) + WeatherLocation location; + if (!WeatherLocationResolver::parseIpLocationResponse(body, provider, &location)) { - finishWithError(QStringLiteral("公网 IP 定位结果格式无效。")); + Logger::warning(QStringLiteral("Weather IP location response was unusable: provider=%1") + .arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois"))); + startNextIpLocationRequest(); return; } - const QJsonObject root = document.object(); - const bool hasLatitude = jsonDouble(root, QStringLiteral("latitude"), &m_location.latitude); - const bool hasLongitude = jsonDouble(root, QStringLiteral("longitude"), &m_location.longitude); - m_location.cityName = root.value(QStringLiteral("city")).toString(); - m_location.adminName = root.value(QStringLiteral("region")).toString(); - m_location.countryName = root.value(QStringLiteral("country_name")).toString(); - m_location.timezone = root.value(QStringLiteral("timezone")).toString(); - m_location.source = WeatherLocationSource::IpFallback; - if (!hasLatitude || !hasLongitude || m_location.cityName.trimmed().isEmpty()) - { - finishWithError(QStringLiteral("公网 IP 定位没有返回有效城市或经纬度。")); - return; - } - - Logger::info(QStringLiteral("Weather IP location succeeded.")); + m_location = location; + m_ipLocationProviderIndex = -1; + Logger::info(QStringLiteral("Weather IP location succeeded: provider=%1") + .arg(provider == WeatherIpLocationProvider::IpApiCo ? QStringLiteral("ipapi") : QStringLiteral("ipwhois"))); startForecastRequest(); } @@ -431,7 +453,10 @@ void WeatherManager::finishForecastRequest(QNetworkReply *reply) const QByteArray body = reply->readAll(); if (reply->error() != QNetworkReply::NoError) { - finishWithError(QStringLiteral("天气数据请求失败:%1。").arg(reply->errorString())); + Logger::warning(QStringLiteral("Weather forecast request failed: networkError=%1 http=%2") + .arg(static_cast(reply->error())) + .arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt())); + finishWithError(QStringLiteral("天气数据服务暂不可用,请稍后再试。")); return; } @@ -511,7 +536,10 @@ void WeatherManager::finishForecastRequest(QNetworkReply *reply) finishWithReport(report); } -void WeatherManager::startRequest(const QUrl &url, std::function finishedHandler) +void WeatherManager::startRequest( + const QUrl &url, + std::function finishedHandler, + int timeoutMs) { clearReply(); @@ -538,11 +566,13 @@ void WeatherManager::startRequest(const QUrl &url, std::function 0 ? timeoutMs : m_config.timeoutMs); } void WeatherManager::finishWithError(const QString &message) { + m_ipLocationAttemptActive = false; + m_ipLocationProviderIndex = -1; clearReply(); WeatherQueryResult result; result.success = false; diff --git a/src/weather/WeatherManager.h b/src/weather/WeatherManager.h index 450a072..2de2351 100644 --- a/src/weather/WeatherManager.h +++ b/src/weather/WeatherManager.h @@ -1,6 +1,7 @@ #pragma once #include "WeatherParser.h" +#include "WeatherLocationResolver.h" #include "WeatherSummaryFormatter.h" #include "WeatherTypes.h" @@ -32,11 +33,15 @@ public: private: void startGeocodingRequest(const QString &cityName, WeatherLocationSource source, bool locationTestOnly); void startIpLocationRequest(); + void startNextIpLocationRequest(); void startForecastRequest(); void finishGeocodingRequest(QNetworkReply *reply); - void finishIpLocationRequest(QNetworkReply *reply); + void finishIpLocationRequest(QNetworkReply *reply, WeatherIpLocationProvider provider); void finishForecastRequest(QNetworkReply *reply); - void startRequest(const QUrl &url, std::function finishedHandler); + void startRequest( + const QUrl &url, + std::function finishedHandler, + int timeoutMs = -1); void finishGeocodingWithError(const QString &message); void finishWithError(const QString &message); void finishLocationTestWithError(const QString &message); @@ -55,9 +60,12 @@ private: LocationTestCallback m_locationTestCallback; WeatherConfig m_config; WeatherQuery m_query; + WeatherLocationSearch m_locationSearch; WeatherLocation m_location; QVector m_locationCandidates; bool m_locationTestOnly = false; bool m_hasLocationAmbiguity = false; + bool m_ipLocationAttemptActive = false; + int m_ipLocationProviderIndex = -1; bool m_busy = false; }; diff --git a/src/weather/WeatherSummaryFormatter.cpp b/src/weather/WeatherSummaryFormatter.cpp index 39b4a8f..1011339 100644 --- a/src/weather/WeatherSummaryFormatter.cpp +++ b/src/weather/WeatherSummaryFormatter.cpp @@ -64,8 +64,8 @@ QString ambiguityPrefix(const WeatherReport &report) } QStringList otherCandidates; - const int maxOtherCandidateCount = qMin(3, report.locationCandidates.size() - 1); - for (int index = 1; index <= maxOtherCandidateCount; ++index) + const qsizetype candidateLimit = qMin(4, report.locationCandidates.size()); + for (qsizetype index = 1; index < candidateLimit; ++index) { otherCandidates.append(candidateDisplayName(report.locationCandidates.at(index))); } @@ -76,7 +76,8 @@ QString ambiguityPrefix(const WeatherReport &report) } return QStringLiteral("可能存在同名城市,当前使用:%1。其他候选包括:%2。\n") - .arg(locationDisplayName(report.location), otherCandidates.join(QStringLiteral(";"))); + .arg(locationDisplayName(report.location)) + .arg(otherCandidates.join(QStringLiteral(";"))); } QString temperatureText(double value) diff --git a/src/weather/WeatherTypes.h b/src/weather/WeatherTypes.h index af0bb3c..edc07e2 100644 --- a/src/weather/WeatherTypes.h +++ b/src/weather/WeatherTypes.h @@ -6,6 +6,7 @@ #include #include #include +#include enum class WeatherLocationSource { @@ -21,6 +22,12 @@ enum class WeatherQueryKind Range, }; +enum class WeatherIpLocationProvider +{ + IpApiCo, + IpWhoIs, +}; + struct WeatherQuery { QString originalText; @@ -32,6 +39,13 @@ struct WeatherQuery QString unsupportedReason; }; +struct WeatherLocationSearch +{ + QString originalName; + QString queryName; + QString countryCode; +}; + struct WeatherLocation { QString cityName; @@ -51,6 +65,9 @@ struct WeatherLocationCandidate QString timezone; double latitude = 0.0; double longitude = 0.0; + QString countryCode; + QString featureCode; + qint64 population = -1; }; struct WeatherCurrent diff --git a/tools/package_release.ps1 b/tools/package_release.ps1 index 505861d..c308fab 100644 --- a/tools/package_release.ps1 +++ b/tools/package_release.ps1 @@ -4,6 +4,7 @@ param( [string]$Version = "", [string]$OutputRoot = "dist", [string]$WindeployQtPath = "", + [string]$MsvcRuntimeDir = "", [switch]$NoZip, [switch]$BuildInstaller, [string]$InnoCompilerPath = "", @@ -44,7 +45,7 @@ function Read-ProjectVersion { return $match.Groups[1].Value } - return "0.1.0" + return "0.2.0" } function Resolve-WindeployQt { @@ -69,6 +70,95 @@ function Resolve-WindeployQt { throw "windeployqt.exe was not found. Add the Qt bin directory to PATH or pass -WindeployQtPath." } +function Test-MsvcRuntimeDirectory { + param( + [string]$Directory + ) + + $requiredFiles = @( + "msvcp140.dll", + "msvcp140_1.dll", + "msvcp140_2.dll", + "vcruntime140.dll", + "vcruntime140_1.dll" + ) + foreach ($fileName in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $Directory $fileName) -PathType Leaf)) { + return $false + } + } + + return $true +} + +function Resolve-MsvcRuntimeDirectory { + param( + [string]$ExplicitPath + ) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + $resolved = Resolve-FullPath -Path $ExplicitPath -BasePath (Get-Location) + if ((Test-Path -LiteralPath $resolved -PathType Container) -and + (Test-MsvcRuntimeDirectory -Directory $resolved)) { + return $resolved + } + + throw "The x64 MSVC runtime directory is incomplete: $resolved" + } + + $x64Roots = @() + if (-not [string]::IsNullOrWhiteSpace($env:VCToolsRedistDir)) { + $x64Roots += Join-Path $env:VCToolsRedistDir "x64" + } + + if (-not [string]::IsNullOrWhiteSpace($env:VCINSTALLDIR)) { + $redistRoot = Join-Path $env:VCINSTALLDIR "Redist\MSVC" + if (Test-Path -LiteralPath $redistRoot -PathType Container) { + $versionDirectories = Get-ChildItem -LiteralPath $redistRoot -Directory | + Sort-Object Name -Descending + foreach ($versionDirectory in $versionDirectories) { + $x64Roots += Join-Path $versionDirectory.FullName "x64" + } + } + } + + foreach ($x64Root in $x64Roots) { + if (-not (Test-Path -LiteralPath $x64Root -PathType Container)) { + continue + } + + $runtimeDirectories = Get-ChildItem -LiteralPath $x64Root -Directory -Filter "Microsoft.VC*.CRT" | + Sort-Object Name -Descending + foreach ($runtimeDirectory in $runtimeDirectories) { + if (Test-MsvcRuntimeDirectory -Directory $runtimeDirectory.FullName) { + return $runtimeDirectory.FullName + } + } + } + + throw "The x64 MSVC runtime was not found. Run from a Visual Studio developer shell or pass -MsvcRuntimeDir." +} + +function Copy-MsvcRuntime { + param( + [string]$SourceDirectory, + [string]$DestinationDirectory + ) + + $runtimeFiles = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Filter "*.dll") + if ($runtimeFiles.Count -eq 0) { + throw "No MSVC runtime DLLs were found in: $SourceDirectory" + } + + foreach ($runtimeFile in $runtimeFiles) { + Copy-Item -LiteralPath $runtimeFile.FullName -Destination $DestinationDirectory -Force + } + + if (-not (Test-MsvcRuntimeDirectory -Directory $DestinationDirectory)) { + throw "MSVC runtime deployment validation failed: $DestinationDirectory" + } +} + function Resolve-InnoCompiler { param( [string]$ExplicitPath @@ -203,6 +293,12 @@ Assert-RequiredPath -Path $licensePath -Description "LICENSE" Assert-RequiredPath -Path $readmePath -Description "README.md" $windeployQt = Resolve-WindeployQt -ExplicitPath $WindeployQtPath +$isMsvcDeployment = (-not [string]::IsNullOrWhiteSpace($MsvcRuntimeDir)) -or + ($windeployQt -match '(?i)[\\/]msvc[^\\/]*[\\/]bin[\\/]windeployqt\.exe$') +$resolvedMsvcRuntimeDir = "" +if ($isMsvcDeployment) { + $resolvedMsvcRuntimeDir = Resolve-MsvcRuntimeDirectory -ExplicitPath $MsvcRuntimeDir +} Assert-ChildPath -Path $packageRoot -ParentPath $resolvedOutputRoot -Description "Release package directory" if (Test-Path -LiteralPath $packageRoot) { @@ -218,7 +314,19 @@ Copy-Item -LiteralPath $licensePath -Destination (Join-Path $packageRoot "LICENS Copy-Item -LiteralPath $readmePath -Destination (Join-Path $packageRoot "README.md") -Force Write-Host "Running windeployqt: $windeployQt" -Invoke-CheckedProcess -FilePath $windeployQt -Arguments @("--release", "--compiler-runtime", $targetExePath) +$windeployArguments = @("--release") +if ($isMsvcDeployment) { + $windeployArguments += "--no-compiler-runtime" +} else { + $windeployArguments += "--compiler-runtime" +} +$windeployArguments += $targetExePath +Invoke-CheckedProcess -FilePath $windeployQt -Arguments $windeployArguments + +if ($isMsvcDeployment) { + Write-Host "Deploying app-local MSVC runtime: $resolvedMsvcRuntimeDir" + Copy-MsvcRuntime -SourceDirectory $resolvedMsvcRuntimeDir -DestinationDirectory $packageRoot +} $manifestPath = Join-Path $packageRoot "package_manifest.txt" @( @@ -226,7 +334,7 @@ $manifestPath = Join-Path $packageRoot "package_manifest.txt" "Version: $Version", "CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))", "SourceExe: $resolvedExePath", - "Includes: QtDesktopPet.exe, Qt runtime, character/icon/sound resources, LICENSE, README.md", + "Includes: QtDesktopPet.exe, Qt/compiler runtime, character/icon/sound resources, LICENSE, README.md", "Excludes: tools, docs, reports, build, dist, .git" ) | Set-Content -LiteralPath $manifestPath -Encoding UTF8