Add character management and release packaging
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
build/
|
build/
|
||||||
|
dist/
|
||||||
cmake-build-*/
|
cmake-build-*/
|
||||||
.vs/
|
.vs/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ target_link_libraries(QtDesktopPet
|
|||||||
Qt6::Widgets
|
Qt6::Widgets
|
||||||
)
|
)
|
||||||
|
|
||||||
|
set_target_properties(QtDesktopPet PROPERTIES
|
||||||
|
WIN32_EXECUTABLE TRUE
|
||||||
|
)
|
||||||
|
|
||||||
if (WIN32)
|
if (WIN32)
|
||||||
set_source_files_properties(resources/icons/app_icon.rc
|
set_source_files_properties(resources/icons/app_icon.rc
|
||||||
PROPERTIES
|
PROPERTIES
|
||||||
|
|||||||
@@ -32,13 +32,16 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型
|
|||||||
- 内存历史上限和可选本地历史保存
|
- 内存历史上限和可选本地历史保存
|
||||||
- AI 请求取消和对话清空
|
- AI 请求取消和对话清空
|
||||||
- Google Gemini 原生聊天请求
|
- Google Gemini 原生聊天请求
|
||||||
|
- 角色文件夹导入和角色切换
|
||||||
|
- 删除用户导入角色
|
||||||
|
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
||||||
|
|
||||||
尚未实现:
|
尚未实现:
|
||||||
|
|
||||||
- 角色导入/切换界面
|
- 角色导出和更完整的管理界面
|
||||||
- 对话历史导出/管理界面
|
- 对话历史导出/管理界面
|
||||||
- 长期性能压测记录
|
- 长期性能压测记录
|
||||||
- 打包发布脚本
|
- 发布包实机安装/卸载验证
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -92,7 +95,13 @@ resources/icons/app_icon_1024.png
|
|||||||
resources/characters/shiroko/
|
resources/characters/shiroko/
|
||||||
```
|
```
|
||||||
|
|
||||||
角色包按 `resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录。基本结构:
|
内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QStandardPaths::AppDataLocation/characters/<characterId>/
|
||||||
|
```
|
||||||
|
|
||||||
|
角色包基本结构:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
resources/characters/shiroko/
|
resources/characters/shiroko/
|
||||||
@@ -108,7 +117,20 @@ resources/characters/shiroko/
|
|||||||
```
|
```
|
||||||
|
|
||||||
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。
|
当前素材版本为 `2.1.0-stable`,所有帧使用 512x512 透明画布。当前实现会读取当前角色包的各状态配置,并按 `character.json` 中的 FPS 播放。
|
||||||
运行时会优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。
|
运行时会合并内置角色和用户导入角色;内置资源优先读取可执行文件同级的 `resources/characters/`,找不到时回退到源码目录下的 `resources/characters/`。
|
||||||
|
|
||||||
|
角色导入:
|
||||||
|
|
||||||
|
- 只支持导入本地文件夹,不支持 zip
|
||||||
|
- 导入前先验证源文件夹;验证失败只弹窗提示,不复制、不创建、不覆盖文件
|
||||||
|
- 验证通过后复制到用户数据目录
|
||||||
|
- 角色 id 优先读取 `character.json` 的 `id`;为空时使用文件夹名
|
||||||
|
- 角色 id 只允许 ASCII 字母、数字、点、下划线和短横线,且不能以点开头或结尾
|
||||||
|
- 用户角色同名时会询问是否覆盖
|
||||||
|
- 内置角色 id 不能被导入包覆盖
|
||||||
|
- 验证要求:`character.json` 可解析、id 安全、存在 `idle` 和 `defaultState`、状态路径安全、fps 合法、每个声明状态至少有一张可读 PNG
|
||||||
|
- 如果 `base.anchorY + bubble.offsetY` 计算出的气泡锚点明显偏低,导入时会提示用户检查配置,但不强制阻止导入
|
||||||
|
- 只允许删除用户导入角色;选择内置角色删除时只会提示“不能删除内置角色”,不做文件操作
|
||||||
|
|
||||||
懒加载现状:
|
懒加载现状:
|
||||||
|
|
||||||
@@ -150,6 +172,78 @@ QStandardPaths::AppConfigLocation/logs/QtDesktopPet.log
|
|||||||
- 最多保留 3 个旧日志文件
|
- 最多保留 3 个旧日志文件
|
||||||
- 文件名为 `QtDesktopPet.log.1`、`QtDesktopPet.log.2`、`QtDesktopPet.log.3`
|
- 文件名为 `QtDesktopPet.log.1`、`QtDesktopPet.log.2`、`QtDesktopPet.log.3`
|
||||||
|
|
||||||
|
## 发布打包
|
||||||
|
|
||||||
|
仓库提供 Windows Release 打包脚本。脚本不负责 CMake 构建,先手动完成 Release 构建,再把 exe 路径传给脚本:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 -ExePath build/release/QtDesktopPet.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会生成目录包和 zip:
|
||||||
|
|
||||||
|
```text
|
||||||
|
dist/QtDesktopPet-<version>-windows-x64/
|
||||||
|
dist/QtDesktopPet-<version>-windows-x64.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
发布目录包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QtDesktopPet.exe
|
||||||
|
Qt runtime
|
||||||
|
resources/characters/
|
||||||
|
resources/icons/
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会调用 `windeployqt.exe` 收集 Qt 运行库。若当前 PATH 找不到 `windeployqt.exe`,需要指定 Qt bin 目录下的工具路径:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
生成 Inno Setup 安装器:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||||
|
-ExePath build/release/QtDesktopPet.exe `
|
||||||
|
-BuildInstaller
|
||||||
|
```
|
||||||
|
|
||||||
|
安装器最终默认输出到项目根目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QtDesktopPet-<version>-windows-x64-setup.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会先让 Inno Setup 输出到当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再把最终安装包复制回项目根目录,避免中文项目路径下出现 `EndUpdateResource failed (5)`。如果需要改变最终安装包目录,可传入 `-InstallerOutputDir`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools/package_release.ps1 `
|
||||||
|
-ExePath build/release/QtDesktopPet.exe `
|
||||||
|
-BuildInstaller `
|
||||||
|
-InstallerOutputDir D:\ReleaseOutput
|
||||||
|
```
|
||||||
|
|
||||||
|
如果需要改变 Inno Setup 的临时编译输出目录,可传入 `-InstallerWorkOutputDir`。
|
||||||
|
|
||||||
|
脚本默认优先查找:
|
||||||
|
|
||||||
|
```text
|
||||||
|
D:\Inno Setup 7\ISCC.exe
|
||||||
|
D:\Inno Setup 6\ISCC.exe
|
||||||
|
C:\Program Files (x86)\Inno Setup 7\ISCC.exe
|
||||||
|
C:\Program Files (x86)\Inno Setup 6\ISCC.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 Inno Setup 安装在其他位置,可传入 `-InnoCompilerPath`。
|
||||||
|
|
||||||
|
安装器卸载时会询问是否同时删除用户配置、导入角色、聊天记录和日志;用户确认后会在卸载完成阶段删除当前用户下的 QtDesktopPet 数据目录。
|
||||||
|
|
||||||
## 开发诊断
|
## 开发诊断
|
||||||
|
|
||||||
仓库提供开发用性能采样脚本,不进入普通用户发布包:
|
仓库提供开发用性能采样脚本,不进入普通用户发布包:
|
||||||
@@ -170,7 +264,7 @@ reports/perf/
|
|||||||
docs/performance_stability_check.md
|
docs/performance_stability_check.md
|
||||||
```
|
```
|
||||||
|
|
||||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。
|
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。
|
||||||
|
|
||||||
## AI 配置和聊天
|
## AI 配置和聊天
|
||||||
|
|
||||||
|
|||||||
@@ -1394,16 +1394,29 @@ Windows 下不能只拷贝 exe。
|
|||||||
8. 便携模式是否可用
|
8. 便携模式是否可用
|
||||||
```
|
```
|
||||||
|
|
||||||
建议后续提供:
|
当前提供:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. Windows x64 Release 构建
|
1. Windows x64 Release 构建
|
||||||
2. 打包脚本
|
2. 打包脚本
|
||||||
3. README 部署说明
|
3. README 部署说明
|
||||||
4. 示例角色包
|
4. Inno Setup 安装器脚本
|
||||||
```
|
```
|
||||||
|
|
||||||
第一版可以先不做安装器,但需要保证构建产物可以在普通 Windows 环境运行。
|
发布流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 用户手动完成 Release 构建
|
||||||
|
2. 运行 tools/package_release.ps1,传入 QtDesktopPet.exe 路径
|
||||||
|
3. 脚本调用 windeployqt 收集 Qt 运行库
|
||||||
|
4. 脚本复制 resources/characters、resources/icons、LICENSE、README.md
|
||||||
|
5. 脚本生成 dist/QtDesktopPet-<version>-windows-x64.zip
|
||||||
|
6. 需要安装器时,脚本优先查找 D:\Inno Setup 7\ISCC.exe,并调用 ISCC 编译 installer/QtDesktopPet.iss
|
||||||
|
7. 安装器默认最终输出到项目根目录
|
||||||
|
8. Inno 编译阶段使用当前盘符下的纯 ASCII 临时目录,例如 D:\QtDesktopPetInstallerOutput,避免中文项目路径下出现 EndUpdateResource failed (5)
|
||||||
|
```
|
||||||
|
|
||||||
|
安装器卸载时需要询问用户是否删除用户数据。用户确认后,在卸载完成阶段删除当前用户的 QtDesktopPet 配置、导入角色、聊天记录和日志。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1683,7 +1696,7 @@ MIT License 开源
|
|||||||
当前仍需补齐:
|
当前仍需补齐:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. 角色包导入和角色切换
|
1. 角色包导出和更完整管理界面
|
||||||
2. 对话历史导出、搜索或更完整管理界面
|
2. 对话历史导出、搜索或更完整管理界面
|
||||||
3. 发布前素材授权确认与打包验证
|
3. 发布前素材授权确认与打包验证
|
||||||
4. 长期性能压测记录
|
4. 长期性能压测记录
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ cmake -S . -B build/mingw-debug -G Ninja `
|
|||||||
|
|
||||||
## 3. 角色包约定
|
## 3. 角色包约定
|
||||||
|
|
||||||
`resources/characters/shiroko` 目录作为当前默认角色包。角色包按 `resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录。
|
`resources/characters/shiroko` 目录作为当前默认内置角色包。内置角色按 `resources/characters/<characterId>/` 组织,用户导入角色复制到 `QStandardPaths::AppDataLocation/characters/<characterId>/`。
|
||||||
|
|
||||||
已检查到的结构:
|
已检查到的结构:
|
||||||
|
|
||||||
@@ -504,6 +504,7 @@ tools/
|
|||||||
docs/
|
docs/
|
||||||
reports/
|
reports/
|
||||||
build/
|
build/
|
||||||
|
dist/
|
||||||
.git/
|
.git/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -586,12 +587,14 @@ build/
|
|||||||
懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态
|
懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态
|
||||||
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
|
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
|
||||||
运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/
|
运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/
|
||||||
|
已支持角色文件夹导入、角色切换和删除用户导入角色:验证通过后复制到用户数据目录,验证失败不做文件操作;内置角色不可删除
|
||||||
|
已新增 Windows 发布打包脚本和 Inno Setup 安装器脚本;脚本不负责 CMake 构建,安装器卸载时可由用户确认后清理当前用户数据目录
|
||||||
```
|
```
|
||||||
|
|
||||||
当前实现与计划仍存在差异:
|
当前实现与计划仍存在差异:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局
|
1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程
|
||||||
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
|
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
|
||||||
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
|
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
|
||||||
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
||||||
@@ -639,6 +642,6 @@ build/
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
||||||
2. 设置页下一步先完善角色包配置,还是先补发布打包配置
|
2. 角色管理下一步是否需要导出、打开用户角色目录
|
||||||
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
|
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| 显示器 / DPI | TODO |
|
| 显示器 / DPI | TODO |
|
||||||
| Qt 版本 | TODO |
|
| Qt 版本 | TODO |
|
||||||
| 角色包 | `resources/characters/shiroko` |
|
| 角色包 | `resources/characters/shiroko` |
|
||||||
| AppConfig 关键项 | scale=TODO, performanceMode=TODO, pauseWhenHidden=TODO, enableLazyLoad=TODO, enableAnimationPrewarm=TODO, animationCacheLimitMb=TODO, unloadAnimationsWhenHidden=TODO |
|
| AppConfig 关键项 | characterId=TODO, scale=TODO, performanceMode=TODO, pauseWhenHidden=TODO, enableLazyLoad=TODO, enableAnimationPrewarm=TODO, animationCacheLimitMb=TODO, unloadAnimationsWhenHidden=TODO |
|
||||||
|
|
||||||
## 采样脚本
|
## 采样脚本
|
||||||
|
|
||||||
@@ -85,6 +85,7 @@ tools/
|
|||||||
docs/
|
docs/
|
||||||
reports/
|
reports/
|
||||||
build/
|
build/
|
||||||
|
dist/
|
||||||
.git/
|
.git/
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -99,6 +100,16 @@ LICENSE
|
|||||||
README.md
|
README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
安装器卸载验证:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 正常卸载时程序文件应删除
|
||||||
|
2. 选择不删除用户数据时,配置、导入角色、聊天记录和日志应保留
|
||||||
|
3. 选择删除用户数据时,卸载完成阶段应删除当前用户下的 QtDesktopPet 数据目录
|
||||||
|
```
|
||||||
|
|
||||||
|
安装器最终文件默认放在项目根目录。Inno Setup 编译阶段使用当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再复制最终安装包回项目根目录,用于规避中文项目路径下可能出现的 `EndUpdateResource failed (5)`。
|
||||||
|
|
||||||
运行时资源查找顺序:
|
运行时资源查找顺序:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#define AppName "QtDesktopPet"
|
||||||
|
#ifndef AppVersion
|
||||||
|
#define AppVersion "0.1.0"
|
||||||
|
#endif
|
||||||
|
#ifndef SourceDir
|
||||||
|
#define SourceDir "..\dist\QtDesktopPet-0.1.0-windows-x64"
|
||||||
|
#endif
|
||||||
|
#ifndef OutputDir
|
||||||
|
#define OutputDir "..\dist\installer"
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Setup]
|
||||||
|
AppId={{B37D912A-7354-4D67-AF75-5EFA7E80D605}
|
||||||
|
AppName={#AppName}
|
||||||
|
AppVersion={#AppVersion}
|
||||||
|
AppPublisher=Make
|
||||||
|
DefaultDirName={localappdata}\Programs\{#AppName}
|
||||||
|
DefaultGroupName={#AppName}
|
||||||
|
DisableProgramGroupPage=yes
|
||||||
|
OutputDir={#OutputDir}
|
||||||
|
OutputBaseFilename=QtDesktopPet-{#AppVersion}-windows-x64-setup
|
||||||
|
Compression=lzma2
|
||||||
|
SolidCompression=yes
|
||||||
|
WizardStyle=modern
|
||||||
|
PrivilegesRequired=lowest
|
||||||
|
ArchitecturesAllowed=x64compatible
|
||||||
|
ArchitecturesInstallIn64BitMode=x64compatible
|
||||||
|
SetupIconFile=..\resources\icons\app_icon.ico
|
||||||
|
UninstallDisplayIcon={app}\QtDesktopPet.exe
|
||||||
|
|
||||||
|
[Tasks]
|
||||||
|
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"; Flags: unchecked
|
||||||
|
|
||||||
|
[Files]
|
||||||
|
Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||||
|
|
||||||
|
[Icons]
|
||||||
|
Name: "{group}\QtDesktopPet"; Filename: "{app}\QtDesktopPet.exe"; WorkingDir: "{app}"
|
||||||
|
Name: "{group}\Uninstall 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
|
||||||
|
|
||||||
|
[Code]
|
||||||
|
var
|
||||||
|
DeleteUserDataAfterUninstall: Boolean;
|
||||||
|
|
||||||
|
procedure DeleteDirIfExists(Path: String);
|
||||||
|
begin
|
||||||
|
if DirExists(Path) then
|
||||||
|
begin
|
||||||
|
DelTree(Path, True, True, True);
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
function InitializeUninstall(): Boolean;
|
||||||
|
var
|
||||||
|
Answer: Integer;
|
||||||
|
begin
|
||||||
|
Result := True;
|
||||||
|
DeleteUserDataAfterUninstall := False;
|
||||||
|
Answer := MsgBox(
|
||||||
|
'是否同时删除 QtDesktopPet 的用户配置、导入角色、聊天记录和日志?' + #13#10 + #13#10 +
|
||||||
|
'这会删除当前用户的 QtDesktopPet 数据,操作不可恢复。',
|
||||||
|
mbConfirmation,
|
||||||
|
MB_YESNO);
|
||||||
|
|
||||||
|
if Answer = IDYES then
|
||||||
|
begin
|
||||||
|
DeleteUserDataAfterUninstall := True;
|
||||||
|
end;
|
||||||
|
end;
|
||||||
|
|
||||||
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
||||||
|
begin
|
||||||
|
if (CurUninstallStep = usPostUninstall) and DeleteUserDataAfterUninstall then
|
||||||
|
begin
|
||||||
|
DeleteDirIfExists(ExpandConstant('{userappdata}\QtDesktopPet\QtDesktopPet'));
|
||||||
|
DeleteDirIfExists(ExpandConstant('{localappdata}\QtDesktopPet\QtDesktopPet'));
|
||||||
|
end;
|
||||||
|
end;
|
||||||
@@ -122,6 +122,10 @@ CharacterPackage CharacterPackageLoader::load(const QString &packagePath, QStrin
|
|||||||
}
|
}
|
||||||
|
|
||||||
package.id = requiredString(root, QStringLiteral("id"));
|
package.id = requiredString(root, QStringLiteral("id"));
|
||||||
|
if (package.id.isEmpty())
|
||||||
|
{
|
||||||
|
package.id = QFileInfo(package.packagePath).fileName().trimmed();
|
||||||
|
}
|
||||||
package.displayName = requiredString(root, QStringLiteral("displayName"));
|
package.displayName = requiredString(root, QStringLiteral("displayName"));
|
||||||
package.author = requiredString(root, QStringLiteral("author"));
|
package.author = requiredString(root, QStringLiteral("author"));
|
||||||
package.version = requiredString(root, QStringLiteral("version"));
|
package.version = requiredString(root, QStringLiteral("version"));
|
||||||
|
|||||||
@@ -1,28 +1,423 @@
|
|||||||
#include "CharacterPackageRepository.h"
|
#include "CharacterPackageRepository.h"
|
||||||
|
|
||||||
|
#include "CharacterPackageLoader.h"
|
||||||
#include "../util/ResourcePaths.h"
|
#include "../util/ResourcePaths.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
bool isValidCharacterId(const QString &characterId)
|
constexpr int SupportedSchemaVersion = 1;
|
||||||
|
|
||||||
|
bool setError(QString *errorMessage, const QString &message)
|
||||||
|
{
|
||||||
|
if (errorMessage != nullptr)
|
||||||
|
{
|
||||||
|
*errorMessage = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString fallbackUserDataPath()
|
||||||
|
{
|
||||||
|
const QString appDataPath = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||||
|
if (!appDataPath.isEmpty())
|
||||||
|
{
|
||||||
|
return appDataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return QDir::currentPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSafeRelativePath(QString path)
|
||||||
|
{
|
||||||
|
path = path.trimmed();
|
||||||
|
path.replace(QLatin1Char('\\'), QLatin1Char('/'));
|
||||||
|
if (path.isEmpty() || path.startsWith(QLatin1Char('/')) || QDir::isAbsolutePath(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString cleanedPath = QDir::cleanPath(path);
|
||||||
|
if (cleanedPath == QStringLiteral(".") || cleanedPath.startsWith(QStringLiteral("../")))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !cleanedPath.split(QLatin1Char('/'), Qt::SkipEmptyParts).contains(QStringLiteral(".."));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasReadablePngFrame(const QStringList &framePaths)
|
||||||
|
{
|
||||||
|
for (const QString &framePath : framePaths)
|
||||||
|
{
|
||||||
|
QImageReader reader(framePath);
|
||||||
|
if (reader.canRead())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isAsciiLetterOrNumber(QChar character)
|
||||||
|
{
|
||||||
|
return (character >= QLatin1Char('a') && character <= QLatin1Char('z'))
|
||||||
|
|| (character >= QLatin1Char('A') && character <= QLatin1Char('Z'))
|
||||||
|
|| (character >= QLatin1Char('0') && character <= QLatin1Char('9'));
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList collectPackageIds(const QString &rootPath)
|
||||||
|
{
|
||||||
|
const QDir rootDirectory(rootPath);
|
||||||
|
if (!rootDirectory.exists())
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfoList entries = rootDirectory.entryInfoList(
|
||||||
|
QDir::Dirs | QDir::NoDotAndDotDot,
|
||||||
|
QDir::Name);
|
||||||
|
|
||||||
|
QStringList packageIds;
|
||||||
|
for (const QFileInfo &entry : entries)
|
||||||
|
{
|
||||||
|
if (!CharacterPackageRepository::isValidCharacterId(entry.fileName()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo manifest(QDir(entry.absoluteFilePath()).filePath(QStringLiteral("character.json")));
|
||||||
|
if (manifest.isFile())
|
||||||
|
{
|
||||||
|
packageIds.append(entry.fileName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return packageIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool copyDirectoryRecursively(const QString &sourcePath, const QString &targetPath, QString *errorMessage)
|
||||||
|
{
|
||||||
|
const QDir sourceDirectory(sourcePath);
|
||||||
|
if (!sourceDirectory.exists())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("源角色文件夹不存在。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir targetDirectory(targetPath);
|
||||||
|
if (!targetDirectory.exists() && !QDir().mkpath(targetPath))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("无法创建导入目标目录。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfoList entries = sourceDirectory.entryInfoList(
|
||||||
|
QDir::NoDotAndDotDot | QDir::AllEntries | QDir::Hidden | QDir::System,
|
||||||
|
QDir::Name);
|
||||||
|
for (const QFileInfo &entry : entries)
|
||||||
|
{
|
||||||
|
if (entry.isSymLink())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色包中不允许包含符号链接:") + entry.fileName());
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString targetEntryPath = targetDirectory.filePath(entry.fileName());
|
||||||
|
if (entry.isDir())
|
||||||
|
{
|
||||||
|
if (!copyDirectoryRecursively(entry.absoluteFilePath(), targetEntryPath, errorMessage))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!QFile::copy(entry.absoluteFilePath(), targetEntryPath))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("复制文件失败:") + entry.fileName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool replaceDirectoryWithPreparedImport(const QString &preparedPath, const QString &targetPath, bool overwrite, QString *errorMessage)
|
||||||
|
{
|
||||||
|
QDir rootDirectory(QFileInfo(targetPath).absolutePath());
|
||||||
|
if (!rootDirectory.exists() && !rootDirectory.mkpath(QStringLiteral(".")))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("无法创建用户角色目录。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo targetInfo(targetPath);
|
||||||
|
if (targetInfo.exists())
|
||||||
|
{
|
||||||
|
if (!overwrite)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("同名角色已存在。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString backupPath = targetPath
|
||||||
|
+ QStringLiteral(".replacing.")
|
||||||
|
+ QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss"));
|
||||||
|
if (!rootDirectory.rename(targetPath, backupPath))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("无法替换已有角色目录。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootDirectory.rename(preparedPath, targetPath))
|
||||||
|
{
|
||||||
|
rootDirectory.rename(backupPath, targetPath);
|
||||||
|
return setError(errorMessage, QStringLiteral("写入新角色目录失败,已尝试恢复旧目录。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir(backupPath).removeRecursively();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootDirectory.rename(preparedPath, targetPath))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("写入角色目录失败。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validateManifest(const QString &directoryPath, QString *characterId, QString *displayName, QString *errorMessage)
|
||||||
|
{
|
||||||
|
const QDir packageDirectory(directoryPath);
|
||||||
|
if (!packageDirectory.exists())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色文件夹不存在。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile manifestFile(packageDirectory.filePath(QStringLiteral("character.json")));
|
||||||
|
if (!manifestFile.exists())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("缺少 character.json。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manifestFile.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("无法读取 character.json。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonParseError parseError;
|
||||||
|
const QJsonDocument document = QJsonDocument::fromJson(manifestFile.readAll(), &parseError);
|
||||||
|
if (parseError.error != QJsonParseError::NoError || !document.isObject())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("character.json 不是有效 JSON。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject root = document.object();
|
||||||
|
if (root.value(QStringLiteral("schemaVersion")).toInt(0) != SupportedSchemaVersion)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("schemaVersion 不受支持。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString packageId = root.value(QStringLiteral("id")).toString().trimmed();
|
||||||
|
if (packageId.isEmpty())
|
||||||
|
{
|
||||||
|
packageId = QFileInfo(directoryPath).fileName().trimmed();
|
||||||
|
}
|
||||||
|
if (!CharacterPackageRepository::isValidCharacterId(packageId))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色 id 不是安全的目录名。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonValue baseValue = root.value(QStringLiteral("base"));
|
||||||
|
if (baseValue.isObject())
|
||||||
|
{
|
||||||
|
const QJsonObject base = baseValue.toObject();
|
||||||
|
if (base.contains(QStringLiteral("width")) && base.value(QStringLiteral("width")).toInt(0) <= 0)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("base.width 必须大于 0。"));
|
||||||
|
}
|
||||||
|
if (base.contains(QStringLiteral("height")) && base.value(QStringLiteral("height")).toInt(0) <= 0)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("base.height 必须大于 0。"));
|
||||||
|
}
|
||||||
|
if (base.contains(QStringLiteral("scale")) && base.value(QStringLiteral("scale")).toDouble(0.0) <= 0.0)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("base.scale 必须大于 0。"));
|
||||||
|
}
|
||||||
|
const double anchorX = base.value(QStringLiteral("anchorX")).toDouble(0.5);
|
||||||
|
const double anchorY = base.value(QStringLiteral("anchorY")).toDouble(0.0);
|
||||||
|
if (anchorX < 0.0 || anchorX > 1.0 || anchorY < 0.0 || anchorY > 1.0)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("base.anchorX/anchorY 必须在 0 到 1 之间。"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString defaultState = root.value(QStringLiteral("defaultState")).toString().trimmed();
|
||||||
|
if (defaultState.isEmpty())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("defaultState 不能为空。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonValue statesValue = root.value(QStringLiteral("states"));
|
||||||
|
if (!statesValue.isObject() || statesValue.toObject().isEmpty())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("states 不能为空。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QSet<QString> validStates;
|
||||||
|
const QJsonObject states = statesValue.toObject();
|
||||||
|
for (auto iterator = states.constBegin(); iterator != states.constEnd(); ++iterator)
|
||||||
|
{
|
||||||
|
if (!iterator.value().isObject())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态配置必须是对象:") + iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
const QJsonObject state = iterator.value().toObject();
|
||||||
|
const QString relativePath = state.value(QStringLiteral("path")).toString().trimmed();
|
||||||
|
if (!isSafeRelativePath(relativePath))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态路径非法:") + iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.value(QStringLiteral("fps")).toInt(0) <= 0)
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态 fps 必须大于 0:") + iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString stateDirectoryPath = packageDirectory.filePath(relativePath);
|
||||||
|
if (QFileInfo(stateDirectoryPath).isSymLink())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态目录不允许是符号链接:") + iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
const QDir stateDirectory(stateDirectoryPath);
|
||||||
|
if (!stateDirectory.exists())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态目录不存在:") + iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfoList frameFiles = stateDirectory.entryInfoList(
|
||||||
|
{QStringLiteral("*.png"), QStringLiteral("*.PNG")},
|
||||||
|
QDir::Files,
|
||||||
|
QDir::Name);
|
||||||
|
QStringList framePaths;
|
||||||
|
for (const QFileInfo &frameFile : frameFiles)
|
||||||
|
{
|
||||||
|
if (frameFile.isSymLink())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态帧不允许是符号链接:") + iterator.key());
|
||||||
|
}
|
||||||
|
framePaths.append(frameFile.absoluteFilePath());
|
||||||
|
}
|
||||||
|
if (framePaths.isEmpty() || !hasReadablePngFrame(framePaths))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("状态没有可读 PNG 帧:") + iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
validStates.insert(iterator.key());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validStates.contains(QStringLiteral("idle")))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("缺少可用 idle 状态。"));
|
||||||
|
}
|
||||||
|
if (!validStates.contains(defaultState))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("defaultState 不存在或不可用。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QString loadError;
|
||||||
|
const CharacterPackage package = CharacterPackageLoader::load(directoryPath, &loadError);
|
||||||
|
if (!loadError.isEmpty() || !package.hasState(QStringLiteral("idle")))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, loadError.isEmpty() ? QStringLiteral("角色包无法被当前加载器读取。") : loadError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (characterId != nullptr)
|
||||||
|
{
|
||||||
|
*characterId = packageId;
|
||||||
|
}
|
||||||
|
if (displayName != nullptr)
|
||||||
|
{
|
||||||
|
*displayName = root.value(QStringLiteral("displayName")).toString(packageId).trimmed();
|
||||||
|
if (displayName->isEmpty())
|
||||||
|
{
|
||||||
|
*displayName = packageId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString bubbleAnchorWarning(const QString &directoryPath)
|
||||||
|
{
|
||||||
|
QString loadError;
|
||||||
|
const CharacterPackage package = CharacterPackageLoader::load(directoryPath, &loadError);
|
||||||
|
if (!loadError.isEmpty())
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const double anchorY = static_cast<double>(package.base.height) * package.base.anchorY + package.bubble.offsetY;
|
||||||
|
if (package.base.height > 0 && anchorY > static_cast<double>(package.base.height) * 0.6)
|
||||||
|
{
|
||||||
|
return QStringLiteral("当前 bubble.offsetY 可能会让输出气泡显示在角色中部或偏下位置。建议检查 character.json 中的 base.anchorY 和 bubble.offsetY。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::isValidCharacterId(const QString &characterId)
|
||||||
{
|
{
|
||||||
const QString trimmed = characterId.trimmed();
|
const QString trimmed = characterId.trimmed();
|
||||||
return !trimmed.isEmpty()
|
if (trimmed.isEmpty()
|
||||||
&& trimmed != QStringLiteral(".")
|
|| trimmed == QStringLiteral(".")
|
||||||
&& trimmed != QStringLiteral("..")
|
|| trimmed == QStringLiteral("..")
|
||||||
&& !trimmed.contains(QLatin1Char('/'))
|
|| trimmed.startsWith(QLatin1Char('.'))
|
||||||
&& !trimmed.contains(QLatin1Char('\\'));
|
|| trimmed.endsWith(QLatin1Char('.')))
|
||||||
}
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QChar character : trimmed)
|
||||||
|
{
|
||||||
|
if (!isAsciiLetterOrNumber(character)
|
||||||
|
&& character != QLatin1Char('.')
|
||||||
|
&& character != QLatin1Char('_')
|
||||||
|
&& character != QLatin1Char('-'))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString CharacterPackageRepository::charactersRootPath()
|
QString CharacterPackageRepository::charactersRootPath()
|
||||||
|
{
|
||||||
|
return builtInCharactersRootPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CharacterPackageRepository::builtInCharactersRootPath()
|
||||||
{
|
{
|
||||||
return ResourcePaths::charactersRootPath();
|
return ResourcePaths::charactersRootPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString CharacterPackageRepository::userCharactersRootPath()
|
||||||
|
{
|
||||||
|
return QDir(fallbackUserDataPath()).filePath(QStringLiteral("characters"));
|
||||||
|
}
|
||||||
|
|
||||||
QString CharacterPackageRepository::defaultCharacterId()
|
QString CharacterPackageRepository::defaultCharacterId()
|
||||||
{
|
{
|
||||||
return QStringLiteral("shiroko");
|
return QStringLiteral("shiroko");
|
||||||
@@ -39,6 +434,17 @@ QString CharacterPackageRepository::defaultPreviewPath()
|
|||||||
}
|
}
|
||||||
|
|
||||||
QString CharacterPackageRepository::packagePath(const QString &characterId)
|
QString CharacterPackageRepository::packagePath(const QString &characterId)
|
||||||
|
{
|
||||||
|
const QString userPath = userPackagePath(characterId);
|
||||||
|
if (!userPath.isEmpty() && QFileInfo::exists(QDir(userPath).filePath(QStringLiteral("character.json"))))
|
||||||
|
{
|
||||||
|
return userPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return builtInPackagePath(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CharacterPackageRepository::builtInPackagePath(const QString &characterId)
|
||||||
{
|
{
|
||||||
const QString trimmed = characterId.trimmed();
|
const QString trimmed = characterId.trimmed();
|
||||||
if (!isValidCharacterId(trimmed))
|
if (!isValidCharacterId(trimmed))
|
||||||
@@ -46,7 +452,18 @@ QString CharacterPackageRepository::packagePath(const QString &characterId)
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return QDir(charactersRootPath()).filePath(trimmed);
|
return QDir(builtInCharactersRootPath()).filePath(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CharacterPackageRepository::userPackagePath(const QString &characterId)
|
||||||
|
{
|
||||||
|
const QString trimmed = characterId.trimmed();
|
||||||
|
if (!isValidCharacterId(trimmed))
|
||||||
|
{
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return QDir(userCharactersRootPath()).filePath(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
QString CharacterPackageRepository::previewPath(const QString &characterId)
|
QString CharacterPackageRepository::previewPath(const QString &characterId)
|
||||||
@@ -60,32 +477,190 @@ QString CharacterPackageRepository::previewPath(const QString &characterId)
|
|||||||
return QDir(path).filePath(QStringLiteral("preview.png"));
|
return QDir(path).filePath(QStringLiteral("preview.png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::hasPackage(const QString &characterId)
|
||||||
|
{
|
||||||
|
return hasUserPackage(characterId) || hasBuiltInPackage(characterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::hasBuiltInPackage(const QString &characterId)
|
||||||
|
{
|
||||||
|
const QString path = builtInPackagePath(characterId);
|
||||||
|
return !path.isEmpty() && QFileInfo::exists(QDir(path).filePath(QStringLiteral("character.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::hasUserPackage(const QString &characterId)
|
||||||
|
{
|
||||||
|
const QString path = userPackagePath(characterId);
|
||||||
|
return !path.isEmpty() && QFileInfo::exists(QDir(path).filePath(QStringLiteral("character.json")));
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<CharacterPackageInfo> CharacterPackageRepository::availablePackages()
|
||||||
|
{
|
||||||
|
QVector<CharacterPackageInfo> packages;
|
||||||
|
QSet<QString> seenIds;
|
||||||
|
|
||||||
|
const auto appendPackage = [&packages, &seenIds](const QString &id, const QString &path, bool userPackage) {
|
||||||
|
if (seenIds.contains(id))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString loadError;
|
||||||
|
const CharacterPackage package = CharacterPackageLoader::load(path, &loadError);
|
||||||
|
if (!loadError.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterPackageInfo info;
|
||||||
|
info.id = id;
|
||||||
|
info.displayName = package.displayName.trimmed().isEmpty() ? id : package.displayName.trimmed();
|
||||||
|
info.packagePath = path;
|
||||||
|
info.previewPath = package.previewPath.isEmpty() ? QDir(path).filePath(QStringLiteral("preview.png")) : package.previewPath;
|
||||||
|
info.userPackage = userPackage;
|
||||||
|
packages.append(info);
|
||||||
|
seenIds.insert(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const QString &id : collectPackageIds(builtInCharactersRootPath()))
|
||||||
|
{
|
||||||
|
appendPackage(id, builtInPackagePath(id), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const QString &id : collectPackageIds(userCharactersRootPath()))
|
||||||
|
{
|
||||||
|
appendPackage(id, userPackagePath(id), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return packages;
|
||||||
|
}
|
||||||
|
|
||||||
QStringList CharacterPackageRepository::availablePackageIds()
|
QStringList CharacterPackageRepository::availablePackageIds()
|
||||||
{
|
{
|
||||||
const QDir rootDirectory(charactersRootPath());
|
|
||||||
if (!rootDirectory.exists())
|
|
||||||
{
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfoList entries = rootDirectory.entryInfoList(
|
|
||||||
QDir::Dirs | QDir::NoDotAndDotDot,
|
|
||||||
QDir::Name);
|
|
||||||
|
|
||||||
QStringList packageIds;
|
QStringList packageIds;
|
||||||
for (const QFileInfo &entry : entries)
|
const QVector<CharacterPackageInfo> packages = availablePackages();
|
||||||
|
for (const CharacterPackageInfo &package : packages)
|
||||||
{
|
{
|
||||||
if (!isValidCharacterId(entry.fileName()))
|
packageIds.append(package.id);
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QFileInfo manifest(QDir(entry.absoluteFilePath()).filePath(QStringLiteral("character.json")));
|
|
||||||
if (manifest.isFile())
|
|
||||||
{
|
|
||||||
packageIds.append(entry.fileName());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return packageIds;
|
return packageIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CharacterPackageValidationResult CharacterPackageRepository::validatePackageDirectoryWithDetails(const QString &directoryPath)
|
||||||
|
{
|
||||||
|
CharacterPackageValidationResult result;
|
||||||
|
result.valid = validateManifest(
|
||||||
|
QDir::cleanPath(directoryPath),
|
||||||
|
&result.characterId,
|
||||||
|
&result.displayName,
|
||||||
|
&result.errorMessage);
|
||||||
|
if (result.valid)
|
||||||
|
{
|
||||||
|
result.warningMessage = bubbleAnchorWarning(QDir::cleanPath(directoryPath));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::validatePackageDirectory(
|
||||||
|
const QString &directoryPath,
|
||||||
|
QString *characterId,
|
||||||
|
QString *displayName,
|
||||||
|
QString *errorMessage)
|
||||||
|
{
|
||||||
|
return validateManifest(QDir::cleanPath(directoryPath), characterId, displayName, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::importPackageDirectory(
|
||||||
|
const QString &sourceDirectoryPath,
|
||||||
|
bool overwrite,
|
||||||
|
QString *importedCharacterId,
|
||||||
|
QString *errorMessage)
|
||||||
|
{
|
||||||
|
QString characterId;
|
||||||
|
QString displayName;
|
||||||
|
if (!validatePackageDirectory(sourceDirectoryPath, &characterId, &displayName, errorMessage))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBuiltInPackage(characterId))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色 id 与内置角色重复,不能覆盖内置角色。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString targetPath = userPackagePath(characterId);
|
||||||
|
const QString sourcePath = QDir::cleanPath(sourceDirectoryPath);
|
||||||
|
if (sourcePath == QDir::cleanPath(targetPath))
|
||||||
|
{
|
||||||
|
if (importedCharacterId != nullptr)
|
||||||
|
{
|
||||||
|
*importedCharacterId = characterId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir userRoot(userCharactersRootPath());
|
||||||
|
if (!userRoot.exists() && !QDir().mkpath(userRoot.absolutePath()))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("无法创建用户角色目录。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString importStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss-zzz"));
|
||||||
|
const QString preparedPath = userRoot.filePath(QStringLiteral(".importing-") + characterId + QStringLiteral("-") + importStamp);
|
||||||
|
QDir(preparedPath).removeRecursively();
|
||||||
|
|
||||||
|
if (!copyDirectoryRecursively(sourcePath, preparedPath, errorMessage))
|
||||||
|
{
|
||||||
|
QDir(preparedPath).removeRecursively();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replaceDirectoryWithPreparedImport(preparedPath, targetPath, overwrite, errorMessage))
|
||||||
|
{
|
||||||
|
QDir(preparedPath).removeRecursively();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedCharacterId != nullptr)
|
||||||
|
{
|
||||||
|
*importedCharacterId = characterId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CharacterPackageRepository::deleteUserPackage(const QString &characterId, QString *errorMessage)
|
||||||
|
{
|
||||||
|
const QString trimmed = characterId.trimmed();
|
||||||
|
if (!isValidCharacterId(trimmed))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("角色 id 无效。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString targetPath = userPackagePath(trimmed);
|
||||||
|
const QString userRootPath = QDir::cleanPath(userCharactersRootPath());
|
||||||
|
const QString cleanedTargetPath = QDir::cleanPath(targetPath);
|
||||||
|
if (cleanedTargetPath == userRootPath || !cleanedTargetPath.startsWith(userRootPath + QLatin1Char('/')))
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("删除目标不在用户角色目录内。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QDir targetDirectory(cleanedTargetPath);
|
||||||
|
if (!targetDirectory.exists())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("用户角色目录不存在。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QFileInfo manifest(targetDirectory.filePath(QStringLiteral("character.json")));
|
||||||
|
if (!manifest.isFile())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("目标目录不是有效的用户角色包。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetDirectory.removeRecursively())
|
||||||
|
{
|
||||||
|
return setError(errorMessage, QStringLiteral("删除用户角色目录失败。"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,55 @@
|
|||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
struct CharacterPackageInfo
|
||||||
|
{
|
||||||
|
QString id;
|
||||||
|
QString displayName;
|
||||||
|
QString packagePath;
|
||||||
|
QString previewPath;
|
||||||
|
bool userPackage = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CharacterPackageValidationResult
|
||||||
|
{
|
||||||
|
bool valid = false;
|
||||||
|
QString characterId;
|
||||||
|
QString displayName;
|
||||||
|
QString warningMessage;
|
||||||
|
QString errorMessage;
|
||||||
|
};
|
||||||
|
|
||||||
class CharacterPackageRepository
|
class CharacterPackageRepository
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static QString charactersRootPath();
|
static QString charactersRootPath();
|
||||||
|
static QString builtInCharactersRootPath();
|
||||||
|
static QString userCharactersRootPath();
|
||||||
static QString defaultCharacterId();
|
static QString defaultCharacterId();
|
||||||
static QString defaultPackagePath();
|
static QString defaultPackagePath();
|
||||||
static QString defaultPreviewPath();
|
static QString defaultPreviewPath();
|
||||||
static QString packagePath(const QString &characterId);
|
static QString packagePath(const QString &characterId);
|
||||||
|
static QString builtInPackagePath(const QString &characterId);
|
||||||
|
static QString userPackagePath(const QString &characterId);
|
||||||
static QString previewPath(const QString &characterId);
|
static QString previewPath(const QString &characterId);
|
||||||
|
static bool isValidCharacterId(const QString &characterId);
|
||||||
|
static bool hasPackage(const QString &characterId);
|
||||||
|
static bool hasBuiltInPackage(const QString &characterId);
|
||||||
|
static bool hasUserPackage(const QString &characterId);
|
||||||
|
static QVector<CharacterPackageInfo> availablePackages();
|
||||||
static QStringList availablePackageIds();
|
static QStringList availablePackageIds();
|
||||||
|
static CharacterPackageValidationResult validatePackageDirectoryWithDetails(const QString &directoryPath);
|
||||||
|
static bool validatePackageDirectory(
|
||||||
|
const QString &directoryPath,
|
||||||
|
QString *characterId = nullptr,
|
||||||
|
QString *displayName = nullptr,
|
||||||
|
QString *errorMessage = nullptr);
|
||||||
|
static bool importPackageDirectory(
|
||||||
|
const QString &sourceDirectoryPath,
|
||||||
|
bool overwrite,
|
||||||
|
QString *importedCharacterId = nullptr,
|
||||||
|
QString *errorMessage = nullptr);
|
||||||
|
static bool deleteUserPackage(const QString &characterId, QString *errorMessage = nullptr);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct AppConfig
|
|||||||
bool enableAnimationPrewarm = true;
|
bool enableAnimationPrewarm = true;
|
||||||
int animationCacheLimitMb = 180;
|
int animationCacheLimitMb = 180;
|
||||||
bool unloadAnimationsWhenHidden = true;
|
bool unloadAnimationsWhenHidden = true;
|
||||||
|
QString characterId = QStringLiteral("shiroko");
|
||||||
int requestContextMessageLimit = 12;
|
int requestContextMessageLimit = 12;
|
||||||
int memoryHistoryMessageLimit = 200;
|
int memoryHistoryMessageLimit = 200;
|
||||||
bool saveConversationHistory = false;
|
bool saveConversationHistory = false;
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ QJsonObject chatObjectFromConfig(const AppConfig &config)
|
|||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QJsonObject characterObjectFromConfig(const AppConfig &config)
|
||||||
|
{
|
||||||
|
QJsonObject character;
|
||||||
|
character.insert(QStringLiteral("id"), config.characterId);
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
|
||||||
QString normalizedProviderName(const QString &provider)
|
QString normalizedProviderName(const QString &provider)
|
||||||
{
|
{
|
||||||
const QString normalized = provider.trimmed().toLower();
|
const QString normalized = provider.trimmed().toLower();
|
||||||
@@ -245,6 +252,12 @@ AppConfig ConfigManager::loadAppConfig() const
|
|||||||
config.savedHistoryMessageLimit = chat.value(QStringLiteral("savedHistoryMessageLimit")).toInt(config.savedHistoryMessageLimit);
|
config.savedHistoryMessageLimit = chat.value(QStringLiteral("savedHistoryMessageLimit")).toInt(config.savedHistoryMessageLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const QJsonObject character = root.value(QStringLiteral("character")).toObject();
|
||||||
|
if (character.contains(QStringLiteral("id")))
|
||||||
|
{
|
||||||
|
config.characterId = character.value(QStringLiteral("id")).toString(config.characterId).trimmed();
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +345,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
|
|||||||
root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
|
root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
||||||
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
||||||
|
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
||||||
|
|
||||||
QFile file(appConfigPath());
|
QFile file(appConfigPath());
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
|
|||||||
+35
-5
@@ -81,6 +81,11 @@ AppConfig normalizedAppConfig(AppConfig config)
|
|||||||
config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200);
|
config.requestContextMessageLimit = qBound(0, config.requestContextMessageLimit, 200);
|
||||||
config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000);
|
config.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000);
|
||||||
config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000);
|
config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000);
|
||||||
|
config.characterId = config.characterId.trimmed();
|
||||||
|
if (!CharacterPackageRepository::hasPackage(config.characterId))
|
||||||
|
{
|
||||||
|
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +172,7 @@ PetWindow::~PetWindow()
|
|||||||
void PetWindow::applyAppConfig(const AppConfig &config)
|
void PetWindow::applyAppConfig(const AppConfig &config)
|
||||||
{
|
{
|
||||||
const AppConfig normalizedConfig = normalizedAppConfig(config);
|
const AppConfig normalizedConfig = normalizedAppConfig(config);
|
||||||
|
const bool characterChanged = m_appConfig.characterId != normalizedConfig.characterId;
|
||||||
const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|
const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|
||||||
|| m_appConfig.performanceMode != normalizedConfig.performanceMode
|
|| m_appConfig.performanceMode != normalizedConfig.performanceMode
|
||||||
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad;
|
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad;
|
||||||
@@ -186,7 +192,12 @@ void PetWindow::applyAppConfig(const AppConfig &config)
|
|||||||
move(m_appConfig.windowPosition);
|
move(m_appConfig.windowPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rebuildClips && !m_characterPackage.states.isEmpty())
|
if (characterChanged)
|
||||||
|
{
|
||||||
|
m_frameAnimator.stop();
|
||||||
|
loadCharacterPackage(m_appConfig.characterId, false);
|
||||||
|
}
|
||||||
|
else if (rebuildClips && !m_characterPackage.states.isEmpty())
|
||||||
{
|
{
|
||||||
const QString previousState = m_stateMachine.currentState().isEmpty()
|
const QString previousState = m_stateMachine.currentState().isEmpty()
|
||||||
? QStringLiteral("idle")
|
? QStringLiteral("idle")
|
||||||
@@ -782,22 +793,41 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
|
|||||||
|
|
||||||
void PetWindow::loadInitialImage()
|
void PetWindow::loadInitialImage()
|
||||||
{
|
{
|
||||||
|
loadCharacterPackage(m_appConfig.characterId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PetWindow::loadCharacterPackage(const QString &characterId, bool centerWindow)
|
||||||
|
{
|
||||||
|
const QString requestedCharacterId = CharacterPackageRepository::hasPackage(characterId)
|
||||||
|
? characterId
|
||||||
|
: CharacterPackageRepository::defaultCharacterId();
|
||||||
QString loadError;
|
QString loadError;
|
||||||
m_characterPackage = CharacterPackageLoader::load(CharacterPackageRepository::defaultPackagePath(), &loadError);
|
CharacterPackage package = CharacterPackageLoader::load(CharacterPackageRepository::packagePath(requestedCharacterId), &loadError);
|
||||||
|
if (!loadError.isEmpty() && requestedCharacterId != CharacterPackageRepository::defaultCharacterId())
|
||||||
|
{
|
||||||
|
Logger::warning(QStringLiteral("Character package load failed: id=%1 error=%2")
|
||||||
|
.arg(requestedCharacterId, loadError));
|
||||||
|
loadError.clear();
|
||||||
|
package = CharacterPackageLoader::load(CharacterPackageRepository::defaultPackagePath(), &loadError);
|
||||||
|
m_appConfig.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||||
|
}
|
||||||
|
|
||||||
if (!loadError.isEmpty())
|
if (!loadError.isEmpty())
|
||||||
{
|
{
|
||||||
Logger::warning(QStringLiteral("Character package load failed: ") + loadError);
|
Logger::warning(QStringLiteral("Character package load failed: ") + loadError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
m_characterPackage = package;
|
||||||
buildAnimationClips();
|
buildAnimationClips();
|
||||||
|
|
||||||
if (m_clips.contains(QStringLiteral("idle")))
|
if (m_clips.contains(QStringLiteral("idle")))
|
||||||
{
|
{
|
||||||
playResolvedState(m_stateMachine.start(), true);
|
playResolvedState(m_stateMachine.start(), centerWindow);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
setDisplayImage(CharacterPackageRepository::defaultPreviewPath(), true);
|
setDisplayImage(CharacterPackageRepository::previewPath(m_appConfig.characterId), centerWindow);
|
||||||
|
return !m_characterPackage.states.isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
void PetWindow::buildAnimationClips()
|
void PetWindow::buildAnimationClips()
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ protected:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void loadInitialImage();
|
void loadInitialImage();
|
||||||
|
bool loadCharacterPackage(const QString &characterId, bool centerWindow);
|
||||||
void buildAnimationClips();
|
void buildAnimationClips();
|
||||||
void addStateTestActions(QMenu *menu);
|
void addStateTestActions(QMenu *menu);
|
||||||
void startChat();
|
void startChat();
|
||||||
|
|||||||
+195
-20
@@ -9,6 +9,7 @@
|
|||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
#include <QDoubleSpinBox>
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QFileDialog>
|
||||||
#include <QFormLayout>
|
#include <QFormLayout>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
#include <QPair>
|
#include <QPair>
|
||||||
#include <QPointer>
|
#include <QPointer>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QDir>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
#include <QStackedWidget>
|
#include <QStackedWidget>
|
||||||
#include <QStyle>
|
#include <QStyle>
|
||||||
@@ -90,6 +92,9 @@ SettingsDialog::SettingsDialog(
|
|||||||
, m_savedHistoryMessageLimitSpinBox(new QSpinBox(this))
|
, m_savedHistoryMessageLimitSpinBox(new QSpinBox(this))
|
||||||
, m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this))
|
, m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), this))
|
||||||
, m_characterComboBox(new QComboBox(this))
|
, m_characterComboBox(new QComboBox(this))
|
||||||
|
, m_importCharacterButton(new QPushButton(QStringLiteral("导入角色文件夹"), this))
|
||||||
|
, m_deleteCharacterButton(new QPushButton(QStringLiteral("删除角色"), this))
|
||||||
|
, m_characterStatusLabel(new QLabel(this))
|
||||||
, m_configStore(configStore)
|
, m_configStore(configStore)
|
||||||
, m_appConfig(appConfig)
|
, m_appConfig(appConfig)
|
||||||
, m_aiTestBlocked(std::move(aiTestBlocked))
|
, m_aiTestBlocked(std::move(aiTestBlocked))
|
||||||
@@ -273,29 +278,13 @@ SettingsDialog::SettingsDialog(
|
|||||||
auto *chatPage = new QWidget(this);
|
auto *chatPage = new QWidget(this);
|
||||||
chatPage->setLayout(chatPageLayout);
|
chatPage->setLayout(chatPageLayout);
|
||||||
|
|
||||||
QStringList characterIds = CharacterPackageRepository::availablePackageIds();
|
m_characterStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||||
if (characterIds.isEmpty())
|
m_characterStatusLabel->setWordWrap(true);
|
||||||
{
|
reloadCharacterList(m_appConfig.characterId);
|
||||||
characterIds.append(CharacterPackageRepository::defaultCharacterId());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const QString &characterId : characterIds)
|
|
||||||
{
|
|
||||||
m_characterComboBox->addItem(characterId, characterId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const int defaultCharacterIndex = m_characterComboBox->findData(CharacterPackageRepository::defaultCharacterId());
|
|
||||||
if (defaultCharacterIndex >= 0)
|
|
||||||
{
|
|
||||||
m_characterComboBox->setCurrentIndex(defaultCharacterIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
m_characterComboBox->setEnabled(false);
|
|
||||||
m_characterComboBox->setToolTip(QStringLiteral("角色选择将在多角色资源配置接入后启用。"));
|
|
||||||
|
|
||||||
auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this);
|
auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this);
|
||||||
characterTitleLabel->setObjectName(QStringLiteral("PageTitle"));
|
characterTitleLabel->setObjectName(QStringLiteral("PageTitle"));
|
||||||
auto *characterHintLabel = new QLabel(QStringLiteral("当前版本使用内置角色资源。"), this);
|
auto *characterHintLabel = new QLabel(QStringLiteral("支持导入本地角色文件夹。导入前会先验证角色包,验证失败不会复制或覆盖任何文件。"), this);
|
||||||
characterHintLabel->setObjectName(QStringLiteral("HintLabel"));
|
characterHintLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||||
characterHintLabel->setWordWrap(true);
|
characterHintLabel->setWordWrap(true);
|
||||||
|
|
||||||
@@ -306,11 +295,17 @@ SettingsDialog::SettingsDialog(
|
|||||||
characterFormLayout->setVerticalSpacing(12);
|
characterFormLayout->setVerticalSpacing(12);
|
||||||
characterFormLayout->addRow(QStringLiteral("当前角色"), m_characterComboBox);
|
characterFormLayout->addRow(QStringLiteral("当前角色"), m_characterComboBox);
|
||||||
|
|
||||||
|
auto *characterActionLayout = new QHBoxLayout();
|
||||||
|
characterActionLayout->addWidget(m_importCharacterButton);
|
||||||
|
characterActionLayout->addWidget(m_deleteCharacterButton);
|
||||||
|
characterActionLayout->addWidget(m_characterStatusLabel, 1);
|
||||||
|
|
||||||
auto *characterPageLayout = new QVBoxLayout();
|
auto *characterPageLayout = new QVBoxLayout();
|
||||||
characterPageLayout->setContentsMargins(24, 24, 24, 24);
|
characterPageLayout->setContentsMargins(24, 24, 24, 24);
|
||||||
characterPageLayout->setSpacing(16);
|
characterPageLayout->setSpacing(16);
|
||||||
characterPageLayout->addWidget(characterTitleLabel);
|
characterPageLayout->addWidget(characterTitleLabel);
|
||||||
characterPageLayout->addLayout(characterFormLayout);
|
characterPageLayout->addLayout(characterFormLayout);
|
||||||
|
characterPageLayout->addLayout(characterActionLayout);
|
||||||
characterPageLayout->addWidget(characterHintLabel);
|
characterPageLayout->addWidget(characterHintLabel);
|
||||||
characterPageLayout->addStretch();
|
characterPageLayout->addStretch();
|
||||||
|
|
||||||
@@ -404,6 +399,12 @@ SettingsDialog::SettingsDialog(
|
|||||||
connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() {
|
connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() {
|
||||||
this->clearConversationHistory();
|
this->clearConversationHistory();
|
||||||
});
|
});
|
||||||
|
connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||||
|
importCharacterFolder();
|
||||||
|
});
|
||||||
|
connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||||
|
deleteSelectedCharacter();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsDialog::~SettingsDialog()
|
SettingsDialog::~SettingsDialog()
|
||||||
@@ -449,6 +450,11 @@ AppConfig SettingsDialog::appConfig() const
|
|||||||
config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value();
|
config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value();
|
||||||
config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked();
|
config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked();
|
||||||
config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value();
|
config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value();
|
||||||
|
config.characterId = m_characterComboBox->currentData().toString().trimmed();
|
||||||
|
if (config.characterId.isEmpty())
|
||||||
|
{
|
||||||
|
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -755,3 +761,172 @@ void SettingsDialog::clearConversationHistory()
|
|||||||
|
|
||||||
m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。"));
|
m_clearConversationStatusLabel->setText(QStringLiteral("聊天记录已清空。"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::reloadCharacterList(const QString &selectedCharacterId)
|
||||||
|
{
|
||||||
|
const QString currentSelection = selectedCharacterId.trimmed().isEmpty()
|
||||||
|
? m_characterComboBox->currentData().toString()
|
||||||
|
: selectedCharacterId.trimmed();
|
||||||
|
|
||||||
|
m_characterComboBox->clear();
|
||||||
|
const QVector<CharacterPackageInfo> packages = CharacterPackageRepository::availablePackages();
|
||||||
|
for (const CharacterPackageInfo &package : packages)
|
||||||
|
{
|
||||||
|
QString label = package.displayName.trimmed().isEmpty() ? package.id : package.displayName.trimmed();
|
||||||
|
if (label != package.id)
|
||||||
|
{
|
||||||
|
label += QStringLiteral(" (") + package.id + QStringLiteral(")");
|
||||||
|
}
|
||||||
|
label += package.userPackage ? QStringLiteral(" - 用户") : QStringLiteral(" - 内置");
|
||||||
|
m_characterComboBox->addItem(label, package.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_characterComboBox->count() == 0)
|
||||||
|
{
|
||||||
|
m_characterComboBox->addItem(CharacterPackageRepository::defaultCharacterId(), CharacterPackageRepository::defaultCharacterId());
|
||||||
|
}
|
||||||
|
|
||||||
|
int selectedIndex = m_characterComboBox->findData(currentSelection);
|
||||||
|
if (selectedIndex < 0)
|
||||||
|
{
|
||||||
|
selectedIndex = m_characterComboBox->findData(CharacterPackageRepository::defaultCharacterId());
|
||||||
|
}
|
||||||
|
m_characterComboBox->setCurrentIndex(selectedIndex >= 0 ? selectedIndex : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::deleteSelectedCharacter()
|
||||||
|
{
|
||||||
|
const QString characterId = m_characterComboBox->currentData().toString().trimmed();
|
||||||
|
if (characterId.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (CharacterPackageRepository::hasBuiltInPackage(characterId))
|
||||||
|
{
|
||||||
|
QMessageBox::information(
|
||||||
|
this,
|
||||||
|
QStringLiteral("不能删除内置角色"),
|
||||||
|
QStringLiteral("当前选择的是内置角色,不能删除。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!CharacterPackageRepository::hasUserPackage(characterId))
|
||||||
|
{
|
||||||
|
QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("删除角色失败"),
|
||||||
|
QStringLiteral("没有找到可删除的用户角色目录。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QMessageBox::StandardButton result = QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("删除角色"),
|
||||||
|
QStringLiteral("确定要删除用户角色 \"%1\" 吗?\n\n这会删除该角色的用户角色目录,操作不可恢复。").arg(characterId),
|
||||||
|
QMessageBox::Yes | QMessageBox::Cancel,
|
||||||
|
QMessageBox::Cancel);
|
||||||
|
if (result != QMessageBox::Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString errorMessage;
|
||||||
|
if (!CharacterPackageRepository::deleteUserPackage(characterId, &errorMessage))
|
||||||
|
{
|
||||||
|
QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("删除角色失败"),
|
||||||
|
errorMessage.isEmpty() ? QStringLiteral("删除用户角色目录失败。") : errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_appConfig.characterId == characterId)
|
||||||
|
{
|
||||||
|
m_appConfig.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadCharacterList(CharacterPackageRepository::defaultCharacterId());
|
||||||
|
m_characterStatusLabel->setText(QStringLiteral("已删除角色:%1").arg(characterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsDialog::importCharacterFolder()
|
||||||
|
{
|
||||||
|
const QString directoryPath = QFileDialog::getExistingDirectory(
|
||||||
|
this,
|
||||||
|
QStringLiteral("选择角色文件夹"),
|
||||||
|
QString(),
|
||||||
|
QFileDialog::ShowDirsOnly | QFileDialog::DontResolveSymlinks);
|
||||||
|
if (directoryPath.isEmpty())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterPackageValidationResult validation = CharacterPackageRepository::validatePackageDirectoryWithDetails(directoryPath);
|
||||||
|
if (!validation.valid)
|
||||||
|
{
|
||||||
|
QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("角色导入失败"),
|
||||||
|
QStringLiteral("角色包验证未通过:\n") + validation.errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString characterId = validation.characterId;
|
||||||
|
const QString displayName = validation.displayName;
|
||||||
|
if (CharacterPackageRepository::hasBuiltInPackage(characterId))
|
||||||
|
{
|
||||||
|
QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("角色导入失败"),
|
||||||
|
QStringLiteral("角色 id 与内置角色重复,不能覆盖内置角色。\n请修改角色包 id 或文件夹名后再导入。"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool overwrite = false;
|
||||||
|
const bool sourceIsExistingUserPackage =
|
||||||
|
QDir::cleanPath(directoryPath) == QDir::cleanPath(CharacterPackageRepository::userPackagePath(characterId));
|
||||||
|
if (CharacterPackageRepository::hasUserPackage(characterId) && !sourceIsExistingUserPackage)
|
||||||
|
{
|
||||||
|
const QMessageBox::StandardButton result = QMessageBox::question(
|
||||||
|
this,
|
||||||
|
QStringLiteral("覆盖已有角色"),
|
||||||
|
QStringLiteral("已存在同名用户角色:%1\n是否覆盖?").arg(characterId),
|
||||||
|
QMessageBox::Yes | QMessageBox::Cancel,
|
||||||
|
QMessageBox::Cancel);
|
||||||
|
if (result != QMessageBox::Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
overwrite = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validation.warningMessage.isEmpty())
|
||||||
|
{
|
||||||
|
const QMessageBox::StandardButton result = QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("角色导入提示"),
|
||||||
|
validation.warningMessage + QStringLiteral("\n\n仍然继续导入吗?"),
|
||||||
|
QMessageBox::Yes | QMessageBox::Cancel,
|
||||||
|
QMessageBox::Cancel);
|
||||||
|
if (result != QMessageBox::Yes)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QString errorMessage;
|
||||||
|
QString importedCharacterId;
|
||||||
|
if (!CharacterPackageRepository::importPackageDirectory(directoryPath, overwrite, &importedCharacterId, &errorMessage))
|
||||||
|
{
|
||||||
|
QMessageBox::warning(
|
||||||
|
this,
|
||||||
|
QStringLiteral("角色导入失败"),
|
||||||
|
errorMessage.isEmpty() ? QStringLiteral("复制角色文件夹失败。") : errorMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadCharacterList(importedCharacterId);
|
||||||
|
m_characterStatusLabel->setText(QStringLiteral("已导入角色:%1").arg(displayName.isEmpty() ? importedCharacterId : displayName));
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ private:
|
|||||||
void testConnection();
|
void testConnection();
|
||||||
void setTestStatus(const QString &message, const QString &state);
|
void setTestStatus(const QString &message, const QString &state);
|
||||||
void clearConversationHistory();
|
void clearConversationHistory();
|
||||||
|
void reloadCharacterList(const QString &selectedCharacterId = {});
|
||||||
|
void importCharacterFolder();
|
||||||
|
void deleteSelectedCharacter();
|
||||||
|
|
||||||
QComboBox *m_providerComboBox = nullptr;
|
QComboBox *m_providerComboBox = nullptr;
|
||||||
QLineEdit *m_baseUrlEdit = nullptr;
|
QLineEdit *m_baseUrlEdit = nullptr;
|
||||||
@@ -71,6 +74,9 @@ private:
|
|||||||
QPushButton *m_clearConversationHistoryButton = nullptr;
|
QPushButton *m_clearConversationHistoryButton = nullptr;
|
||||||
QLabel *m_clearConversationStatusLabel = nullptr;
|
QLabel *m_clearConversationStatusLabel = nullptr;
|
||||||
QComboBox *m_characterComboBox = nullptr;
|
QComboBox *m_characterComboBox = nullptr;
|
||||||
|
QPushButton *m_importCharacterButton = nullptr;
|
||||||
|
QPushButton *m_deleteCharacterButton = nullptr;
|
||||||
|
QLabel *m_characterStatusLabel = nullptr;
|
||||||
AIConfigStore m_configStore;
|
AIConfigStore m_configStore;
|
||||||
AIConfigStore m_acceptedConfigStore;
|
AIConfigStore m_acceptedConfigStore;
|
||||||
AppConfig m_appConfig;
|
AppConfig m_appConfig;
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ExePath,
|
||||||
|
[string]$Version = "",
|
||||||
|
[string]$OutputRoot = "dist",
|
||||||
|
[string]$WindeployQtPath = "",
|
||||||
|
[switch]$NoZip,
|
||||||
|
[switch]$BuildInstaller,
|
||||||
|
[string]$InnoCompilerPath = "",
|
||||||
|
[string]$InstallerOutputDir = "",
|
||||||
|
[string]$InstallerWorkOutputDir = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Resolve-RepoRoot {
|
||||||
|
$scriptDirectory = Split-Path -Parent $PSCommandPath
|
||||||
|
return Split-Path -Parent $scriptDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-FullPath {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$BasePath
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([System.IO.Path]::IsPathRooted($Path)) {
|
||||||
|
return [System.IO.Path]::GetFullPath($Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Read-ProjectVersion {
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
$cmakeFile = Join-Path $RepoRoot "CMakeLists.txt"
|
||||||
|
$content = Get-Content -LiteralPath $cmakeFile -Raw
|
||||||
|
$match = [regex]::Match($content, 'project\s*\(\s*QtDesktopPet\s+VERSION\s+([0-9]+(?:\.[0-9]+){1,3})')
|
||||||
|
if ($match.Success) {
|
||||||
|
return $match.Groups[1].Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return "0.1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-WindeployQt {
|
||||||
|
param(
|
||||||
|
[string]$ExplicitPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
|
||||||
|
$resolved = Resolve-FullPath -Path $ExplicitPath -BasePath (Get-Location)
|
||||||
|
if (Test-Path -LiteralPath $resolved) {
|
||||||
|
return $resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "windeployqt was not found at '$ExplicitPath'."
|
||||||
|
}
|
||||||
|
|
||||||
|
$command = Get-Command "windeployqt.exe" -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $command) {
|
||||||
|
return $command.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "windeployqt.exe was not found. Add the Qt bin directory to PATH or pass -WindeployQtPath."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-InnoCompiler {
|
||||||
|
param(
|
||||||
|
[string]$ExplicitPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$candidatePaths = @()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
|
||||||
|
$candidatePaths += $ExplicitPath
|
||||||
|
} else {
|
||||||
|
$candidatePaths += @(
|
||||||
|
"D:\Inno Setup 7\ISCC.exe",
|
||||||
|
"D:\Inno Setup 6\ISCC.exe",
|
||||||
|
"C:\Program Files (x86)\Inno Setup 7\ISCC.exe",
|
||||||
|
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidatePath in $candidatePaths) {
|
||||||
|
$resolved = Resolve-FullPath -Path $candidatePath -BasePath (Get-Location)
|
||||||
|
if (Test-Path -LiteralPath $resolved) {
|
||||||
|
return $resolved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) {
|
||||||
|
throw "Inno Setup compiler was not found at '$ExplicitPath'."
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "ISCC.exe was not found. Install Inno Setup or pass -InnoCompilerPath."
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-DefaultInstallerWorkOutputDir {
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
$driveRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($RepoRoot))
|
||||||
|
if ([string]::IsNullOrWhiteSpace($driveRoot)) {
|
||||||
|
return [System.IO.Path]::GetFullPath("QtDesktopPetInstallerOutput")
|
||||||
|
}
|
||||||
|
|
||||||
|
return [System.IO.Path]::GetFullPath((Join-Path $driveRoot "QtDesktopPetInstallerOutput"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-RequiredPath {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Description
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
throw "$Description was not found: $Path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-VersionSegment {
|
||||||
|
param(
|
||||||
|
[string]$Value
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($Value -notmatch '^[0-9A-Za-z._-]+$') {
|
||||||
|
throw "Version contains unsupported path characters: $Value"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-ChildPath {
|
||||||
|
param(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$ParentPath,
|
||||||
|
[string]$Description
|
||||||
|
)
|
||||||
|
|
||||||
|
$fullPath = [System.IO.Path]::GetFullPath($Path).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||||
|
$fullParentPath = [System.IO.Path]::GetFullPath($ParentPath).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
|
||||||
|
$expectedPrefix = $fullParentPath + [System.IO.Path]::DirectorySeparatorChar
|
||||||
|
if (-not $fullPath.StartsWith($expectedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "$Description must stay inside output root: $fullPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Copy-DirectoryFresh {
|
||||||
|
param(
|
||||||
|
[string]$Source,
|
||||||
|
[string]$Destination
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $Destination) {
|
||||||
|
Remove-Item -LiteralPath $Destination -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Destination) | Out-Null
|
||||||
|
Copy-Item -LiteralPath $Source -Destination $Destination -Recurse -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-CheckedProcess {
|
||||||
|
param(
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments
|
||||||
|
)
|
||||||
|
|
||||||
|
& $FilePath @Arguments
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "$FilePath exited with code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$repoRoot = Resolve-RepoRoot
|
||||||
|
$resolvedExePath = Resolve-FullPath -Path $ExePath -BasePath $repoRoot
|
||||||
|
$resolvedOutputRoot = Resolve-FullPath -Path $OutputRoot -BasePath $repoRoot
|
||||||
|
if ([string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
$Version = Read-ProjectVersion -RepoRoot $repoRoot
|
||||||
|
}
|
||||||
|
Assert-VersionSegment -Value $Version
|
||||||
|
|
||||||
|
$packageName = "QtDesktopPet-$Version-windows-x64"
|
||||||
|
$packageRoot = Join-Path $resolvedOutputRoot $packageName
|
||||||
|
$installerFileName = "$packageName-setup.exe"
|
||||||
|
$targetExePath = Join-Path $packageRoot "QtDesktopPet.exe"
|
||||||
|
$resourcesRoot = Join-Path $repoRoot "resources"
|
||||||
|
$charactersRoot = Join-Path $resourcesRoot "characters"
|
||||||
|
$iconsRoot = Join-Path $resourcesRoot "icons"
|
||||||
|
$licensePath = Join-Path $repoRoot "LICENSE"
|
||||||
|
$readmePath = Join-Path $repoRoot "README.md"
|
||||||
|
$installerScriptPath = Join-Path $repoRoot "installer\QtDesktopPet.iss"
|
||||||
|
|
||||||
|
Assert-RequiredPath -Path $resolvedExePath -Description "QtDesktopPet.exe"
|
||||||
|
Assert-RequiredPath -Path (Join-Path $charactersRoot "shiroko\character.json") -Description "Default character package"
|
||||||
|
Assert-RequiredPath -Path (Join-Path $iconsRoot "app_icon.ico") -Description "Application icon"
|
||||||
|
Assert-RequiredPath -Path $licensePath -Description "LICENSE"
|
||||||
|
Assert-RequiredPath -Path $readmePath -Description "README.md"
|
||||||
|
|
||||||
|
$windeployQt = Resolve-WindeployQt -ExplicitPath $WindeployQtPath
|
||||||
|
Assert-ChildPath -Path $packageRoot -ParentPath $resolvedOutputRoot -Description "Release package directory"
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $packageRoot) {
|
||||||
|
Remove-Item -LiteralPath $packageRoot -Recurse -Force
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null
|
||||||
|
|
||||||
|
Copy-Item -LiteralPath $resolvedExePath -Destination $targetExePath -Force
|
||||||
|
Copy-DirectoryFresh -Source $charactersRoot -Destination (Join-Path $packageRoot "resources\characters")
|
||||||
|
Copy-DirectoryFresh -Source $iconsRoot -Destination (Join-Path $packageRoot "resources\icons")
|
||||||
|
Copy-Item -LiteralPath $licensePath -Destination (Join-Path $packageRoot "LICENSE") -Force
|
||||||
|
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)
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $packageRoot "package_manifest.txt"
|
||||||
|
@(
|
||||||
|
"QtDesktopPet release package",
|
||||||
|
"Version: $Version",
|
||||||
|
"CreatedUtc: $((Get-Date).ToUniversalTime().ToString("o"))",
|
||||||
|
"SourceExe: $resolvedExePath",
|
||||||
|
"Includes: QtDesktopPet.exe, Qt runtime, resources, LICENSE, README.md",
|
||||||
|
"Excludes: tools, docs, reports, build, dist, .git"
|
||||||
|
) | Set-Content -LiteralPath $manifestPath -Encoding UTF8
|
||||||
|
|
||||||
|
if (-not $NoZip) {
|
||||||
|
$zipPath = Join-Path $resolvedOutputRoot "$packageName.zip"
|
||||||
|
Assert-ChildPath -Path $zipPath -ParentPath $resolvedOutputRoot -Description "ZIP package"
|
||||||
|
if (Test-Path -LiteralPath $zipPath) {
|
||||||
|
Remove-Item -LiteralPath $zipPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Compress-Archive -LiteralPath $packageRoot -DestinationPath $zipPath -Force
|
||||||
|
Write-Host "ZIP package: $zipPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($BuildInstaller) {
|
||||||
|
Assert-RequiredPath -Path $installerScriptPath -Description "Inno Setup script"
|
||||||
|
$resolvedInnoCompilerPath = Resolve-InnoCompiler -ExplicitPath $InnoCompilerPath
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($InstallerOutputDir)) {
|
||||||
|
$installerFinalOutputDir = $repoRoot
|
||||||
|
} else {
|
||||||
|
$installerFinalOutputDir = Resolve-FullPath -Path $InstallerOutputDir -BasePath $repoRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($InstallerWorkOutputDir)) {
|
||||||
|
$installerWorkOutputDir = Resolve-DefaultInstallerWorkOutputDir -RepoRoot $repoRoot
|
||||||
|
} else {
|
||||||
|
$installerWorkOutputDir = Resolve-FullPath -Path $InstallerWorkOutputDir -BasePath $repoRoot
|
||||||
|
}
|
||||||
|
New-Item -ItemType Directory -Force -Path $installerWorkOutputDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $installerFinalOutputDir | Out-Null
|
||||||
|
|
||||||
|
Invoke-CheckedProcess -FilePath $resolvedInnoCompilerPath -Arguments @(
|
||||||
|
"/DAppVersion=$Version",
|
||||||
|
"/DSourceDir=$packageRoot",
|
||||||
|
"/DOutputDir=$installerWorkOutputDir",
|
||||||
|
$installerScriptPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$builtInstallerPath = Join-Path $installerWorkOutputDir $installerFileName
|
||||||
|
Assert-RequiredPath -Path $builtInstallerPath -Description "Built installer"
|
||||||
|
|
||||||
|
$finalInstallerPath = Join-Path $installerFinalOutputDir $installerFileName
|
||||||
|
if ([System.IO.Path]::GetFullPath($builtInstallerPath) -ne [System.IO.Path]::GetFullPath($finalInstallerPath)) {
|
||||||
|
Copy-Item -LiteralPath $builtInstallerPath -Destination $finalInstallerPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installer work output: $installerWorkOutputDir"
|
||||||
|
Write-Host "Installer final output: $finalInstallerPath"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Release package directory: $packageRoot"
|
||||||
Reference in New Issue
Block a user