diff --git a/.gitignore b/.gitignore index cb9265e..a2037bb 100644 --- a/.gitignore +++ b/.gitignore @@ -514,3 +514,4 @@ nul /*.AppImage /velopack-output-local-verify /velopack-output-local +/test-aot-publish diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json new file mode 100644 index 0000000..ae2321a --- /dev/null +++ b/.kilo/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".kilo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "7.2.20" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.2.20", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.20.tgz", + "integrity": "sha512-M5lMc58Mu9j1zveH+E3ZUKRHefzh+acNAqHGSG3TuF6K2l16KrZlCl38CZlgj2R5Qgaig6Jec/F2p9Rbn3BhCQ==", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.2.20", + "effect": "4.0.0-beta.48", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.99", + "@opentui/solid": ">=0.1.99" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.2.20", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.20.tgz", + "integrity": "sha512-KUpu1fyzcAyZWpiv//834zGLN+PYzIH65crs15VTtUJ9CDvGqcj08EM0XlkF9jMuGQAjHjfRbvCfml3+YO31+Q==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", + "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.kilo/plans/1776989126427-witty-island.md b/.kilo/plans/1776989126427-witty-island.md new file mode 100644 index 0000000..477cdf0 --- /dev/null +++ b/.kilo/plans/1776989126427-witty-island.md @@ -0,0 +1,171 @@ +# LanMountainDesktop 启动器无法启动应用 - 问题分析与修复计划 + +## 1. 项目架构概述 + +LanMountainDesktop 采用**双进程架构**: +- **Launcher** (`LanMountainDesktop.Launcher`) - 启动器,负责版本管理、更新、启动主程序 +- **Host** (`LanMountainDesktop`) - 主应用宿主 + +### 启动流程 +1. 用户启动 `LanMountainDesktop.Launcher.exe` +2. Launcher 扫描 `app-*` 目录,选择最佳版本 +3. 检查并应用待处理的更新 +4. 处理插件升级队列 +5. 启动主程序 `app-{version}/LanMountainDesktop.exe` +6. 通过 IPC 监控主程序启动进度 + +## 2. 问题分析 + +### 2.1 核心问题:主机可执行文件找不到 + +根据代码分析(`DeploymentLocator.cs`),启动器通过以下顺序查找主机可执行文件: + +1. **显式 app-root**(如果通过命令行指定) +2. **已发布部署**(查找 `app-*` 目录) +3. **可移植主机**(直接在应用根目录) +4. **调试主机**(开发模式,查找构建输出路径) +5. **旧版回退路径** + +**当前状态检查**: +- ❌ 未找到 `app-*` 目录(生产部署结构不存在) +- ❌ 未找到 `bin/Debug/**/*.exe`(项目未构建或构建输出不存在) + +### 2.2 可能的启动失败原因 + +| 问题 | 描述 | 优先级 | +|------|------|--------| +| **项目未构建** | LanMountainDesktop 主程序未编译,没有可执行文件 | P0 | +| **部署结构缺失** | 生产模式下缺少 `app-*` 目录结构 | P0 | +| **开发模式路径问题** | 调试模式下路径计算错误或构建输出不在预期位置 | P1 | +| **.NET 版本问题** | 项目使用 .NET 10.0,运行环境可能缺少对应运行时 | P1 | +| **更新应用失败** | `ApplyPendingUpdateAsync` 失败导致无法完成部署 | P2 | +| **IPC 连接超时** | 主程序启动后未及时建立 IPC 连接,导致启动器超时 | P2 | + +### 2.3 关键代码位置 + +- **主机查找逻辑**: `LanMountainDesktop.Launcher/Services/DeploymentLocator.cs` + - `FindCurrentDeploymentDirectory()` - 查找 app-* 目录 + - `ResolveHostExecutable()` - 解析主机路径 + +- **启动协调逻辑**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` + - `RunAsync()` - 主启动流程 + - `LaunchHostWithIpcAsync()` - 启动主机进程 + +- **更新引擎**: `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs` + - `ApplyPendingUpdateAsync()` - 应用待处理的更新 + +## 3. 诊断步骤 + +### 步骤 1:检查构建状态 +```bash +dotnet --info +dotnet build LanMountainDesktop.slnx -c Debug +``` + +### 步骤 2:验证主机可执行文件是否存在 +检查以下路径是否存在 `LanMountainDesktop.exe`: +- `LanMountainDesktop/bin/Debug/net10.0/` +- `LanMountainDesktop/bin/Release/net10.0/` + +### 步骤 3:测试直接运行主程序(跳过 Launcher) +```bash +dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj +``` + +### 步骤 4:检查 Launcher 启动日志 +在开发模式下运行 Launcher 并查看控制台输出: +```bash +dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch +``` + +## 4. 修复计划 + +### 方案 A:构建并配置开发环境(推荐) + +**适用场景**:开发或调试环境 + +1. **构建整个解决方案** + ```bash + dotnet restore + dotnet build LanMountainDesktop.slnx -c Debug + ``` + +2. **验证构建输出** + - 确认 `LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe` 存在 + - 确认 `LanMountainDesktop.Launcher/bin/Debug/net10.0/LanMountainDesktop.Launcher.exe` 存在 + +3. **测试 Launcher 启动** + ```bash + dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch + ``` + +4. **如果路径查找失败,检查 `DeploymentLocator.cs` 中的开发路径** + - 当前逻辑(第 366-375 行)查找: + - `../LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe` + - `../LanMountainDesktop/bin/Release/net10.0/LanMountainDesktop.exe` + - 确认这些路径与实际的构建输出路径匹配 + +### 方案 B:创建生产部署结构 + +**适用场景**:生产环境或模拟生产环境 + +1. **发布主程序** + ```bash + dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Release -o app-1.0.0 + ``` + +2. **创建 .current 标记文件** + ```bash + echo. > app-1.0.0/.current + ``` + +3. **从 Launcher 启动** + - Launcher 应该能找到 `app-1.0.0/LanMountainDesktop.exe` + +### 方案 C:修复潜在的代码问题 + +如果上述方案无法解决问题,可能需要修复代码: + +#### C1. 增强错误处理和日志 +在 `DeploymentLocator.cs` 中添加更详细的日志输出,帮助诊断路径查找失败的原因。 + +#### C2. 检查更新逻辑 +如果 `ApplyPendingUpdateAsync` 失败,可能导致启动中止。检查 `.launcher/update/incoming/` 目录是否有残留的更新文件。 + +#### C3. 调整超时设置 +如果主程序启动较慢,可以适当增加 `LauncherFlowCoordinator.cs` 中的超时时间: +- `StartupSoftTimeout` (当前 10 秒) +- `StartupHardTimeout` (当前 30 秒) + +## 5. 建议执行顺序 + +1. ✅ **首先执行方案 A 的步骤 1-2**(构建项目) +2. ✅ **执行诊断步骤 3**(测试直接运行主程序) +3. ✅ **执行诊断步骤 4**(查看 Launcher 启动日志) +4. 根据日志输出决定后续操作: + - 如果显示 "host executable was not found" → 检查路径配置 + - 如果显示 "update apply failed" → 清理更新缓存 + - 如果主程序启动后超时 → 检查 IPC 连接或增加超时 + +## 6. 验证方法 + +修复后,通过以下方式验证: + +```bash +# 开发模式启动 +dotnet run --project LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj -- launch + +# 或直接运行 Launcher 可执行文件 +# (需要先构建 Launcher) +``` + +启动后应该看到: +1. Splash 窗口显示 +2. 主程序桌面窗口出现 +3. Launcher 自动退出(或最小化到托盘) + +## 7. 注意事项 + +- 项目使用 .NET 10.0(`global.json` 指定版本 10.0.103) +- 确保开发环境已安装对应的 .NET SDK +- 如果修改了 `DeploymentLocator.cs` 的路径查找逻辑,需要同步更新文档 `docs/DEVELOPMENT.md` diff --git a/LanMountainDesktop.Launcher/App.axaml.cs b/LanMountainDesktop.Launcher/App.axaml.cs index eb9d0cc..8405ee3 100644 --- a/LanMountainDesktop.Launcher/App.axaml.cs +++ b/LanMountainDesktop.Launcher/App.axaml.cs @@ -18,6 +18,12 @@ public partial class App : Application { public override void Initialize() { + if (Design.IsDesignMode) + { + AvaloniaXamlLoader.Load(this); + return; + } + Logger.Initialize(); var context = LauncherRuntimeContext.Current; var execution = LauncherExecutionContext.Capture(); @@ -32,6 +38,12 @@ public partial class App : Application public override void OnFrameworkInitializationCompleted() { + if (Design.IsDesignMode) + { + base.OnFrameworkInitializationCompleted(); + return; + } + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; @@ -49,6 +61,18 @@ public partial class App : Application return; } + // 调试模式:只显示 DevDebugWindow,不走正常启动流程 + // 避免启动主程序后 Launcher 自动退出,导致开发者无法预览 UI + if (context.IsDebugMode && !context.IsPreviewCommand && + !string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) + { + Logger.Info("Debug mode active — showing DevDebugWindow instead of normal launch flow."); + var devDebugWindow = new DevDebugWindow(); + devDebugWindow.Show(); + base.OnFrameworkInitializationCompleted(); + return; + } + if (string.Equals(context.Command, "apply-update", StringComparison.OrdinalIgnoreCase)) { var updateWindow = new UpdateWindow(); @@ -119,8 +143,7 @@ public partial class App : Application private static SplashWindow CreateSplashWindow() { - var preferences = StartupVisualPreferencesResolver.Resolve(); - var window = new SplashWindow(preferences.Mode); + var window = new SplashWindow(); TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current); return window; } diff --git a/LanMountainDesktop.Launcher/AppJsonContext.cs b/LanMountainDesktop.Launcher/AppJsonContext.cs index 57e6ac7..cb8571e 100644 --- a/LanMountainDesktop.Launcher/AppJsonContext.cs +++ b/LanMountainDesktop.Launcher/AppJsonContext.cs @@ -34,7 +34,9 @@ namespace LanMountainDesktop.Launcher; [JsonSerializable(typeof(PendingUpgrade))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(OobeStateFile))] +[JsonSerializable(typeof(DataLocationConfig))] [JsonSerializable(typeof(GitHubRelease))] [JsonSerializable(typeof(GitHubAsset))] [JsonSerializable(typeof(List))] +[JsonSerializable(typeof(StartupAttemptRecord))] internal sealed partial class AppJsonContext : JsonSerializerContext; diff --git a/LanMountainDesktop.Launcher/CommandContext.cs b/LanMountainDesktop.Launcher/CommandContext.cs index 37439a0..fcad276 100644 --- a/LanMountainDesktop.Launcher/CommandContext.cs +++ b/LanMountainDesktop.Launcher/CommandContext.cs @@ -37,11 +37,25 @@ internal sealed class CommandContext /// /// 是否处于调试模式(从 Rider/VS 等 IDE 启动) - /// 仅当明确指定 --debug 参数或调试器附加时才启用 + /// 当满足以下任一条件时启用: + /// 1. 明确指定 --debug 参数 + /// 2. 调试器附加(Debugger.IsAttached) + /// 3. DOTNET_ENVIRONMENT 环境变量为 Development(IDE 调试启动时自动设置) /// public bool IsDebugMode => Options.ContainsKey("debug") || - System.Diagnostics.Debugger.IsAttached; + System.Diagnostics.Debugger.IsAttached || + IsDevelopmentEnvironment; + + /// + /// 是否为 Development 环境(DOTNET_ENVIRONMENT=Development) + /// Rider/VS 调试启动时会自动设置此环境变量 + /// + public bool IsDevelopmentEnvironment => + string.Equals( + System.Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"), + "Development", + StringComparison.OrdinalIgnoreCase); public bool IsPreviewCommand => Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase); diff --git a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj index de158af..7b5f45f 100644 --- a/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj +++ b/LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj @@ -25,6 +25,7 @@ + diff --git a/LanMountainDesktop.Launcher/Models/DataLocationModels.cs b/LanMountainDesktop.Launcher/Models/DataLocationModels.cs new file mode 100644 index 0000000..89cad9b --- /dev/null +++ b/LanMountainDesktop.Launcher/Models/DataLocationModels.cs @@ -0,0 +1,23 @@ +namespace LanMountainDesktop.Launcher.Models; + +internal enum DataLocationMode +{ + System, + Portable +} + +internal sealed class DataLocationConfig +{ + public string DataLocationMode { get; set; } = "System"; + + public string? SystemDataPath { get; set; } + + public string? PortableDataPath { get; set; } +} + +internal sealed class DataLocationPromptResult +{ + public DataLocationMode SelectedMode { get; init; } + + public bool MigrateExistingData { get; init; } +} diff --git a/LanMountainDesktop.Launcher/Program.cs b/LanMountainDesktop.Launcher/Program.cs index a5b089b..6eeb837 100644 --- a/LanMountainDesktop.Launcher/Program.cs +++ b/LanMountainDesktop.Launcher/Program.cs @@ -4,10 +4,10 @@ using LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher; -internal static class Program +public static class Program { [STAThread] - private static async Task Main(string[] args) + public static async Task Main(string[] args) { var commandContext = CommandContext.FromArgs(args); var execution = LauncherExecutionContext.Capture(); @@ -66,7 +66,7 @@ internal static class Program } } - private static AppBuilder BuildAvaloniaApp() + public static AppBuilder BuildAvaloniaApp() { return AppBuilder.Configure() .UsePlatformDetect() diff --git a/LanMountainDesktop.Launcher/Properties/launchSettings.json b/LanMountainDesktop.Launcher/Properties/launchSettings.json index 7a8f3ab..f8c1f60 100644 --- a/LanMountainDesktop.Launcher/Properties/launchSettings.json +++ b/LanMountainDesktop.Launcher/Properties/launchSettings.json @@ -1,6 +1,14 @@ { "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { + "Launcher (Debug Mode)": { + "commandName": "Project", + "commandLineArgs": "launch --debug", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, "Launcher (Launch Mode)": { "commandName": "Project", "commandLineArgs": "launch", @@ -9,6 +17,46 @@ "DOTNET_ENVIRONMENT": "Development" } }, + "Launcher (Preview Debug Window)": { + "commandName": "Project", + "commandLineArgs": "preview-debug", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Launcher (Preview Splash)": { + "commandName": "Project", + "commandLineArgs": "preview-splash", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Launcher (Preview Error)": { + "commandName": "Project", + "commandLineArgs": "preview-error", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Launcher (Preview Update)": { + "commandName": "Project", + "commandLineArgs": "preview-update", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, + "Launcher (Preview OOBE)": { + "commandName": "Project", + "commandLineArgs": "preview-oobe", + "workingDirectory": "$(SolutionDir)", + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + }, "Launcher (Update Check)": { "commandName": "Project", "commandLineArgs": "update check", diff --git a/LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs b/LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs new file mode 100644 index 0000000..67e40a2 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DataLocationOobeStep.cs @@ -0,0 +1,67 @@ +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Views; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class DataLocationOobeStep : IOobeStep +{ + private readonly DataLocationResolver _resolver; + + public DataLocationOobeStep(DataLocationResolver resolver) + { + _resolver = resolver; + } + + public async Task RunAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var existingConfig = _resolver.LoadConfig(); + if (existingConfig is not null) + { + Logger.Info("DataLocation OOBE step skipped: config already exists."); + return; + } + + DataLocationPromptWindow? window = null; + await Dispatcher.UIThread.InvokeAsync(() => + { + window = new DataLocationPromptWindow(_resolver); + window.Show(); + }); + + if (window is null) + { + Logger.Warn("DataLocation OOBE step failed: window could not be created."); + return; + } + + try + { + var result = await window.WaitForChoiceAsync().ConfigureAwait(false); + if (result is null) + { + Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location."); + _resolver.ApplyLocationChoice(DataLocationMode.System, null, false); + } + else + { + var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData); + Logger.Info( + $"DataLocation OOBE step: user selected '{result.SelectedMode}'. " + + $"Migrate={result.MigrateExistingData}; Success={success}."); + } + } + finally + { + await Dispatcher.UIThread.InvokeAsync(() => + { + if (window.IsVisible) + { + window.Close(); + } + }); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs b/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs new file mode 100644 index 0000000..860b884 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/DataLocationResolver.cs @@ -0,0 +1,270 @@ +using System.Text.Json; +using LanMountainDesktop.Launcher.Models; + +namespace LanMountainDesktop.Launcher.Services; + +internal sealed class DataLocationResolver +{ + private const string ConfigFileName = "data-location.config.json"; + private const string LauncherFolderName = "Launcher"; + private const string DesktopFolderName = "Desktop"; + + private readonly string _appRoot; + private readonly string _defaultSystemDataPath; + + public DataLocationResolver(string appRoot) + { + _appRoot = Path.GetFullPath(appRoot); + _defaultSystemDataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop"); + } + + public string AppRoot => _appRoot; + + /// + /// 默认系统数据路径(用户目录) + /// + public string DefaultSystemDataPath => _defaultSystemDataPath; + + /// + /// 默认便携模式数据路径(应用目录下的 AppData) + /// + public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData"); + + /// + /// 检查是否允许便携模式(应用目录是否可写) + /// + public bool IsPortableModeAllowed() + { + try + { + var testFile = Path.Combine(_appRoot, $".write-test-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(testFile, string.Empty); + File.Delete(testFile); + return true; + } + catch + { + return false; + } + } + + /// + /// 解析数据根目录(用户选择的位置) + /// + public string ResolveDataRoot() + { + var config = LoadConfig(); + if (config is null) + { + return _defaultSystemDataPath; + } + + if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase)) + { + var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath) + ? config.PortableDataPath + : _defaultSystemDataPath; + return Path.GetFullPath(portablePath); + } + + return !string.IsNullOrWhiteSpace(config.SystemDataPath) + ? Path.GetFullPath(config.SystemDataPath) + : _defaultSystemDataPath; + } + + /// + /// 启动器数据目录(日志、配置、状态等) + /// + public string ResolveLauncherDataPath() + { + return Path.Combine(ResolveDataRoot(), LauncherFolderName); + } + + /// + /// 桌面应用数据目录(组件、设置、插件等) + /// + public string ResolveDesktopDataPath() + { + return Path.Combine(ResolveDataRoot(), DesktopFolderName); + } + + /// + /// 数据位置配置文件路径(保存在 Launcher 目录下) + /// + public string ResolveConfigPath() + { + return Path.Combine(ResolveLauncherDataPath(), ConfigFileName); + } + + /// + /// 启动器日志目录 + /// + public string ResolveLauncherLogsPath() + { + return Path.Combine(ResolveLauncherDataPath(), "logs"); + } + + /// + /// 启动器状态目录 + /// + public string ResolveLauncherStatePath() + { + return Path.Combine(ResolveLauncherDataPath(), "state"); + } + + public DataLocationMode ResolveMode() + { + var config = LoadConfig(); + if (config is null) + { + return DataLocationMode.System; + } + + return string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase) + ? DataLocationMode.Portable + : DataLocationMode.System; + } + + public DataLocationConfig? LoadConfig() + { + try + { + var configPath = ResolveConfigPath(); + if (!File.Exists(configPath)) + { + return null; + } + + var json = File.ReadAllText(configPath); + return JsonSerializer.Deserialize(json, AppJsonContext.Default.DataLocationConfig); + } + catch (Exception ex) + { + Logger.Warn($"Failed to load data location config. Error='{ex.Message}'."); + return null; + } + } + + public bool SaveConfig(DataLocationConfig config) + { + try + { + var launcherPath = ResolveLauncherDataPath(); + Directory.CreateDirectory(launcherPath); + + var configPath = ResolveConfigPath(); + var json = JsonSerializer.Serialize(config, AppJsonContext.Default.DataLocationConfig); + File.WriteAllText(configPath, json); + return true; + } + catch (Exception ex) + { + Logger.Warn($"Failed to save data location config. Error='{ex.Message}'."); + return false; + } + } + + public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false) + { + var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath) + ? Path.GetFullPath(customPath) + : _defaultSystemDataPath; + + var config = new DataLocationConfig + { + DataLocationMode = mode.ToString(), + SystemDataPath = _defaultSystemDataPath, + PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null + }; + + // 先创建目录结构 + try + { + Directory.CreateDirectory(ResolveLauncherDataPath()); + Directory.CreateDirectory(ResolveDesktopDataPath()); + } + catch (Exception ex) + { + Logger.Warn($"Failed to create data directories. Error='{ex.Message}'."); + return false; + } + + // 保存配置 + if (!SaveConfig(config)) + { + return false; + } + + if (migrateExistingData && mode == DataLocationMode.Portable) + { + MigrateSystemDataToPortable(targetDataRoot); + } + + return true; + } + + public bool HasExistingSystemData() + { + var desktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName); + if (!Directory.Exists(desktopPath)) + { + return false; + } + + var markerFiles = new[] + { + Path.Combine(desktopPath, "settings.json"), + Path.Combine(desktopPath, "component-state.db"), + Path.Combine(desktopPath, "app.db") + }; + + return markerFiles.Any(File.Exists); + } + + private void MigrateSystemDataToPortable(string targetDataRoot) + { + if (!HasExistingSystemData()) + { + return; + } + + var sourceDesktopPath = Path.Combine(_defaultSystemDataPath, DesktopFolderName); + var targetDesktopPath = Path.Combine(targetDataRoot, DesktopFolderName); + + try + { + Directory.CreateDirectory(targetDesktopPath); + + // 迁移桌面数据 + if (Directory.Exists(sourceDesktopPath)) + { + CopyDirectory(sourceDesktopPath, targetDesktopPath); + } + + Logger.Info($"Data migration completed. Target='{targetDataRoot}'."); + } + catch (Exception ex) + { + Logger.Warn($"Data migration failed. Target='{targetDataRoot}'. Error='{ex.Message}'."); + } + } + + private static void CopyDirectory(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + + foreach (var file in Directory.GetFiles(sourceDir)) + { + var destFile = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, destFile, overwrite: true); + } + + foreach (var subDir in Directory.GetDirectories(sourceDir)) + { + var destSubDir = Path.Combine(destDir, Path.GetFileName(subDir)); + CopyDirectory(subDir, destSubDir); + } + } +} diff --git a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs index 81e041b..7ba3430 100644 --- a/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs +++ b/LanMountainDesktop.Launcher/Services/DeploymentLocator.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System.Globalization; using System.Text.Json; using LanMountainDesktop.Launcher.Models; using LanMountainDesktop.Shared.Contracts.Launcher; @@ -360,51 +360,59 @@ internal sealed class DeploymentLocator /// private static string? ScanDevelopmentPaths(string executable) { + var solutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..")); + var possiblePaths = new[] { - // 浠?Launcher 椤圭洰杩愯 + // 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目 + Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable), + Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable), + + // 向后兼容 Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), Path.Combine(AppContext.BaseDirectory, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), - // 浠庤В鍐虫柟妗堟牴鐩綍杩愯 - Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), - Path.Combine(AppContext.BaseDirectory, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), - - // dev-test 鐩綍 - Path.Combine(AppContext.BaseDirectory, "..", "dev-test", "app-1.0.0-dev", executable), + // dev-test 目录 + Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable), }; foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct()) { + Logger.Info($"Scanning development path: {path}"); if (File.Exists(path)) { + Logger.Info($"Found host at: {path}"); return path; } } return null; - } - + } + /// - /// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// + /// 鑾峰彇寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忚経 + /// private static IEnumerable GetDevelopmentPaths(string executable) { var launcherDir = AppContext.BaseDirectory; + // 计算解决方案根目录:从 LanMountainDesktop.Launcher\bin\Debug\net10.0\ 向上4级 + var solutionRoot = Path.GetFullPath(Path.Combine(launcherDir, "..", "..", "..", "..")); + var possiblePaths = new[] { - // 浠?Launcher 椤圭洰杩愯锛?.\LanMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe + // 标准开发路径:解决方案根目录下的 LanMountainDesktop 项目 + Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Debug", "net10.0", executable), + Path.Combine(solutionRoot, "LanMountainDesktop", "bin", "Release", "net10.0", executable), + + // 向后兼容:如果 Launcher 在特殊目录结构中 Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), - // 浠庤В鍐虫柟妗堟牴鐩綍杩愯锛歀anMountainDesktop\bin\Debug\net10.0\LanMountainDesktop.exe - Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", executable), - Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", executable), - - // 浠?dev-test 鐩綍杩愯 - Path.Combine(launcherDir, "..", "dev-test", "app-1.0.0-dev", executable), + // dev-test 目录 + Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable), }; - + return possiblePaths.Select(Path.GetFullPath).Distinct(); } @@ -489,7 +497,8 @@ internal sealed class DeploymentLocator } // 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級 - var snapshotDir = Path.Combine(_appRoot, ".launcher", "snapshots"); + var resolver = new DataLocationResolver(_appRoot); + var snapshotDir = Path.Combine(resolver.ResolveLauncherDataPath(), "snapshots"); if (Directory.Exists(snapshotDir)) { try diff --git a/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs b/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs index 2b7d7d7..ce01a88 100644 --- a/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs +++ b/LanMountainDesktop.Launcher/Services/HostLaunchPlan.cs @@ -12,10 +12,12 @@ internal sealed record HostLaunchPlan( internal static class HostLaunchPlanBuilder { + public const string DataRootOptionName = "data-root"; + private static readonly string[] LauncherOnlyOptions = [ "debug", "show-loading-details", "plugins-dir", "source", "result", - "app-root", + "app-root", DataRootOptionName, LauncherIpcConstants.LauncherPidEnvVar, LauncherIpcConstants.PackageRootEnvVar, LauncherIpcConstants.VersionEnvVar, @@ -25,7 +27,8 @@ internal static class HostLaunchPlanBuilder public static HostLaunchPlan Build( CommandContext context, DeploymentLocator deploymentLocator, - HostResolutionResult resolution) + HostResolutionResult resolution, + string? dataRoot = null) { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(deploymentLocator); @@ -39,7 +42,7 @@ internal static class HostLaunchPlanBuilder var hostPath = Path.GetFullPath(resolution.ResolvedHostPath); var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource); var versionInfo = deploymentLocator.GetVersionInfo(); - var arguments = BuildForwardedArguments(context, packageRoot, versionInfo); + var arguments = BuildForwardedArguments(context, packageRoot, versionInfo, dataRoot); var environment = new Dictionary(StringComparer.OrdinalIgnoreCase) { [LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(), @@ -48,6 +51,11 @@ internal static class HostLaunchPlanBuilder [LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename }; + if (!string.IsNullOrWhiteSpace(dataRoot)) + { + environment["LMD_DATA_ROOT"] = dataRoot; + } + return new HostLaunchPlan( hostPath, packageRoot, @@ -92,7 +100,8 @@ internal static class HostLaunchPlanBuilder private static IReadOnlyList BuildForwardedArguments( CommandContext context, string packageRoot, - AppVersionInfo versionInfo) + AppVersionInfo versionInfo, + string? dataRoot = null) { var arguments = new List(); @@ -144,6 +153,11 @@ internal static class HostLaunchPlanBuilder arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}"); arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}"); + if (!string.IsNullOrWhiteSpace(dataRoot)) + { + arguments.Add($"--{DataRootOptionName}={dataRoot}"); + } + return arguments; } diff --git a/LanMountainDesktop.Launcher/Services/LauncherBackgroundService.cs b/LanMountainDesktop.Launcher/Services/LauncherBackgroundService.cs new file mode 100644 index 0000000..d39f4ad --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/LauncherBackgroundService.cs @@ -0,0 +1,174 @@ +using Avalonia.Media.Imaging; + +namespace LanMountainDesktop.Launcher.Services; + +/// +/// 启动器背景图片服务 +/// +internal static class LauncherBackgroundService +{ + private const string PictureFileName = "Launcher Picture"; + private const long MaxFileSize = 10 * 1024 * 1024; // 10MB + private const double WindowAspectRatio = 7.0 / 5.0; // 700:500 + private const double AspectRatioTolerance = 0.15; // 15% 误差 + + private static Bitmap? _cachedBitmap; + private static string? _cachedPath; + + /// + /// 背景图片信息 + /// + public record BackgroundImageInfo + { + public required bool Exists { get; init; } + public required bool IsValid { get; init; } + public string? FilePath { get; init; } + public Bitmap? Bitmap { get; init; } + public int Width { get; init; } + public int Height { get; init; } + public double AspectRatio { get; init; } + public string? ErrorMessage { get; init; } + } + + /// + /// 加载背景图片 + /// + public static BackgroundImageInfo LoadBackgroundImage() + { + try + { + var resolver = new DataLocationResolver(AppContext.BaseDirectory); + var launcherPath = resolver.ResolveLauncherDataPath(); + + // 查找图片文件 + var imagePath = FindImageFile(launcherPath); + if (imagePath == null) + { + return new BackgroundImageInfo + { + Exists = false, + IsValid = false, + ErrorMessage = "未找到背景图片文件" + }; + } + + // 检查文件大小 + var fileInfo = new FileInfo(imagePath); + if (fileInfo.Length > MaxFileSize) + { + return new BackgroundImageInfo + { + Exists = true, + IsValid = false, + FilePath = imagePath, + ErrorMessage = $"图片文件过大 ({fileInfo.Length / 1024 / 1024}MB > 10MB)" + }; + } + + // 使用缓存 + if (_cachedBitmap != null && _cachedPath == imagePath) + { + return new BackgroundImageInfo + { + Exists = true, + IsValid = true, + FilePath = imagePath, + Bitmap = _cachedBitmap, + Width = _cachedBitmap.PixelSize.Width, + Height = _cachedBitmap.PixelSize.Height, + AspectRatio = (double)_cachedBitmap.PixelSize.Width / _cachedBitmap.PixelSize.Height + }; + } + + // 加载图片 + var bitmap = new Bitmap(imagePath); + var width = bitmap.PixelSize.Width; + var height = bitmap.PixelSize.Height; + var aspectRatio = (double)width / height; + + // 校验比例 + var ratioDiff = Math.Abs(aspectRatio - WindowAspectRatio) / WindowAspectRatio; + if (ratioDiff > AspectRatioTolerance) + { + bitmap.Dispose(); + return new BackgroundImageInfo + { + Exists = true, + IsValid = false, + FilePath = imagePath, + Width = width, + Height = height, + AspectRatio = aspectRatio, + ErrorMessage = $"图片比例不符合要求 ({aspectRatio:F2},需要接近 {WindowAspectRatio:F2})" + }; + } + + // 缓存图片 + _cachedBitmap = bitmap; + _cachedPath = imagePath; + + Logger.Info($"[LauncherBackground] 背景图片加载成功: {imagePath} ({width}x{height}, 比例: {aspectRatio:F2})"); + + return new BackgroundImageInfo + { + Exists = true, + IsValid = true, + FilePath = imagePath, + Bitmap = bitmap, + Width = width, + Height = height, + AspectRatio = aspectRatio + }; + } + catch (Exception ex) + { + Logger.Warn($"[LauncherBackground] 加载背景图片失败: {ex.Message}"); + return new BackgroundImageInfo + { + Exists = false, + IsValid = false, + ErrorMessage = $"加载失败: {ex.Message}" + }; + } + } + + /// + /// 查找图片文件 + /// + private static string? FindImageFile(string directory) + { + var extensions = new[] { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp" }; + + foreach (var ext in extensions) + { + var path = Path.Combine(directory, PictureFileName + ext); + if (File.Exists(path)) + { + return path; + } + } + + // 也尝试不带扩展名的匹配(如果文件本身就有扩展名) + var files = Directory.GetFiles(directory, PictureFileName + ".*"); + foreach (var file in files) + { + var ext = Path.GetExtension(file).ToLowerInvariant(); + if (extensions.Contains(ext)) + { + return file; + } + } + + return null; + } + + /// + /// 清除缓存 + /// + public static void ClearCache() + { + _cachedBitmap?.Dispose(); + _cachedBitmap = null; + _cachedPath = null; + } +} diff --git a/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs b/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs index 5cb0bed..9ed4cad 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherDebugSettingsStore.cs @@ -100,12 +100,22 @@ internal static class LauncherDebugSettingsStore private static string ResolveConfigBaseDirectory() { + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + return resolver.ResolveLauncherDataPath(); + } + catch + { + } + try { var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (!string.IsNullOrWhiteSpace(appData)) { - return Path.Combine(appData, "LanMountainDesktop", ".launcher"); + return Path.Combine(appData, "LanMountainDesktop", "Launcher"); } } catch @@ -114,11 +124,11 @@ internal static class LauncherDebugSettingsStore try { - return Path.Combine(AppContext.BaseDirectory, ".launcher"); + return Path.Combine(AppContext.BaseDirectory, "Launcher"); } catch { - return Path.Combine(Directory.GetCurrentDirectory(), ".launcher"); + return Path.Combine(Directory.GetCurrentDirectory(), "Launcher"); } } } diff --git a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs index 1ed1499..c598042 100644 --- a/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs +++ b/LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs @@ -23,6 +23,7 @@ internal sealed class LauncherFlowCoordinator private readonly PluginInstallerService _pluginInstallerService; private readonly StartupAttemptRegistry _startupAttemptRegistry; private readonly LauncherCoordinatorIpcServer? _coordinatorIpcServer; + private readonly DataLocationResolver _dataLocationResolver; private readonly IReadOnlyList _oobeSteps; public LauncherFlowCoordinator( @@ -41,7 +42,12 @@ internal sealed class LauncherFlowCoordinator _pluginInstallerService = pluginInstallerService; _startupAttemptRegistry = startupAttemptRegistry ?? new StartupAttemptRegistry(); _coordinatorIpcServer = coordinatorIpcServer; - _oobeSteps = [new WelcomeOobeStep(_oobeStateService, _context)]; + _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); + _oobeSteps = + [ + new WelcomeOobeStep(_oobeStateService, _context), + new DataLocationOobeStep(_dataLocationResolver) + ]; } public static string ResolveSuccessPolicyKey(CommandContext context) @@ -270,7 +276,18 @@ internal sealed class LauncherFlowCoordinator var updateResult = await _updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false); if (!updateResult.Success) { - return WithAdditionalDetails(updateResult, launcherContextDetails); + Logger.Warn($"Update apply failed, will try to launch existing version. Error='{updateResult.Message}'."); + reporter.Report("update", "Update failed, launching existing version..."); + // Clean up corrupted update files to prevent repeated failures + try + { + _updateEngine.CleanupIncomingArtifacts(); + } + catch (Exception ex) + { + Logger.Warn($"Failed to cleanup update artifacts after failed update: {ex.Message}"); + } + // Continue to launch existing version instead of aborting } reporter.Report("plugins", "Applying plugin upgrades..."); @@ -278,7 +295,8 @@ internal sealed class LauncherFlowCoordinator var queueResult = new PluginUpgradeQueueService(_pluginInstallerService).ApplyPendingUpgrades(pluginsDir); if (!queueResult.Success) { - return WithAdditionalDetails(queueResult, launcherContextDetails); + Logger.Warn($"Plugin upgrade failed, continuing startup. Error='{queueResult.Message}'."); + reporter.Report("plugins", "Plugin upgrade failed, continuing..."); } if (oobeDecision.ShouldShowOobe) @@ -943,7 +961,7 @@ internal sealed class LauncherFlowCoordinator { try { - await splashWindow.DismissAsync().ConfigureAwait(false); + await Dispatcher.UIThread.InvokeAsync(() => splashWindow.DismissAsync()); } catch (Exception ex) { @@ -1013,7 +1031,8 @@ internal sealed class LauncherFlowCoordinator bool forceDirectMode, string? retryTag) { - var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution); + var dataRoot = _dataLocationResolver.ResolveDataRoot(); + var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot); var hostPath = plan.HostPath; if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) { diff --git a/LanMountainDesktop.Launcher/Services/Logger.cs b/LanMountainDesktop.Launcher/Services/Logger.cs index 5d60c39..a76e987 100644 --- a/LanMountainDesktop.Launcher/Services/Logger.cs +++ b/LanMountainDesktop.Launcher/Services/Logger.cs @@ -53,12 +53,22 @@ internal static class Logger /// private static string? GetLogDirectory() { + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + return resolver.ResolveLauncherLogsPath(); + } + catch + { + } + try { var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); if (!string.IsNullOrEmpty(appData)) { - return Path.Combine(appData, "LanMountainDesktop", ".launcher", "logs"); + return Path.Combine(appData, "LanMountainDesktop", "Launcher", "logs"); } } catch @@ -68,7 +78,7 @@ internal static class Logger try { var launcherDir = AppContext.BaseDirectory; - return Path.Combine(launcherDir, ".launcher", "logs"); + return Path.Combine(launcherDir, "Launcher", "logs"); } catch { diff --git a/LanMountainDesktop.Launcher/Services/OobeStateService.cs b/LanMountainDesktop.Launcher/Services/OobeStateService.cs index 6903ba2..9ecca79 100644 --- a/LanMountainDesktop.Launcher/Services/OobeStateService.cs +++ b/LanMountainDesktop.Launcher/Services/OobeStateService.cs @@ -21,9 +21,9 @@ internal sealed class OobeStateService _executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture(); var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride) - ? GetDefaultStateRoot() + ? ResolveStateRoot(appRoot) : Path.GetFullPath(stateRootOverride); - _stateDirectory = Path.Combine(stateRoot, ".launcher", "state"); + _stateDirectory = Path.Combine(stateRoot, "Launcher", "state"); _statePath = Path.Combine(_stateDirectory, "oobe-state.json"); _legacyMarkerPath = Path.Combine(_stateDirectory, "first_run_completed"); } @@ -208,14 +208,22 @@ internal sealed class OobeStateService }; } - private static string GetDefaultStateRoot() + private static string ResolveStateRoot(string appRoot) { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrWhiteSpace(appData)) + try { - throw new InvalidOperationException("LocalApplicationData is unavailable."); + var resolver = new DataLocationResolver(appRoot); + return resolver.ResolveDataRoot(); } + catch + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(appData)) + { + throw new InvalidOperationException("LocalApplicationData is unavailable."); + } - return Path.Combine(appData, "LanMountainDesktop"); + return Path.Combine(appData, "LanMountainDesktop"); + } } } diff --git a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs index 905fe97..60beff7 100644 --- a/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs +++ b/LanMountainDesktop.Launcher/Services/PluginInstallerService.cs @@ -63,13 +63,28 @@ internal sealed class PluginInstallerService return null; } - var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - if (string.IsNullOrWhiteSpace(localAppData)) + string? allowedRoot = null; + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot()); + } + catch { - return null; } - var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop")); + if (string.IsNullOrWhiteSpace(allowedRoot)) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrWhiteSpace(localAppData)) + { + return null; + } + + allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop")); + } + var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory)); if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase)) { diff --git a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs index 8037cd4..c9ebd30 100644 --- a/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs +++ b/LanMountainDesktop.Launcher/Services/StartupAttemptRegistry.cs @@ -10,25 +10,35 @@ namespace LanMountainDesktop.Launcher.Services; internal sealed class StartupAttemptRegistry { private static readonly TimeSpan CoordinatorHeartbeatTimeout = TimeSpan.FromSeconds(10); - private static readonly JsonSerializerOptions SerializerOptions = new() - { - WriteIndented = true - }; private readonly string _statePath; private readonly string _mutexName; private string? _ownedAttemptId; public StartupAttemptRegistry() - : this(Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "LanMountainDesktop", - ".launcher", - "state", - "startup-attempt.json")) + : this(ResolveDefaultStatePath()) { } + private static string ResolveDefaultStatePath() + { + try + { + var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); + var resolver = new DataLocationResolver(appRoot); + return Path.Combine(resolver.ResolveLauncherStatePath(), "startup-attempt.json"); + } + catch + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + "Launcher", + "state", + "startup-attempt.json"); + } + } + internal StartupAttemptRegistry(string statePath) { _statePath = statePath; @@ -415,7 +425,7 @@ internal sealed class StartupAttemptRegistry try { var json = File.ReadAllText(_statePath); - return JsonSerializer.Deserialize(json, SerializerOptions); + return JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupAttemptRecord); } catch { @@ -431,7 +441,7 @@ internal sealed class StartupAttemptRegistry Directory.CreateDirectory(directory); } - File.WriteAllText(_statePath, JsonSerializer.Serialize(record, SerializerOptions)); + File.WriteAllText(_statePath, JsonSerializer.Serialize(record, AppJsonContext.Default.StartupAttemptRecord)); } private static bool IsAttachable(StartupAttemptRecord record) diff --git a/LanMountainDesktop.Launcher/Services/ThemeService.cs b/LanMountainDesktop.Launcher/Services/ThemeService.cs new file mode 100644 index 0000000..d5818e3 --- /dev/null +++ b/LanMountainDesktop.Launcher/Services/ThemeService.cs @@ -0,0 +1,68 @@ +using Avalonia; +using Avalonia.Styling; +using FluentAvalonia.Styling; + +namespace LanMountainDesktop.Launcher.Services; + +/// +/// 主题服务,管理启动器的主题设置 +/// +public static class ThemeService +{ + private static ThemeVariant _currentTheme = ThemeVariant.Light; + private static string _accentColor = "#0078D4"; + + /// + /// 获取当前主题 + /// + public static ThemeVariant CurrentTheme => _currentTheme; + + /// + /// 获取当前主题色 + /// + public static string AccentColor => _accentColor; + + /// + /// 应用主题设置 + /// + public static void ApplyTheme(ThemeMode mode, string accentColor) + { + _currentTheme = mode switch + { + ThemeMode.Dark => ThemeVariant.Dark, + _ => ThemeVariant.Light + }; + _accentColor = accentColor; + + // 应用到当前应用程序 + if (Application.Current is { } app) + { + app.RequestedThemeVariant = _currentTheme; + } + } + + /// + /// 应用浅色主题 + /// + public static void ApplyLightTheme(string accentColor) + { + ApplyTheme(ThemeMode.Light, accentColor); + } + + /// + /// 应用深色主题 + /// + public static void ApplyDarkTheme(string accentColor) + { + ApplyTheme(ThemeMode.Dark, accentColor); + } +} + +/// +/// 主题模式 +/// +public enum ThemeMode +{ + Light, + Dark +} diff --git a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs index 04a86b3..727a117 100644 --- a/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs +++ b/LanMountainDesktop.Launcher/Services/UpdateEngineService.cs @@ -7,7 +7,6 @@ namespace LanMountainDesktop.Launcher.Services; internal sealed class UpdateEngineService { - private const string LauncherDirectoryName = ".launcher"; private const string UpdateDirectoryName = "update"; private const string IncomingDirectoryName = "incoming"; private const string SnapshotsDirectoryName = "snapshots"; @@ -30,7 +29,8 @@ internal sealed class UpdateEngineService { _deploymentLocator = deploymentLocator; _appRoot = deploymentLocator.GetAppRoot(); - _launcherRoot = Path.Combine(_appRoot, LauncherDirectoryName); + var resolver = new DataLocationResolver(_appRoot); + _launcherRoot = resolver.ResolveLauncherDataPath(); _incomingRoot = Path.Combine(_launcherRoot, UpdateDirectoryName, IncomingDirectoryName); _snapshotsRoot = Path.Combine(_launcherRoot, SnapshotsDirectoryName); } @@ -1458,7 +1458,7 @@ internal sealed class UpdateEngineService } } - private void CleanupIncomingArtifacts() + internal void CleanupIncomingArtifacts() { foreach (var path in new[] { diff --git a/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs b/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs index e7a0239..b149e5a 100644 --- a/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs +++ b/LanMountainDesktop.Launcher/ViewModels/DevDebugWindowViewModel.cs @@ -13,6 +13,7 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged private bool _isErrorEnabled = true; private bool _isUpdateEnabled = true; private bool _isOobeEnabled = true; + private bool _isDataLocationEnabled = true; private string _statusMessage = "就绪"; public event PropertyChangedEventHandler? PropertyChanged; @@ -87,6 +88,23 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged } } + /// + /// 数据位置选择页面是否启用实际功能 + /// + public bool IsDataLocationEnabled + { + get => _isDataLocationEnabled; + set + { + if (_isDataLocationEnabled != value) + { + _isDataLocationEnabled = value; + OnPropertyChanged(); + UpdateStatus($"数据位置选择: {(value ? "功能模式" : "仅查看")}"); + } + } + } + #endregion #region 状态信息 @@ -131,6 +149,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged /// public ICommand OpenOobeCommand { get; } + /// + /// 打开数据位置选择页面命令 + /// + public ICommand OpenDataLocationCommand { get; } + /// /// 全部切换到查看模式命令 /// @@ -170,6 +193,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged /// public event EventHandler? OpenOobeRequested; + /// + /// 请求打开数据位置选择页面 + /// + public event EventHandler? OpenDataLocationRequested; + /// /// 请求关闭窗口 /// @@ -199,12 +227,18 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged OpenOobeRequested?.Invoke(this, new OobeOpenEventArgs(IsOobeEnabled)); }); + OpenDataLocationCommand = new RelayCommand(() => + { + OpenDataLocationRequested?.Invoke(this, new DataLocationOpenEventArgs(IsDataLocationEnabled)); + }); + SetAllViewOnlyCommand = new RelayCommand(() => { IsSplashEnabled = false; IsErrorEnabled = false; IsUpdateEnabled = false; IsOobeEnabled = false; + IsDataLocationEnabled = false; UpdateStatus("全部页面已切换到查看模式"); }); @@ -214,6 +248,7 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged IsErrorEnabled = true; IsUpdateEnabled = true; IsOobeEnabled = true; + IsDataLocationEnabled = true; UpdateStatus("全部页面已切换到功能模式"); }); @@ -260,4 +295,10 @@ public class OobeOpenEventArgs : EventArgs public OobeOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional; } +public class DataLocationOpenEventArgs : EventArgs +{ + public bool IsFunctional { get; } + public DataLocationOpenEventArgs(bool isFunctional) => IsFunctional = isFunctional; +} + #endregion diff --git a/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml new file mode 100644 index 0000000..386cba2 --- /dev/null +++ b/LanMountainDesktop.Launcher/Views/DataLocationPromptWindow.axaml @@ -0,0 +1,153 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs index 0c1e17a..8fc5eae 100644 --- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs +++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs @@ -1,182 +1,709 @@ using Avalonia; using Avalonia.Animation; -using Avalonia.Animation.Easings; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Markup.Xaml; using Avalonia.Media; -using Avalonia.Styling; +using Avalonia.Threading; +using LanMountainDesktop.Launcher.Models; +using LanMountainDesktop.Launcher.Services; namespace LanMountainDesktop.Launcher.Views; public partial class OobeWindow : Window { + private const int AnimationDurationMs = 300; + private const int TypingDelayMs = 100; + private readonly TaskCompletionSource _completionSource = new(); + private readonly DataLocationResolver _resolver; private bool _isTransitioning; + private bool _isDebugMode; + private int _currentStep = 1; + + // 数据位置选择 + private DataLocationMode _selectedDataLocationMode = DataLocationMode.System; + private bool _migrateExistingData; + + // 主题选择 + private Services.ThemeMode _selectedThemeMode = Services.ThemeMode.Light; + private string _selectedAccentColor = "#0078D4"; + private MonetSource _selectedMonetSource = MonetSource.Wallpaper; public OobeWindow() { AvaloniaXamlLoader.Load(this); Loaded += OnWindowLoaded; Opened += OnWindowOpened; + + var appRoot = AppDomain.CurrentDomain.BaseDirectory; + _resolver = new DataLocationResolver(appRoot); } + public void SetDebugMode(bool isDebugMode) + { + _isDebugMode = isDebugMode; + } + + public Task WaitForEnterAsync() => _completionSource.Task; + private void OnWindowLoaded(object? sender, RoutedEventArgs e) { - Console.WriteLine("[OobeWindow] Window loaded, initializing components..."); + InitializeDataLocationStep(); + SetupEventHandlers(); + } - var enterButton = this.FindControl