Add character management and release packaging
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
build/
|
||||
dist/
|
||||
cmake-build-*/
|
||||
.vs/
|
||||
.vscode/
|
||||
|
||||
@@ -78,6 +78,10 @@ target_link_libraries(QtDesktopPet
|
||||
Qt6::Widgets
|
||||
)
|
||||
|
||||
set_target_properties(QtDesktopPet PROPERTIES
|
||||
WIN32_EXECUTABLE TRUE
|
||||
)
|
||||
|
||||
if (WIN32)
|
||||
set_source_files_properties(resources/icons/app_icon.rc
|
||||
PROPERTIES
|
||||
|
||||
@@ -32,13 +32,16 @@ QtDesktopPet 是一个基于 Qt Widgets / C++17 的 Windows 桌面宠物原型
|
||||
- 内存历史上限和可选本地历史保存
|
||||
- AI 请求取消和对话清空
|
||||
- Google Gemini 原生聊天请求
|
||||
- 角色文件夹导入和角色切换
|
||||
- 删除用户导入角色
|
||||
- Windows 发布打包脚本和 Inno Setup 安装器脚本
|
||||
|
||||
尚未实现:
|
||||
|
||||
- 角色导入/切换界面
|
||||
- 角色导出和更完整的管理界面
|
||||
- 对话历史导出/管理界面
|
||||
- 长期性能压测记录
|
||||
- 打包发布脚本
|
||||
- 发布包实机安装/卸载验证
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -92,7 +95,13 @@ resources/icons/app_icon_1024.png
|
||||
resources/characters/shiroko/
|
||||
```
|
||||
|
||||
角色包按 `resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录。基本结构:
|
||||
内置角色包按 `resources/characters/<characterId>/` 组织。用户导入的角色包不会写入安装目录,而是复制到用户数据目录:
|
||||
|
||||
```text
|
||||
QStandardPaths::AppDataLocation/characters/<characterId>/
|
||||
```
|
||||
|
||||
角色包基本结构:
|
||||
|
||||
```text
|
||||
resources/characters/shiroko/
|
||||
@@ -108,7 +117,20 @@ resources/characters/shiroko/
|
||||
```
|
||||
|
||||
当前素材版本为 `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 个旧日志文件
|
||||
- 文件名为 `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
|
||||
```
|
||||
|
||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。
|
||||
发布包应排除 `tools/`、`docs/`、`reports/`、`build/`、`dist/` 和 `.git/`,只保留运行必需文件、`resources/characters/`、`resources/icons/`、`LICENSE` 和必要说明。
|
||||
|
||||
## AI 配置和聊天
|
||||
|
||||
|
||||
@@ -1394,16 +1394,29 @@ Windows 下不能只拷贝 exe。
|
||||
8. 便携模式是否可用
|
||||
```
|
||||
|
||||
建议后续提供:
|
||||
当前提供:
|
||||
|
||||
```text
|
||||
1. Windows x64 Release 构建
|
||||
2. 打包脚本
|
||||
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
|
||||
1. 角色包导入和角色切换
|
||||
1. 角色包导出和更完整管理界面
|
||||
2. 对话历史导出、搜索或更完整管理界面
|
||||
3. 发布前素材授权确认与打包验证
|
||||
4. 长期性能压测记录
|
||||
|
||||
@@ -84,7 +84,7 @@ cmake -S . -B build/mingw-debug -G Ninja `
|
||||
|
||||
## 3. 角色包约定
|
||||
|
||||
`resources/characters/shiroko` 目录作为当前默认角色包。角色包按 `resources/characters/<characterId>/` 组织,后续新增角色包时放在同级子目录。
|
||||
`resources/characters/shiroko` 目录作为当前默认内置角色包。内置角色按 `resources/characters/<characterId>/` 组织,用户导入角色复制到 `QStandardPaths::AppDataLocation/characters/<characterId>/`。
|
||||
|
||||
已检查到的结构:
|
||||
|
||||
@@ -504,6 +504,7 @@ tools/
|
||||
docs/
|
||||
reports/
|
||||
build/
|
||||
dist/
|
||||
.git/
|
||||
```
|
||||
|
||||
@@ -586,12 +587,14 @@ build/
|
||||
懒加载当前为状态级首次播放加载,并已接入主线程分批预热和状态级 LRU 卸载;单轮预热不会反复重新加载刚被 LRU 卸载的状态
|
||||
Windows 下 API Key 使用 DPAPI 加密保存,非 Windows 需用户确认后才允许明文保存
|
||||
运行时资源优先读取可执行文件同级 resources/,找不到时回退到源码目录 resources/
|
||||
已支持角色文件夹导入、角色切换和删除用户导入角色:验证通过后复制到用户数据目录,验证失败不做文件操作;内置角色不可删除
|
||||
已新增 Windows 发布打包脚本和 Inno Setup 安装器脚本;脚本不负责 CMake 构建,安装器卸载时可由用户确认后清理当前用户数据目录
|
||||
```
|
||||
|
||||
当前实现与计划仍存在差异:
|
||||
|
||||
```text
|
||||
1. SettingsDialog 仍是最小设置界面,尚未包含完整角色切换流程和更完整的分区布局
|
||||
1. SettingsDialog 仍是最小设置界面,角色页已有导入、切换和删除用户角色,但尚未包含导出和更完整的角色管理流程
|
||||
2. 对话历史已有内存上限和可选本地保存,但尚未提供导出、搜索或完整管理界面
|
||||
3. 状态级懒加载尚未包含后台线程预热、单帧级缓存和长期压测记录
|
||||
4. README 和开发文档已开始同步当前进度,但仍需随功能继续维护
|
||||
@@ -639,6 +642,6 @@ build/
|
||||
|
||||
```text
|
||||
1. shiroko 素材是否允许作为正式开源发布素材继续保留在仓库中
|
||||
2. 设置页下一步先完善角色包配置,还是先补发布打包配置
|
||||
2. 角色管理下一步是否需要导出、打开用户角色目录
|
||||
3. 对话历史后续是否需要导出、搜索或按角色/Provider 分组管理
|
||||
```
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 显示器 / DPI | TODO |
|
||||
| Qt 版本 | TODO |
|
||||
| 角色包 | `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/
|
||||
reports/
|
||||
build/
|
||||
dist/
|
||||
.git/
|
||||
```
|
||||
|
||||
@@ -99,6 +100,16 @@ LICENSE
|
||||
README.md
|
||||
```
|
||||
|
||||
安装器卸载验证:
|
||||
|
||||
```text
|
||||
1. 正常卸载时程序文件应删除
|
||||
2. 选择不删除用户数据时,配置、导入角色、聊天记录和日志应保留
|
||||
3. 选择删除用户数据时,卸载完成阶段应删除当前用户下的 QtDesktopPet 数据目录
|
||||
```
|
||||
|
||||
安装器最终文件默认放在项目根目录。Inno Setup 编译阶段使用当前盘符下的纯 ASCII 临时目录,例如 `D:\QtDesktopPetInstallerOutput`,再复制最终安装包回项目根目录,用于规避中文项目路径下可能出现的 `EndUpdateResource failed (5)`。
|
||||
|
||||
运行时资源查找顺序:
|
||||
|
||||
```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"));
|
||||
if (package.id.isEmpty())
|
||||
{
|
||||
package.id = QFileInfo(package.packagePath).fileName().trimmed();
|
||||
}
|
||||
package.displayName = requiredString(root, QStringLiteral("displayName"));
|
||||
package.author = requiredString(root, QStringLiteral("author"));
|
||||
package.version = requiredString(root, QStringLiteral("version"));
|
||||
|
||||
@@ -1,28 +1,423 @@
|
||||
#include "CharacterPackageRepository.h"
|
||||
|
||||
#include "CharacterPackageLoader.h"
|
||||
#include "../util/ResourcePaths.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QImageReader>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
|
||||
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();
|
||||
return !trimmed.isEmpty()
|
||||
&& trimmed != QStringLiteral(".")
|
||||
&& trimmed != QStringLiteral("..")
|
||||
&& !trimmed.contains(QLatin1Char('/'))
|
||||
&& !trimmed.contains(QLatin1Char('\\'));
|
||||
if (trimmed.isEmpty()
|
||||
|| trimmed == QStringLiteral(".")
|
||||
|| trimmed == QStringLiteral("..")
|
||||
|| trimmed.startsWith(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()
|
||||
{
|
||||
return builtInCharactersRootPath();
|
||||
}
|
||||
|
||||
QString CharacterPackageRepository::builtInCharactersRootPath()
|
||||
{
|
||||
return ResourcePaths::charactersRootPath();
|
||||
}
|
||||
|
||||
QString CharacterPackageRepository::userCharactersRootPath()
|
||||
{
|
||||
return QDir(fallbackUserDataPath()).filePath(QStringLiteral("characters"));
|
||||
}
|
||||
|
||||
QString CharacterPackageRepository::defaultCharacterId()
|
||||
{
|
||||
return QStringLiteral("shiroko");
|
||||
@@ -39,6 +434,17 @@ QString CharacterPackageRepository::defaultPreviewPath()
|
||||
}
|
||||
|
||||
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();
|
||||
if (!isValidCharacterId(trimmed))
|
||||
@@ -46,7 +452,18 @@ QString CharacterPackageRepository::packagePath(const QString &characterId)
|
||||
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)
|
||||
@@ -60,32 +477,190 @@ QString CharacterPackageRepository::previewPath(const QString &characterId)
|
||||
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()
|
||||
{
|
||||
const QDir rootDirectory(charactersRootPath());
|
||||
if (!rootDirectory.exists())
|
||||
{
|
||||
return {};
|
||||
}
|
||||
|
||||
const QFileInfoList entries = rootDirectory.entryInfoList(
|
||||
QDir::Dirs | QDir::NoDotAndDotDot,
|
||||
QDir::Name);
|
||||
|
||||
QStringList packageIds;
|
||||
for (const QFileInfo &entry : entries)
|
||||
const QVector<CharacterPackageInfo> packages = availablePackages();
|
||||
for (const CharacterPackageInfo &package : packages)
|
||||
{
|
||||
if (!isValidCharacterId(entry.fileName()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
const QFileInfo manifest(QDir(entry.absoluteFilePath()).filePath(QStringLiteral("character.json")));
|
||||
if (manifest.isFile())
|
||||
{
|
||||
packageIds.append(entry.fileName());
|
||||
}
|
||||
packageIds.append(package.id);
|
||||
}
|
||||
|
||||
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 <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
|
||||
{
|
||||
public:
|
||||
static QString charactersRootPath();
|
||||
static QString builtInCharactersRootPath();
|
||||
static QString userCharactersRootPath();
|
||||
static QString defaultCharacterId();
|
||||
static QString defaultPackagePath();
|
||||
static QString defaultPreviewPath();
|
||||
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 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 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;
|
||||
int animationCacheLimitMb = 180;
|
||||
bool unloadAnimationsWhenHidden = true;
|
||||
QString characterId = QStringLiteral("shiroko");
|
||||
int requestContextMessageLimit = 12;
|
||||
int memoryHistoryMessageLimit = 200;
|
||||
bool saveConversationHistory = false;
|
||||
|
||||
@@ -50,6 +50,13 @@ QJsonObject chatObjectFromConfig(const AppConfig &config)
|
||||
return chat;
|
||||
}
|
||||
|
||||
QJsonObject characterObjectFromConfig(const AppConfig &config)
|
||||
{
|
||||
QJsonObject character;
|
||||
character.insert(QStringLiteral("id"), config.characterId);
|
||||
return character;
|
||||
}
|
||||
|
||||
QString normalizedProviderName(const QString &provider)
|
||||
{
|
||||
const QString normalized = provider.trimmed().toLower();
|
||||
@@ -245,6 +252,12 @@ AppConfig ConfigManager::loadAppConfig() const
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -332,6 +345,7 @@ bool ConfigManager::saveAppConfig(const AppConfig &config) const
|
||||
root.insert(QStringLiteral("window"), windowObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("performance"), performanceObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("chat"), chatObjectFromConfig(config));
|
||||
root.insert(QStringLiteral("character"), characterObjectFromConfig(config));
|
||||
|
||||
QFile file(appConfigPath());
|
||||
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.memoryHistoryMessageLimit = evenBoundedHistoryLimit(config.memoryHistoryMessageLimit, 2, 5000);
|
||||
config.savedHistoryMessageLimit = evenBoundedHistoryLimit(config.savedHistoryMessageLimit, 2, 10000);
|
||||
config.characterId = config.characterId.trimmed();
|
||||
if (!CharacterPackageRepository::hasPackage(config.characterId))
|
||||
{
|
||||
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -167,6 +172,7 @@ PetWindow::~PetWindow()
|
||||
void PetWindow::applyAppConfig(const AppConfig &config)
|
||||
{
|
||||
const AppConfig normalizedConfig = normalizedAppConfig(config);
|
||||
const bool characterChanged = m_appConfig.characterId != normalizedConfig.characterId;
|
||||
const bool rebuildClips = !qFuzzyCompare(m_appConfig.scale, normalizedConfig.scale)
|
||||
|| m_appConfig.performanceMode != normalizedConfig.performanceMode
|
||||
|| m_appConfig.enableLazyLoad != normalizedConfig.enableLazyLoad;
|
||||
@@ -186,7 +192,12 @@ void PetWindow::applyAppConfig(const AppConfig &config)
|
||||
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()
|
||||
? QStringLiteral("idle")
|
||||
@@ -782,22 +793,41 @@ void PetWindow::mouseReleaseEvent(QMouseEvent *event)
|
||||
|
||||
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;
|
||||
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())
|
||||
{
|
||||
Logger::warning(QStringLiteral("Character package load failed: ") + loadError);
|
||||
}
|
||||
|
||||
m_characterPackage = package;
|
||||
buildAnimationClips();
|
||||
|
||||
if (m_clips.contains(QStringLiteral("idle")))
|
||||
{
|
||||
playResolvedState(m_stateMachine.start(), true);
|
||||
return;
|
||||
playResolvedState(m_stateMachine.start(), centerWindow);
|
||||
return true;
|
||||
}
|
||||
|
||||
setDisplayImage(CharacterPackageRepository::defaultPreviewPath(), true);
|
||||
setDisplayImage(CharacterPackageRepository::previewPath(m_appConfig.characterId), centerWindow);
|
||||
return !m_characterPackage.states.isEmpty();
|
||||
}
|
||||
|
||||
void PetWindow::buildAnimationClips()
|
||||
|
||||
@@ -54,6 +54,7 @@ protected:
|
||||
|
||||
private:
|
||||
void loadInitialImage();
|
||||
bool loadCharacterPackage(const QString &characterId, bool centerWindow);
|
||||
void buildAnimationClips();
|
||||
void addStateTestActions(QMenu *menu);
|
||||
void startChat();
|
||||
|
||||
+195
-20
@@ -9,6 +9,7 @@
|
||||
#include <QComboBox>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QDoubleSpinBox>
|
||||
#include <QFileDialog>
|
||||
#include <QFormLayout>
|
||||
#include <QFrame>
|
||||
#include <QHBoxLayout>
|
||||
@@ -20,6 +21,7 @@
|
||||
#include <QPair>
|
||||
#include <QPointer>
|
||||
#include <QPushButton>
|
||||
#include <QDir>
|
||||
#include <QSpinBox>
|
||||
#include <QStackedWidget>
|
||||
#include <QStyle>
|
||||
@@ -90,6 +92,9 @@ SettingsDialog::SettingsDialog(
|
||||
, m_savedHistoryMessageLimitSpinBox(new QSpinBox(this))
|
||||
, m_clearConversationHistoryButton(new QPushButton(QStringLiteral("清空聊天记录"), 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_appConfig(appConfig)
|
||||
, m_aiTestBlocked(std::move(aiTestBlocked))
|
||||
@@ -273,29 +278,13 @@ SettingsDialog::SettingsDialog(
|
||||
auto *chatPage = new QWidget(this);
|
||||
chatPage->setLayout(chatPageLayout);
|
||||
|
||||
QStringList characterIds = CharacterPackageRepository::availablePackageIds();
|
||||
if (characterIds.isEmpty())
|
||||
{
|
||||
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("角色选择将在多角色资源配置接入后启用。"));
|
||||
m_characterStatusLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
m_characterStatusLabel->setWordWrap(true);
|
||||
reloadCharacterList(m_appConfig.characterId);
|
||||
|
||||
auto *characterTitleLabel = new QLabel(QStringLiteral("角色"), this);
|
||||
characterTitleLabel->setObjectName(QStringLiteral("PageTitle"));
|
||||
auto *characterHintLabel = new QLabel(QStringLiteral("当前版本使用内置角色资源。"), this);
|
||||
auto *characterHintLabel = new QLabel(QStringLiteral("支持导入本地角色文件夹。导入前会先验证角色包,验证失败不会复制或覆盖任何文件。"), this);
|
||||
characterHintLabel->setObjectName(QStringLiteral("HintLabel"));
|
||||
characterHintLabel->setWordWrap(true);
|
||||
|
||||
@@ -306,11 +295,17 @@ SettingsDialog::SettingsDialog(
|
||||
characterFormLayout->setVerticalSpacing(12);
|
||||
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();
|
||||
characterPageLayout->setContentsMargins(24, 24, 24, 24);
|
||||
characterPageLayout->setSpacing(16);
|
||||
characterPageLayout->addWidget(characterTitleLabel);
|
||||
characterPageLayout->addLayout(characterFormLayout);
|
||||
characterPageLayout->addLayout(characterActionLayout);
|
||||
characterPageLayout->addWidget(characterHintLabel);
|
||||
characterPageLayout->addStretch();
|
||||
|
||||
@@ -404,6 +399,12 @@ SettingsDialog::SettingsDialog(
|
||||
connect(m_clearConversationHistoryButton, &QPushButton::clicked, this, [this]() {
|
||||
this->clearConversationHistory();
|
||||
});
|
||||
connect(m_importCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||
importCharacterFolder();
|
||||
});
|
||||
connect(m_deleteCharacterButton, &QPushButton::clicked, this, [this]() {
|
||||
deleteSelectedCharacter();
|
||||
});
|
||||
}
|
||||
|
||||
SettingsDialog::~SettingsDialog()
|
||||
@@ -449,6 +450,11 @@ AppConfig SettingsDialog::appConfig() const
|
||||
config.memoryHistoryMessageLimit = m_memoryHistoryMessageLimitSpinBox->value();
|
||||
config.saveConversationHistory = m_saveConversationHistoryCheckBox->isChecked();
|
||||
config.savedHistoryMessageLimit = m_savedHistoryMessageLimitSpinBox->value();
|
||||
config.characterId = m_characterComboBox->currentData().toString().trimmed();
|
||||
if (config.characterId.isEmpty())
|
||||
{
|
||||
config.characterId = CharacterPackageRepository::defaultCharacterId();
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -755,3 +761,172 @@ void SettingsDialog::clearConversationHistory()
|
||||
|
||||
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 setTestStatus(const QString &message, const QString &state);
|
||||
void clearConversationHistory();
|
||||
void reloadCharacterList(const QString &selectedCharacterId = {});
|
||||
void importCharacterFolder();
|
||||
void deleteSelectedCharacter();
|
||||
|
||||
QComboBox *m_providerComboBox = nullptr;
|
||||
QLineEdit *m_baseUrlEdit = nullptr;
|
||||
@@ -71,6 +74,9 @@ private:
|
||||
QPushButton *m_clearConversationHistoryButton = nullptr;
|
||||
QLabel *m_clearConversationStatusLabel = nullptr;
|
||||
QComboBox *m_characterComboBox = nullptr;
|
||||
QPushButton *m_importCharacterButton = nullptr;
|
||||
QPushButton *m_deleteCharacterButton = nullptr;
|
||||
QLabel *m_characterStatusLabel = nullptr;
|
||||
AIConfigStore m_configStore;
|
||||
AIConfigStore m_acceptedConfigStore;
|
||||
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