Launcher fix (#6)

* fix.hy3试图修复中

* Resolve dev paths and fix splash UI thread

Compute a solutionRoot and expand development search paths (LanMountainDesktop and dev-test) in DeploymentLocator, add logging when scanning/finding hosts, and return distinct full paths. Ensure backward-compatible path checks. Fix cross-thread UI calls: invoke splashWindow.DismissAsync on the UI thread in LauncherFlowCoordinator, and make SplashWindow.DismissAsync ensure it runs on the UI thread before closing (simplified Close call). These changes improve development host discovery and prevent UI-thread access issues during shutdown.

* Add configurable data location (portable/system)

Introduce support for choosing and resolving the application's data root (system user dir vs. portable app folder). Adds DataLocationConfig model, DataLocationResolver (load/save/resolve/migrate), a UI prompt (DataLocationPromptWindow) and an OOBE step (DataLocationOobeStep) to let users pick and optionally migrate existing data. Wire the chosen data root into the launcher flow and host launch plan (forwarded via --data-root and LMD_DATA_ROOT), and add AppDataPathProvider to let runtime services read the effective data root (initialized in Program.Main). Update various services (logging, settings, DB, plugin/market, startup registry, etc.) to use the new provider/resolver and register the config type in the JSON context. This enables portable installs, safe migration, and runtime overrides via CLI or environment variable.

* Add dev/debug startup flow and launch profiles

Handle design-time initialization and add a developer debug startup path: App now skips normal startup when in design mode and shows a DevDebugWindow when running in debug (unless a preview or apply-update command). CommandContext.IsDebugMode is extended to include DOTNET_ENVIRONMENT=Development via a new IsDevelopmentEnvironment helper. Program.Main and BuildAvaloniaApp are made public to aid tooling. Added multiple launchSettings profiles for debug and preview commands that set DOTNET_ENVIRONMENT=Development to simplify IDE debugging and UI previewing.

* Simplify splash to fade; add themed about banners

Simplify splash startup visuals by removing the multi-mode/slide behavior and always using a fade animation. Update App to create SplashWindow without a StartupVisualMode parameter and remove related fields, layout configuration, slide animation, and easing helpers from SplashWindow. Clean up unused using. Replace the single about_banner asset with theme-aware variants (about_banner_dark.png and about_banner_light.png), delete the old about_banner.png, and update AboutSettingsPage to use a DynamicResource ImageBrush (AboutBannerBrush) that selects the appropriate banner per theme.

* Use AppJsonContext for startup state serialization

Switch serialization to the source-generated System.Text.Json context: add JsonSerializable(typeof(StartupAttemptRecord)) to AppJsonContext and replace the previous JsonSerializerOptions-based Serialize/Deserialize calls with AppJsonContext.Default.StartupAttemptRecord. Also remove the now-unused SerializerOptions field. Additionally, update .gitignore to exclude /test-aot-publish.

* Add OOBE redesign, theme & data location support

Introduce a redesigned OOBE flow and data-location/theme support across the launcher. Adds a new ThemeService for applying light/dark and accent colors; integrates FluentIcons.Avalonia package for icons. Overhauls OobeWindow (UX animations, typing effect, multi-step theme and data-location pages, Monet options, and final welcome step) and its code-behind to handle step navigation, accent selection, and data-location resolution. Adds DataLocation UI and handlers (DataLocationPromptWindow changes, DataLocation resolver usage) and wires a DevDebug UI for toggling/opening the data-location page. UpdateEngineService now resolves the launcher root via DataLocationResolver. Misc: update various view models, localization entries and remove TrimmerRoots.xml.

* Refactor data location paths and add background service

Refactor DataLocationResolver to centralize data path resolution (ResolveLauncherDataPath, ResolveDesktopDataPath, ResolveConfigPath, ResolveLauncherLogsPath, ResolveLauncherStatePath) and replace usages of the previous ".launcher" layout with a "Launcher" folder. Update API: LoadConfig/SaveConfig reorganized and ApplyLocationChoice now accepts an optional custom path and migration flag; migration logic updated accordingly. Update dependent services and views (Logger, DeploymentLocator, UpdateEngineService, OobeStateService, StartupAttemptRegistry, LauncherDebugSettingsStore, OobeWindow) to use the new resolver APIs and paths. Add LauncherBackgroundService to load/validate/cache a custom splash background image and wire it into SplashWindow (AXAML/Axaml.cs) with UI placeholders and overlay. Misc: minor cleanup of Oobe/Splash XAML and related code adjustments and logging improvements.
This commit is contained in:
lincube
2026-04-25 18:41:26 +08:00
committed by GitHub
parent 0085c66514
commit 0b603384b4
55 changed files with 3512 additions and 429 deletions

1
.gitignore vendored
View File

@@ -514,3 +514,4 @@ nul
/*.AppImage
/velopack-output-local-verify
/velopack-output-local
/test-aot-publish

376
.kilo/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -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`

View File

@@ -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;
}

View File

@@ -34,7 +34,9 @@ namespace LanMountainDesktop.Launcher;
[JsonSerializable(typeof(PendingUpgrade))]
[JsonSerializable(typeof(List<PendingUpgrade>))]
[JsonSerializable(typeof(OobeStateFile))]
[JsonSerializable(typeof(DataLocationConfig))]
[JsonSerializable(typeof(GitHubRelease))]
[JsonSerializable(typeof(GitHubAsset))]
[JsonSerializable(typeof(List<GitHubRelease>))]
[JsonSerializable(typeof(StartupAttemptRecord))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

View File

@@ -37,11 +37,25 @@ internal sealed class CommandContext
/// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 仅当明确指定 --debug 参数或调试器附加时才启用
/// 当满足以下任一条件时启用
/// 1. 明确指定 --debug 参数
/// 2. 调试器附加Debugger.IsAttached
/// 3. DOTNET_ENVIRONMENT 环境变量为 DevelopmentIDE 调试启动时自动设置)
/// </summary>
public bool IsDebugMode =>
Options.ContainsKey("debug") ||
System.Diagnostics.Debugger.IsAttached;
System.Diagnostics.Debugger.IsAttached ||
IsDevelopmentEnvironment;
/// <summary>
/// 是否为 Development 环境DOTNET_ENVIRONMENT=Development
/// Rider/VS 调试启动时会自动设置此环境变量
/// </summary>
public bool IsDevelopmentEnvironment =>
string.Equals(
System.Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"),
"Development",
StringComparison.OrdinalIgnoreCase);
public bool IsPreviewCommand =>
Command.StartsWith("preview-", StringComparison.OrdinalIgnoreCase);

View File

@@ -25,6 +25,7 @@
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="1.1.250403001" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.12" />
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
</ItemGroup>

View File

@@ -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; }
}

View File

@@ -4,10 +4,10 @@ using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher;
internal static class Program
public static class Program
{
[STAThread]
private static async Task<int> Main(string[] args)
public static async Task<int> 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<App>()
.UsePlatformDetect()

View File

@@ -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",

View File

@@ -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();
}
});
}
}
}

View File

@@ -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;
/// <summary>
/// 默认系统数据路径(用户目录)
/// </summary>
public string DefaultSystemDataPath => _defaultSystemDataPath;
/// <summary>
/// 默认便携模式数据路径(应用目录下的 AppData
/// </summary>
public string DefaultPortableDataPath => Path.Combine(_appRoot, "AppData");
/// <summary>
/// 检查是否允许便携模式(应用目录是否可写)
/// </summary>
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;
}
}
/// <summary>
/// 解析数据根目录(用户选择的位置)
/// </summary>
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;
}
/// <summary>
/// 启动器数据目录(日志、配置、状态等)
/// </summary>
public string ResolveLauncherDataPath()
{
return Path.Combine(ResolveDataRoot(), LauncherFolderName);
}
/// <summary>
/// 桌面应用数据目录(组件、设置、插件等)
/// </summary>
public string ResolveDesktopDataPath()
{
return Path.Combine(ResolveDataRoot(), DesktopFolderName);
}
/// <summary>
/// 数据位置配置文件路径(保存在 Launcher 目录下)
/// </summary>
public string ResolveConfigPath()
{
return Path.Combine(ResolveLauncherDataPath(), ConfigFileName);
}
/// <summary>
/// 启动器日志目录
/// </summary>
public string ResolveLauncherLogsPath()
{
return Path.Combine(ResolveLauncherDataPath(), "logs");
}
/// <summary>
/// 启动器状态目录
/// </summary>
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);
}
}
}

View File

@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -360,24 +360,28 @@ internal sealed class DeploymentLocator
/// </summary>
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;
}
}
@@ -386,23 +390,27 @@ internal sealed class DeploymentLocator
}
/// <summary>
/// 鑾峰彇寮€鍙戠幆澧冨彲鑳界殑涓荤▼搴忚矾寰? /// </summary>
/// 鑾峰彇寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忚経
/// </summary>
private static IEnumerable<string> 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

View File

@@ -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<string, string>(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<string> BuildForwardedArguments(
CommandContext context,
string packageRoot,
AppVersionInfo versionInfo)
AppVersionInfo versionInfo,
string? dataRoot = null)
{
var arguments = new List<string>();
@@ -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;
}

View File

@@ -0,0 +1,174 @@
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 启动器背景图片服务
/// </summary>
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;
/// <summary>
/// 背景图片信息
/// </summary>
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; }
}
/// <summary>
/// 加载背景图片
/// </summary>
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}"
};
}
}
/// <summary>
/// 查找图片文件
/// </summary>
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;
}
/// <summary>
/// 清除缓存
/// </summary>
public static void ClearCache()
{
_cachedBitmap?.Dispose();
_cachedBitmap = null;
_cachedPath = null;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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<IOobeStep> _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())
{

View File

@@ -53,12 +53,22 @@ internal static class Logger
/// </summary>
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
{

View File

@@ -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,7 +208,14 @@ internal sealed class OobeStateService
};
}
private static string GetDefaultStateRoot()
private static string ResolveStateRoot(string appRoot)
{
try
{
var resolver = new DataLocationResolver(appRoot);
return resolver.ResolveDataRoot();
}
catch
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(appData))
@@ -218,4 +225,5 @@ internal sealed class OobeStateService
return Path.Combine(appData, "LanMountainDesktop");
}
}
}

View File

@@ -63,13 +63,28 @@ internal sealed class PluginInstallerService
return null;
}
string? allowedRoot = null;
try
{
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(appRoot);
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
}
catch
{
}
if (string.IsNullOrWhiteSpace(allowedRoot))
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
return null;
}
var allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
allowedRoot = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(localAppData), "LanMountainDesktop"));
}
var normalizedPluginsDirectory = EnsureTrailingSeparator(Path.GetFullPath(pluginsDirectory));
if (normalizedPluginsDirectory.StartsWith(allowedRoot, StringComparison.OrdinalIgnoreCase))
{

View File

@@ -10,23 +10,33 @@ 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(
: 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",
"Launcher",
"state",
"startup-attempt.json"))
{
"startup-attempt.json");
}
}
internal StartupAttemptRegistry(string statePath)
@@ -415,7 +425,7 @@ internal sealed class StartupAttemptRegistry
try
{
var json = File.ReadAllText(_statePath);
return JsonSerializer.Deserialize<StartupAttemptRecord>(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)

View File

@@ -0,0 +1,68 @@
using Avalonia;
using Avalonia.Styling;
using FluentAvalonia.Styling;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 主题服务,管理启动器的主题设置
/// </summary>
public static class ThemeService
{
private static ThemeVariant _currentTheme = ThemeVariant.Light;
private static string _accentColor = "#0078D4";
/// <summary>
/// 获取当前主题
/// </summary>
public static ThemeVariant CurrentTheme => _currentTheme;
/// <summary>
/// 获取当前主题色
/// </summary>
public static string AccentColor => _accentColor;
/// <summary>
/// 应用主题设置
/// </summary>
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;
}
}
/// <summary>
/// 应用浅色主题
/// </summary>
public static void ApplyLightTheme(string accentColor)
{
ApplyTheme(ThemeMode.Light, accentColor);
}
/// <summary>
/// 应用深色主题
/// </summary>
public static void ApplyDarkTheme(string accentColor)
{
ApplyTheme(ThemeMode.Dark, accentColor);
}
}
/// <summary>
/// 主题模式
/// </summary>
public enum ThemeMode
{
Light,
Dark
}

View File

@@ -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[]
{

View File

@@ -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
}
}
/// <summary>
/// 数据位置选择页面是否启用实际功能
/// </summary>
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
/// </summary>
public ICommand OpenOobeCommand { get; }
/// <summary>
/// 打开数据位置选择页面命令
/// </summary>
public ICommand OpenDataLocationCommand { get; }
/// <summary>
/// 全部切换到查看模式命令
/// </summary>
@@ -170,6 +193,11 @@ public sealed class DevDebugWindowViewModel : INotifyPropertyChanged
/// </summary>
public event EventHandler<OobeOpenEventArgs>? OpenOobeRequested;
/// <summary>
/// 请求打开数据位置选择页面
/// </summary>
public event EventHandler<DataLocationOpenEventArgs>? OpenDataLocationRequested;
/// <summary>
/// 请求关闭窗口
/// </summary>
@@ -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

View File

@@ -0,0 +1,153 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
d:DesignWidth="520"
d:DesignHeight="480"
x:Class="LanMountainDesktop.Launcher.Views.DataLocationPromptWindow"
x:DataType="views:DataLocationPromptWindow"
Title="选择数据保存位置"
Width="520"
Height="480"
CanResize="False"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
TransparencyLevelHint="None"
Icon="/Assets/logo.ico">
<Grid x:Name="ContentGrid"
Opacity="0">
<Grid.RenderTransform>
<TranslateTransform Y="24" />
</Grid.RenderTransform>
<Grid Margin="36" RowDefinitions="Auto,*,Auto">
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,20">
<TextBlock Text="选择数据保存位置"
FontSize="22"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<StackPanel Grid.Row="1" Spacing="12">
<Border x:Name="AdminWarningBanner"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="12,10"
IsVisible="False">
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="6">
<ui:SymbolIcon Symbol="Important"
FontSize="16"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
<TextBlock Text="无法保存到应用目录"
FontWeight="SemiBold"
FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
</StackPanel>
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
</StackPanel>
</Border>
<Border x:Name="SystemOptionBorder"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="2"
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
Padding="16,14">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="SystemRadio"
Grid.Column="0"
VerticalAlignment="Top"
Margin="0,2,12,0"
GroupName="DataLocation"
IsChecked="True" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="保存在系统用户目录(推荐)"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<TextBlock x:Name="SystemPathText"
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="PortableOptionBorder"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="1"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
Padding="16,14">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="PortableRadio"
Grid.Column="0"
VerticalAlignment="Top"
Margin="0,2,12,0"
GroupName="DataLocation"
IsEnabled="False" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="保存在应用安装目录"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<TextBlock x:Name="PortablePathText"
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="MigrationInfoBorder"
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="12,10"
IsVisible="False">
<StackPanel Orientation="Horizontal" Spacing="6">
<ui:SymbolIcon Symbol="Message"
FontSize="16"
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
<TextBlock x:Name="MigrationInfoText"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="10"
Margin="0,20,0,0">
<Button x:Name="CancelButton"
Content="取消"
Theme="{DynamicResource ButtonTheme}"
IsVisible="False" />
<Button x:Name="ConfirmButton"
Content="确认"
Theme="{DynamicResource AccentButtonTheme}" />
</StackPanel>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,310 @@
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Views;
internal partial class DataLocationPromptWindow : Window
{
private readonly TaskCompletionSource<DataLocationPromptResult?> _completionSource = new();
private readonly DataLocationResolver _resolver;
private bool _isTransitioning;
public DataLocationPromptWindow()
{
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
_resolver = new DataLocationResolver(AppContext.BaseDirectory);
}
internal DataLocationPromptWindow(DataLocationResolver resolver)
{
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
_resolver = resolver;
}
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
BindControls();
UpdateUiState();
}
private void BindControls()
{
var systemRadio = this.FindControl<RadioButton>("SystemRadio");
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
var confirmButton = this.FindControl<Button>("ConfirmButton");
var cancelButton = this.FindControl<Button>("CancelButton");
if (systemRadio is not null)
{
systemRadio.Checked += OnSelectionChanged;
systemRadio.Unchecked += OnSelectionChanged;
}
if (portableRadio is not null)
{
portableRadio.Checked += OnSelectionChanged;
portableRadio.Unchecked += OnSelectionChanged;
}
if (confirmButton is not null)
{
confirmButton.Click += OnConfirmClick;
}
if (cancelButton is not null)
{
cancelButton.Click += OnCancelClick;
}
}
private void UpdateUiState()
{
var systemPathText = this.FindControl<TextBlock>("SystemPathText");
var portablePathText = this.FindControl<TextBlock>("PortablePathText");
var adminWarningBanner = this.FindControl<Border>("AdminWarningBanner");
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
var migrationInfoBorder = this.FindControl<Border>("MigrationInfoBorder");
var migrationInfoText = this.FindControl<TextBlock>("MigrationInfoText");
if (systemPathText is not null)
{
systemPathText.Text = _resolver.DefaultSystemDataPath;
}
if (portablePathText is not null)
{
portablePathText.Text = _resolver.DefaultPortableDataPath;
}
var portableAllowed = _resolver.IsPortableModeAllowed();
if (adminWarningBanner is not null)
{
adminWarningBanner.IsVisible = !portableAllowed;
}
if (portableRadio is not null)
{
portableRadio.IsEnabled = portableAllowed;
}
var hasExistingData = _resolver.HasExistingSystemData();
if (migrationInfoBorder is not null)
{
migrationInfoBorder.IsVisible = hasExistingData;
}
if (migrationInfoText is not null && hasExistingData)
{
migrationInfoText.Text = "检测到系统用户目录已有应用数据。如果选择保存在应用安装目录,将自动迁移现有数据。";
}
}
private void OnSelectionChanged(object? sender, RoutedEventArgs e)
{
var systemRadio = this.FindControl<RadioButton>("SystemRadio");
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
var systemBorder = this.FindControl<Border>("SystemOptionBorder");
var portableBorder = this.FindControl<Border>("PortableOptionBorder");
var isSystem = systemRadio?.IsChecked == true;
var isPortable = portableRadio?.IsChecked == true;
if (systemBorder is not null)
{
systemBorder.BorderBrush = isSystem
? Application.Current?.FindResource("AccentFillColorDefaultBrush") as IBrush
: Application.Current?.FindResource("CardStrokeColorDefaultBrush") as IBrush;
systemBorder.BorderThickness = isSystem ? new Thickness(2) : new Thickness(1);
}
if (portableBorder is not null)
{
portableBorder.BorderBrush = isPortable
? Application.Current?.FindResource("AccentFillColorDefaultBrush") as IBrush
: Application.Current?.FindResource("CardStrokeColorDefaultBrush") as IBrush;
portableBorder.BorderThickness = isPortable ? new Thickness(2) : new Thickness(1);
}
}
private async void OnConfirmClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning)
{
return;
}
_isTransitioning = true;
var portableRadio = this.FindControl<RadioButton>("PortableRadio");
var selectedMode = portableRadio?.IsChecked == true
? DataLocationMode.Portable
: DataLocationMode.System;
var migrateExistingData = selectedMode == DataLocationMode.Portable && _resolver.HasExistingSystemData();
try
{
await PlayExitAnimationAsync();
_completionSource.TrySetResult(new DataLocationPromptResult
{
SelectedMode = selectedMode,
MigrateExistingData = migrateExistingData
});
}
catch (Exception ex)
{
Logger.Warn($"Error during data location prompt exit animation: {ex.Message}");
_completionSource.TrySetResult(new DataLocationPromptResult
{
SelectedMode = selectedMode,
MigrateExistingData = migrateExistingData
});
}
}
private async void OnCancelClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning)
{
return;
}
_isTransitioning = true;
try
{
await PlayExitAnimationAsync();
_completionSource.TrySetResult(null);
}
catch (Exception ex)
{
Logger.Warn($"Error during data location prompt cancel: {ex.Message}");
_completionSource.TrySetResult(null);
}
}
private async void OnWindowOpened(object? sender, EventArgs e)
{
await PlayEntranceAnimationAsync();
}
private async Task PlayEntranceAnimationAsync()
{
try
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
return;
}
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
contentGrid.RenderTransform = translateTransform;
contentGrid.Opacity = 0;
translateTransform.Y = 24;
var fadeInAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(500),
Easing = new CubicEaseOut(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(500)
}
}
};
var slideUpAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(500),
Easing = new CubicEaseOut(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 24.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(500)
}
}
};
await Task.WhenAll(
fadeInAnimation.RunAsync(contentGrid),
slideUpAnimation.RunAsync(translateTransform));
}
catch (Exception ex)
{
Logger.Warn($"Error playing data location prompt entrance animation: {ex.Message}");
}
}
private async Task PlayExitAnimationAsync()
{
try
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
await Task.Delay(150);
return;
}
var fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(200),
Easing = new CubicEaseIn(),
FillMode = FillMode.Forward,
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(200)
}
}
};
await fadeOutAnimation.RunAsync(contentGrid);
}
catch (Exception ex)
{
Logger.Warn($"Error playing data location prompt exit animation: {ex.Message}");
}
}
internal Task<DataLocationPromptResult?> WaitForChoiceAsync() => _completionSource.Task;
}

View File

@@ -141,6 +141,32 @@
</Grid>
</Border>
<!-- 数据位置选择页面 -->
<Border Background="{DynamicResource SystemControlBackgroundAltMediumBrush}"
CornerRadius="8"
Padding="15">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0">
<TextBlock Text="📁 数据位置选择 (DataLocationPromptWindow)"
FontWeight="SemiBold"
FontSize="14" />
<TextBlock Text="选择数据保存位置"
FontSize="11"
Opacity="0.6"
Margin="0,3,0,0" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="8">
<ToggleSwitch Content="启用功能"
IsChecked="{Binding IsDataLocationEnabled}"
OnContent="功能"
OffContent="查看" />
<Button Content="打开"
Command="{Binding OpenDataLocationCommand}"
HorizontalAlignment="Right" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>

View File

@@ -25,6 +25,7 @@ public partial class DevDebugWindow : Window
_viewModel.OpenErrorRequested += OnOpenErrorRequested;
_viewModel.OpenUpdateRequested += OnOpenUpdateRequested;
_viewModel.OpenOobeRequested += OnOpenOobeRequested;
_viewModel.OpenDataLocationRequested += OnOpenDataLocationRequested;
_viewModel.CloseRequested += OnCloseRequested;
}
@@ -135,6 +136,17 @@ public partial class DevDebugWindow : Window
}
}
/// <summary>
/// 打开数据位置选择页面
/// </summary>
private void OnOpenDataLocationRequested(object? sender, DataLocationOpenEventArgs e)
{
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
var resolver = new DataLocationResolver(appRoot);
var window = new DataLocationPromptWindow(resolver);
window.Show();
}
/// <summary>
/// 关闭窗口
/// </summary>

View File

@@ -4,13 +4,14 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LanMountainDesktop.Launcher.Views"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="600"
d:DesignWidth="700"
d:DesignHeight="500"
x:Class="LanMountainDesktop.Launcher.Views.OobeWindow"
x:DataType="views:OobeWindow"
Title="欢迎使用阑山桌面"
Width="600"
Width="700"
Height="500"
CanResize="False"
WindowStartupLocation="CenterScreen"
@@ -21,59 +22,599 @@
<views:OobeWindow />
</Design.DataContext>
<Grid x:Name="ContentGrid"
Opacity="0">
<Grid.RenderTransform>
<TranslateTransform Y="24" />
</Grid.RenderTransform>
<!-- 主内容区域 -->
<Grid Margin="48" RowDefinitions="*,Auto">
<!-- 中央内容区域 -->
<Grid x:Name="ContentGrid">
<!-- 步骤 1: 打字机动画开场 -->
<Grid x:Name="TypingStep" Margin="60,80,60,60">
<!-- 主标题区域(左上角) -->
<StackPanel HorizontalAlignment="Left" VerticalAlignment="Top" Spacing="16">
<!-- 打字机文本区域 -->
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="TypingTextBlock"
FontSize="28"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
FontFamily="Consolas, Monaco, 'Courier New', monospace" />
<Border x:Name="CursorBorder"
Width="3"
Height="28"
Background="{DynamicResource TextFillColorPrimaryBrush}"
VerticalAlignment="Bottom"
Margin="4,0,0,4">
<Border.Styles>
<Style Selector="Border">
<Style.Animations>
<Animation Duration="0:0:0.8" IterationCount="INFINITE">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="50%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="51%">
<Setter Property="Opacity" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Border.Styles>
</Border>
</StackPanel>
<!-- 副标题区域(流光渐变动画 + 打字机效果) -->
<StackPanel x:Name="SubtitlePanel" Opacity="0" IsVisible="False" Spacing="4">
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="NextGenTextBlock"
FontSize="48"
FontWeight="Bold"
FontFamily="Consolas, Monaco, 'Courier New', monospace">
<TextBlock.Foreground>
<LinearGradientBrush StartPoint="0%,0%" EndPoint="100%,100%">
<GradientStop Offset="0.0" Color="#0078D4" />
<GradientStop Offset="0.33" Color="#7B68EE" />
<GradientStop Offset="0.66" Color="#FF8C00" />
<GradientStop Offset="1.0" Color="#107C10" />
</LinearGradientBrush>
</TextBlock.Foreground>
</TextBlock> <Border x:Name="SubtitleCursorBorder"
Width="4"
Height="48"
Background="{DynamicResource TextFillColorPrimaryBrush}"
VerticalAlignment="Bottom"
Margin="4,0,0,4"
IsVisible="False">
<Border.Styles>
<Style Selector="Border">
<Style.Animations>
<Animation Duration="0:0:0.8" IterationCount="INFINITE">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="50%">
<Setter Property="Opacity" Value="1" />
</KeyFrame>
<KeyFrame Cue="51%">
<Setter Property="Opacity" Value="0" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Border.Styles>
</Border>
</StackPanel>
<TextBlock x:Name="DashboardTextBlock"
FontSize="48"
FontWeight="Bold"
FontFamily="Consolas, Monaco, 'Courier New', monospace"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
</StackPanel>
</StackPanel>
<!-- 按钮动画区域(左下角) -->
<Grid x:Name="ButtonAnimationArea"
Width="280"
Height="80"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Margin="0,0,0,40"
IsVisible="False">
<!-- 方框边框(由鼠标画出) -->
<Border x:Name="DrawnBorder"
Width="160"
Height="56"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Background="Transparent"
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
BorderThickness="0"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
</Border>
<!-- 开始按钮(从方框中弹出) -->
<Button x:Name="StartButton"
Width="160"
Height="56"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Theme="{DynamicResource AccentButtonTheme}"
Opacity="0"
IsVisible="False"
RenderTransformOrigin="0.5,0.5">
<Button.RenderTransform>
<ScaleTransform ScaleX="0.1" ScaleY="0.1" />
</Button.RenderTransform>
<TextBlock Text="开始使用"
FontSize="16"
FontWeight="SemiBold" />
</Button>
<!-- 鼠标光标 -->
<Canvas x:Name="MouseCursor"
Width="24"
Height="24"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="-50,-50,0,0"
IsVisible="False">
<Path Data="M0,0 L0,18 L4,14 L7,20 L10,19 L7,13 L12,13 Z"
Fill="{DynamicResource TextFillColorPrimaryBrush}"
Stroke="{DynamicResource SolidBackgroundFillColorBaseBrush}"
StrokeThickness="1" />
</Canvas>
</Grid>
</Grid>
<!-- 步骤 2: 主题选择页面 -->
<Grid x:Name="ThemeStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
<TextBlock Text="个性化你的桌面"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="选择你喜欢的主题样式,可随时在设置中更改"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="20">
<!-- 浅色/深色模式选择 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<StackPanel Spacing="12">
<TextBlock Text="外观模式"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<Grid ColumnDefinitions="*,*" ColumnSpacing="12">
<Border x:Name="LightModeOption"
Grid.Column="0"
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="2"
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
Padding="16"
Cursor="Hand">
<StackPanel Spacing="8" HorizontalAlignment="Center">
<Border Width="48"
Height="48"
Background="#F3F3F3"
CornerRadius="8"
BorderBrush="#E0E0E0"
BorderThickness="1">
<fi:SymbolIcon Symbol="WeatherSunny"
FontSize="24"
Foreground="#5F5F5F"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="浅色模式"
FontSize="13"
HorizontalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<RadioButton x:Name="LightModeRadio"
GroupName="ThemeMode"
IsChecked="True"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
<Border x:Name="DarkModeOption"
Grid.Column="1"
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="1"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
Padding="16"
Cursor="Hand">
<StackPanel Spacing="8" HorizontalAlignment="Center">
<Border Width="48"
Height="48"
Background="#1E1E1E"
CornerRadius="8"
BorderBrush="#333333"
BorderThickness="1">
<fi:SymbolIcon Symbol="WeatherMoon"
FontSize="24"
Foreground="#E0E0E0"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<TextBlock Text="深色模式"
FontSize="13"
HorizontalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<RadioButton x:Name="DarkModeRadio"
GroupName="ThemeMode"
HorizontalAlignment="Center" />
</StackPanel>
</Border>
</Grid>
</StackPanel>
</Border>
<!-- 主题色选择 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<StackPanel Spacing="12">
<TextBlock Text="主题色"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<WrapPanel x:Name="AccentColorPanel" HorizontalAlignment="Left">
<!-- 预设颜色 -->
<Border x:Name="BlueColor"
Width="40"
Height="40"
Background="#0078D4"
CornerRadius="20"
BorderThickness="3"
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
Margin="0,0,12,12"
Cursor="Hand">
<Border.Styles>
<Style Selector="Border:pointerover">
<Setter Property="RenderTransform" Value="scale(1.1)" />
</Style>
</Border.Styles>
</Border>
<Border x:Name="PurpleColor"
Width="40"
Height="40"
Background="#7B68EE"
CornerRadius="20"
BorderThickness="0"
Margin="0,0,12,12"
Cursor="Hand" />
<Border x:Name="GreenColor"
Width="40"
Height="40"
Background="#107C10"
CornerRadius="20"
BorderThickness="0"
Margin="0,0,12,12"
Cursor="Hand" />
<Border x:Name="OrangeColor"
Width="40"
Height="40"
Background="#D83B01"
CornerRadius="20"
BorderThickness="0"
Margin="0,0,12,12"
Cursor="Hand" />
<Border x:Name="PinkColor"
Width="40"
Height="40"
Background="#E3008C"
CornerRadius="20"
BorderThickness="0"
Margin="0,0,12,12"
Cursor="Hand" />
<Border x:Name="TealColor"
Width="40"
Height="40"
Background="#008080"
CornerRadius="20"
BorderThickness="0"
Margin="0,0,12,12"
Cursor="Hand" />
</WrapPanel>
</StackPanel>
</Border>
<!-- 莫奈取色来源 -->
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="16">
<StackPanel Spacing="12">
<TextBlock Text="莫奈取色来源"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="从壁纸自动提取主题色,让界面与桌面完美融合"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<StackPanel Spacing="8">
<Border x:Name="MonetFromWallpaperOption"
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="2"
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
Padding="12"
Cursor="Hand">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="MonetFromWallpaperRadio"
Grid.Column="0"
GroupName="MonetSource"
IsChecked="True"
VerticalAlignment="Center"
Margin="0,0,12,0" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="从桌面壁纸取色"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="自动分析当前壁纸颜色生成主题"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="MonetFromCustomOption"
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="1"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
Padding="12"
Cursor="Hand">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="MonetFromCustomRadio"
Grid.Column="0"
GroupName="MonetSource"
VerticalAlignment="Center"
Margin="0,0,12,0" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="自定义图片取色"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="选择一张图片作为取色来源"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="MonetDisabledOption"
Background="{DynamicResource SolidBackgroundFillColorQuarternaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="1"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
Padding="12"
Cursor="Hand">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="MonetDisabledRadio"
Grid.Column="0"
GroupName="MonetSource"
VerticalAlignment="Center"
Margin="0,0,12,0" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="不使用莫奈取色"
FontSize="13"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="使用固定的预设主题色"
FontSize="11"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="12"
Margin="0,24,0,0">
<Button x:Name="ThemeBackButton"
Content="返回"
Theme="{DynamicResource ButtonTheme}" />
<Button x:Name="ThemeNextButton"
Content="下一步"
Theme="{DynamicResource AccentButtonTheme}" />
</StackPanel>
</Grid>
<!-- 步骤 3: 数据位置选择页面 -->
<Grid x:Name="DataLocationStep" Margin="48" RowDefinitions="Auto,*,Auto" IsVisible="False">
<StackPanel Grid.Row="0" Spacing="8" Margin="0,0,0,24">
<TextBlock Text="选择数据保存位置"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="决定将应用数据保存在哪里,可随时在设置中更改"
FontSize="13"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
<StackPanel Grid.Row="1" Spacing="16">
<Border x:Name="AdminWarningBanner"
Background="{DynamicResource SystemFillColorCriticalBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="12,10"
IsVisible="False">
<StackPanel Spacing="4">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="M12,2 L1,21 L23,21 Z M11,9 L13,9 L13,15 L11,15 Z M11,17 L13,17 L13,19 L11,19 Z"
Width="16"
Height="16"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
<TextBlock Text="无法保存到应用目录"
FontWeight="SemiBold"
FontSize="13"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
</StackPanel>
<TextBlock Text="当前安装目录需要管理员权限才能写入,数据将自动保存到系统用户目录。"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource SystemFillColorCriticalBrush}" />
</StackPanel>
</Border>
<Border x:Name="SystemOptionBorder"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="2"
BorderBrush="{DynamicResource AccentFillColorDefaultBrush}"
Padding="16,14"
Cursor="Hand">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="SystemRadio"
Grid.Column="0"
VerticalAlignment="Top"
Margin="0,2,12,0"
GroupName="DataLocation"
IsChecked="True" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="保存在系统用户目录(推荐)"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="数据与当前 Windows 用户绑定,重装应用或更新后数据不会丢失"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<TextBlock x:Name="SystemPathText"
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="PortableOptionBorder"
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
BorderThickness="1"
BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}"
Padding="16,14"
Cursor="Hand">
<Grid ColumnDefinitions="Auto,*">
<RadioButton x:Name="PortableRadio"
Grid.Column="0"
VerticalAlignment="Top"
Margin="0,2,12,0"
GroupName="DataLocation"
IsEnabled="False" />
<StackPanel Grid.Column="1" Spacing="4">
<TextBlock Text="保存在应用安装目录"
FontSize="14"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}" />
<TextBlock Text="便于携带,可随应用文件夹整体移动到其他电脑"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
<TextBlock x:Name="PortablePathText"
FontSize="11"
TextWrapping="Wrap"
Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Margin="0,4,0,0" />
</StackPanel>
</Grid>
</Border>
<Border x:Name="MigrationInfoBorder"
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Padding="12,10"
IsVisible="False">
<StackPanel Orientation="Horizontal" Spacing="6">
<PathIcon Data="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z"
Width="16"
Height="16"
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
<TextBlock x:Name="MigrationInfoText"
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource SystemFillColorSuccessBrush}" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Row="2"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="12"
Margin="0,24,0,0">
<Button x:Name="DataLocationBackButton"
Content="返回"
Theme="{DynamicResource ButtonTheme}" />
<Button x:Name="DataLocationNextButton"
Content="下一步"
Theme="{DynamicResource AccentButtonTheme}" />
</StackPanel>
</Grid>
<!-- 步骤 4: 欢迎完成页面 -->
<Grid x:Name="WelcomeStep" Margin="48" RowDefinitions="*,Auto" IsVisible="False">
<StackPanel Grid.Row="0"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Spacing="24">
Spacing="32">
<!-- 顶部:完成状态勾号图标 -->
<Border Width="80"
Height="80"
<Border Width="96"
Height="96"
Background="{DynamicResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="40"
CornerRadius="48"
HorizontalAlignment="Center">
<ui:SymbolIcon Symbol="Accept"
FontSize="40"
<PathIcon Data="M9,16.17 L4.83,12 L3.41,13.41 L9,19 L21,7 L19.59,5.59 Z"
Width="48"
Height="48"
Foreground="{DynamicResource SystemFillColorSuccessBrush}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<!-- 中央:欢迎文字 -->
<StackPanel Spacing="8" HorizontalAlignment="Center">
<StackPanel Spacing="12" HorizontalAlignment="Center">
<TextBlock Text="欢迎使用阑山桌面"
FontSize="28"
FontSize="32"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
HorizontalAlignment="Center" />
<TextBlock Text="你的桌面,不止一面"
FontSize="14"
FontSize="16"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
HorizontalAlignment="Center" />
</StackPanel>
</StackPanel>
<!-- 底部:圆形开始按钮 -->
<Button Grid.Row="1"
x:Name="EnterButton"
HorizontalAlignment="Center"
Width="56"
Height="56"
Margin="0,0,0,16"
Margin="0,0,0,24"
Theme="{DynamicResource AccentButtonTheme}"
CornerRadius="28">
<ui:SymbolIcon Symbol="Forward"
<fi:SymbolIcon Symbol="ArrowRight"
FontSize="24"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"/>
Foreground="{DynamicResource TextFillColorPrimaryBrush}"/>
</Button>
</Grid>
</Grid>

View File

@@ -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<bool> _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<Button>("EnterButton");
if (enterButton is not null)
private void SetupEventHandlers()
{
// 步骤 1: 开始按钮
if (this.FindControl<Button>("StartButton") is { } startButton)
{
startButton.Click += OnStartButtonClick;
}
// 步骤 2: 主题选择页面
if (this.FindControl<Button>("ThemeBackButton") is { } themeBackButton)
{
themeBackButton.Click += OnThemeBackClick;
}
if (this.FindControl<Button>("ThemeNextButton") is { } themeNextButton)
{
themeNextButton.Click += OnThemeNextClick;
}
// 浅色/深色模式选择
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
{
lightModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Light);
}
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
{
darkModeOption.PointerPressed += (s, e) => SelectThemeMode(Services.ThemeMode.Dark);
}
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
{
lightModeRadio.IsCheckedChanged += (s, e) =>
{
if (lightModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Light);
};
}
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
{
darkModeRadio.IsCheckedChanged += (s, e) =>
{
if (darkModeRadio.IsChecked == true) SelectThemeMode(Services.ThemeMode.Dark);
};
}
// 主题色选择
SetupAccentColorHandlers();
// 莫奈取色来源选择
if (this.FindControl<Border>("MonetFromWallpaperOption") is { } monetWallpaperOption)
{
monetWallpaperOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Wallpaper);
}
if (this.FindControl<Border>("MonetFromCustomOption") is { } monetCustomOption)
{
monetCustomOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Custom);
}
if (this.FindControl<Border>("MonetDisabledOption") is { } monetDisabledOption)
{
monetDisabledOption.PointerPressed += (s, e) => SelectMonetSource(MonetSource.Disabled);
}
if (this.FindControl<RadioButton>("MonetFromWallpaperRadio") is { } monetWallpaperRadio)
{
monetWallpaperRadio.IsCheckedChanged += (s, e) =>
{
if (monetWallpaperRadio.IsChecked == true) SelectMonetSource(MonetSource.Wallpaper);
};
}
if (this.FindControl<RadioButton>("MonetFromCustomRadio") is { } monetCustomRadio)
{
monetCustomRadio.IsCheckedChanged += (s, e) =>
{
if (monetCustomRadio.IsChecked == true) SelectMonetSource(MonetSource.Custom);
};
}
if (this.FindControl<RadioButton>("MonetDisabledRadio") is { } monetDisabledRadio)
{
monetDisabledRadio.IsCheckedChanged += (s, e) =>
{
if (monetDisabledRadio.IsChecked == true) SelectMonetSource(MonetSource.Disabled);
};
}
// 步骤 3: 数据位置选择页面
if (this.FindControl<Button>("DataLocationBackButton") is { } dataLocationBackButton)
{
dataLocationBackButton.Click += OnDataLocationBackClick;
}
if (this.FindControl<Button>("DataLocationNextButton") is { } dataLocationNextButton)
{
dataLocationNextButton.Click += OnDataLocationNextClick;
}
if (this.FindControl<Border>("SystemOptionBorder") is { } systemOption)
{
systemOption.PointerPressed += (s, e) => SelectDataLocationMode(DataLocationMode.System);
}
if (this.FindControl<Border>("PortableOptionBorder") is { } portableOption)
{
portableOption.PointerPressed += (s, e) => SelectDataLocationMode(DataLocationMode.Portable);
}
if (this.FindControl<RadioButton>("SystemRadio") is { } systemRadio)
{
systemRadio.IsCheckedChanged += (s, e) =>
{
if (systemRadio.IsChecked == true) SelectDataLocationMode(DataLocationMode.System);
};
}
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
{
portableRadio.IsCheckedChanged += (s, e) =>
{
if (portableRadio.IsChecked == true) SelectDataLocationMode(DataLocationMode.Portable);
};
}
// 步骤 4: 欢迎完成页面
if (this.FindControl<Button>("EnterButton") is { } enterButton)
{
enterButton.Click += OnEnterClick;
Console.WriteLine("[OobeWindow] EnterButton event bound successfully");
}
else
}
private void SetupAccentColorHandlers()
{
Console.Error.WriteLine("[OobeWindow] Failed to find EnterButton!");
var colorMap = new Dictionary<string, string>
{
{ "BlueColor", "#0078D4" },
{ "PurpleColor", "#7B68EE" },
{ "GreenColor", "#107C10" },
{ "OrangeColor", "#D83B01" },
{ "PinkColor", "#E3008C" },
{ "TealColor", "#008080" }
};
foreach (var (name, color) in colorMap)
{
if (this.FindControl<Border>(name) is { } colorBorder)
{
colorBorder.PointerPressed += (s, e) => SelectAccentColor(name, color);
}
}
}
private async void OnWindowOpened(object? sender, EventArgs e)
{
Console.WriteLine("[OobeWindow] Window opened, playing entrance animation...");
await PlayEntranceAnimationAsync();
await PlayTypingAnimationAsync();
}
private async Task PlayEntranceAnimationAsync()
private async Task PlayTypingAnimationAsync()
{
try
var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock");
var cursorBorder = this.FindControl<Border>("CursorBorder");
var subtitlePanel = this.FindControl<StackPanel>("SubtitlePanel");
var buttonAnimationArea = this.FindControl<Grid>("ButtonAnimationArea");
var startButton = this.FindControl<Button>("StartButton");
var mouseCursor = this.FindControl<Canvas>("MouseCursor");
if (typingTextBlock == null || cursorBorder == null) return;
// 打字机效果:阑山桌面 LanMountain Desktop在同一行
var fullText = "阑山桌面 LanMountain Desktop";
for (int i = 0; i <= fullText.Length; i++)
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
{
return;
typingTextBlock.Text = fullText.Substring(0, i);
await Task.Delay(TypingDelayMs);
}
var translateTransform = contentGrid.RenderTransform as TranslateTransform ?? new TranslateTransform();
contentGrid.RenderTransform = translateTransform;
// 停顿一下
await Task.Delay(500);
var offset = ResolveEntranceOffset();
contentGrid.Opacity = 0;
translateTransform.Y = offset;
// 隐藏光标
cursorBorder.IsVisible = false;
var fadeInAnimation = new Animation
// 显示副标题(打字机效果:下一代 互动信息看板)
if (subtitlePanel != null)
{
Duration = TimeSpan.FromMilliseconds(600),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
subtitlePanel.IsVisible = true;
subtitlePanel.Opacity = 1;
await PlaySubtitleTypingAnimationAsync();
}
}
};
var slideUpAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(600),
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, offset) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(TranslateTransform.YProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(600)
}
}
};
// 停顿一下再显示按钮
await Task.Delay(400);
await Task.WhenAll(
fadeInAnimation.RunAsync(contentGrid),
slideUpAnimation.RunAsync(translateTransform));
Console.WriteLine("[OobeWindow] Entrance animation completed");
}
catch (Exception ex)
// 显示按钮动画区域
if (buttonAnimationArea != null)
{
Console.Error.WriteLine($"[OobeWindow] Error playing entrance animation: {ex.Message}");
buttonAnimationArea.IsVisible = true;
}
// 鼠标拖拽按钮入场
if (mouseCursor != null && startButton != null)
{
await AnimateMouseDragButtonAsync(mouseCursor, startButton);
}
}
public Task WaitForEnterAsync() => _completionSource.Task;
private async Task AnimateMouseDragButtonAsync(Canvas mouseCursor, Button button)
{
// 初始处于画面外部的 X 坐标
var startX = -400.0;
var endX = 0.0;
button.IsVisible = true;
button.Opacity = 1;
button.RenderTransform = new TranslateTransform(startX, 0);
// 鼠标位于按钮上,比如偏移 (100, 30) 的位置
var mouseOffsetX = 100.0;
var mouseOffsetY = 30.0;
mouseCursor.Margin = new Thickness(startX + mouseOffsetX, mouseOffsetY, 0, 0);
mouseCursor.IsVisible = true;
await Task.Delay(300);
var duration = 800;
var steps = 40;
var delay = duration / steps;
for (int i = 0; i <= steps; i++)
{
var progress = (double)i / steps;
var eased = EaseOutBack(progress); // 使用 EaseOutBack 营造“拖拽到位”的清脆回弹感
var currentX = startX + (endX - startX) * eased;
button.RenderTransform = new TranslateTransform(currentX, 0);
mouseCursor.Margin = new Thickness(currentX + mouseOffsetX, mouseOffsetY, 0, 0);
await Task.Delay(delay);
}
await Task.Delay(200);
// 隐藏鼠标光标
await AnimateOpacityAsync(mouseCursor, 1, 0, 200);
mouseCursor.IsVisible = false;
}
private async Task PlaySubtitleTypingAnimationAsync()
{
var nextGenTextBlock = this.FindControl<TextBlock>("NextGenTextBlock");
var dashboardTextBlock = this.FindControl<TextBlock>("DashboardTextBlock");
var subtitleCursorBorder = this.FindControl<Border>("SubtitleCursorBorder");
if (nextGenTextBlock == null || dashboardTextBlock == null) return;
// 获取渐变画刷
var gradientBrush = nextGenTextBlock.Foreground as LinearGradientBrush;
// 启动渐变色流动动画
if (gradientBrush != null)
{
_ = AnimateGradientFlowAsync(gradientBrush);
}
// 显示光标
if (subtitleCursorBorder != null)
{
subtitleCursorBorder.IsVisible = true;
}
// 打字机效果:下一代
var nextGenText = "下一代";
for (int i = 0; i <= nextGenText.Length; i++)
{
nextGenTextBlock.Text = nextGenText.Substring(0, i);
await Task.Delay(TypingDelayMs);
}
// 停顿一下
await Task.Delay(200);
// 换行,光标移到第二行
if (subtitleCursorBorder != null)
{
subtitleCursorBorder.IsVisible = false;
}
// 打字机效果:互动信息看板
var dashboardText = "互动信息看板";
for (int i = 0; i <= dashboardText.Length; i++)
{
dashboardTextBlock.Text = dashboardText.Substring(0, i);
await Task.Delay(TypingDelayMs);
}
// 停顿一下后隐藏光标
await Task.Delay(300);
}
private async Task AnimateGradientFlowAsync(LinearGradientBrush? gradientBrush)
{
if (gradientBrush == null) return;
var stops = gradientBrush.GradientStops;
if (stops.Count < 2) return;
// 获取原有的所有颜色
var colors = new System.Collections.Generic.List<Color>();
foreach (var stop in stops)
{
colors.Add(stop.Color);
}
// 为了实现无缝循环流动,把第一个颜色追加到最后
colors.Add(colors[0]);
// 重新分配 GradientStops
stops.Clear();
for (int i = 0; i < colors.Count; i++)
{
stops.Add(new GradientStop(colors[i], (double)i / (colors.Count - 1)));
}
// 设置铺展模式,超出范围时重复
gradientBrush.SpreadMethod = GradientSpreadMethod.Repeat;
double offset = 0;
while (true)
{
offset -= 0.005; // 每次流动一小步,负数表示向右流动
if (offset <= -1.0) offset = 0;
// 让渐变保持水平方向,但位置不断偏移,形成河流般的流动效果
gradientBrush.StartPoint = new RelativePoint(offset, 0, RelativeUnit.Relative);
gradientBrush.EndPoint = new RelativePoint(offset + 1, 0, RelativeUnit.Relative);
await Task.Delay(16); // 约60帧
}
}
private async void OnStartButtonClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
await NavigateToStep(2);
}
// 主题选择页面按钮
private async void OnThemeBackClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
await NavigateToStep(1);
}
private async void OnThemeNextClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
await NavigateToStep(3);
}
// 数据位置选择页面按钮
private async void OnDataLocationBackClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
await NavigateToStep(2);
}
private async void OnDataLocationNextClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning) return;
// 应用数据位置选择
if (!_isDebugMode)
{
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
}
await NavigateToStep(4);
}
private async void OnEnterClick(object? sender, RoutedEventArgs e)
{
if (_isTransitioning)
{
return;
}
if (_isTransitioning) return;
_isTransitioning = true;
Console.WriteLine("[OobeWindow] Enter button clicked, starting transition...");
try
{
await PlayExitAnimationAsync();
_completionSource.TrySetResult(true);
Close();
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error during transition: {ex.Message}");
Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}");
_completionSource.TrySetResult(true);
Close();
}
}
private void InitializeDataLocationStep()
{
if (this.FindControl<TextBlock>("SystemPathText") is { } systemPathText)
{
systemPathText.Text = _resolver.DefaultSystemDataPath;
}
if (this.FindControl<TextBlock>("PortablePathText") is { } portablePathText)
{
portablePathText.Text = _resolver.DefaultPortableDataPath;
}
var canWriteToAppRoot = _resolver.IsPortableModeAllowed();
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
{
portableRadio.IsEnabled = canWriteToAppRoot;
}
if (!canWriteToAppRoot)
{
if (this.FindControl<Border>("AdminWarningBanner") is { } warningBanner)
{
warningBanner.IsVisible = true;
}
}
if (_resolver.HasExistingSystemData())
{
_migrateExistingData = true;
if (this.FindControl<Border>("MigrationInfoBorder") is { } migrationInfo)
{
migrationInfo.IsVisible = true;
}
if (this.FindControl<TextBlock>("MigrationInfoText") is { } migrationText)
{
migrationText.Text = "检测到现有数据,选择便携模式时将自动迁移。";
}
}
}
private void SelectDataLocationMode(DataLocationMode mode)
{
_selectedDataLocationMode = mode;
if (this.FindControl<RadioButton>("SystemRadio") is { } systemRadio)
{
systemRadio.IsChecked = mode == DataLocationMode.System;
}
if (this.FindControl<RadioButton>("PortableRadio") is { } portableRadio)
{
portableRadio.IsChecked = mode == DataLocationMode.Portable;
}
if (this.FindControl<Border>("SystemOptionBorder") is { } systemBorder)
{
systemBorder.BorderBrush = mode == DataLocationMode.System
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
systemBorder.BorderThickness = mode == DataLocationMode.System
? new Thickness(2)
: new Thickness(1);
}
if (this.FindControl<Border>("PortableOptionBorder") is { } portableBorder)
{
portableBorder.BorderBrush = mode == DataLocationMode.Portable
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
portableBorder.BorderThickness = mode == DataLocationMode.Portable
? new Thickness(2)
: new Thickness(1);
}
}
// 主题选择方法
private void SelectThemeMode(Services.ThemeMode mode)
{
_selectedThemeMode = mode;
// 立即应用主题到启动器
ThemeService.ApplyTheme(mode, _selectedAccentColor);
if (this.FindControl<RadioButton>("LightModeRadio") is { } lightModeRadio)
{
lightModeRadio.IsChecked = mode == Services.ThemeMode.Light;
}
if (this.FindControl<RadioButton>("DarkModeRadio") is { } darkModeRadio)
{
darkModeRadio.IsChecked = mode == Services.ThemeMode.Dark;
}
if (this.FindControl<Border>("LightModeOption") is { } lightModeOption)
{
lightModeOption.BorderBrush = mode == Services.ThemeMode.Light
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
lightModeOption.BorderThickness = mode == Services.ThemeMode.Light
? new Thickness(2)
: new Thickness(1);
}
if (this.FindControl<Border>("DarkModeOption") is { } darkModeOption)
{
darkModeOption.BorderBrush = mode == Services.ThemeMode.Dark
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
darkModeOption.BorderThickness = mode == Services.ThemeMode.Dark
? new Thickness(2)
: new Thickness(1);
}
}
private void SelectAccentColor(string colorName, string colorValue)
{
_selectedAccentColor = colorValue;
// 更新所有颜色圆圈边框
var colorBorders = new[] { "BlueColor", "PurpleColor", "GreenColor", "OrangeColor", "PinkColor", "TealColor" };
foreach (var name in colorBorders)
{
if (this.FindControl<Border>(name) is { } border)
{
var isSelected = name == colorName;
border.BorderBrush = isSelected
? Application.Current?.Resources["TextFillColorPrimaryBrush"] as IBrush
: null;
border.BorderThickness = isSelected ? new Thickness(3) : new Thickness(0);
}
}
}
private void SelectMonetSource(MonetSource source)
{
_selectedMonetSource = source;
if (this.FindControl<RadioButton>("MonetFromWallpaperRadio") is { } wallpaperRadio)
{
wallpaperRadio.IsChecked = source == MonetSource.Wallpaper;
}
if (this.FindControl<RadioButton>("MonetFromCustomRadio") is { } customRadio)
{
customRadio.IsChecked = source == MonetSource.Custom;
}
if (this.FindControl<RadioButton>("MonetDisabledRadio") is { } disabledRadio)
{
disabledRadio.IsChecked = source == MonetSource.Disabled;
}
UpdateMonetOptionBorder("MonetFromWallpaperOption", source == MonetSource.Wallpaper);
UpdateMonetOptionBorder("MonetFromCustomOption", source == MonetSource.Custom);
UpdateMonetOptionBorder("MonetDisabledOption", source == MonetSource.Disabled);
}
private void UpdateMonetOptionBorder(string borderName, bool isSelected)
{
if (this.FindControl<Border>(borderName) is { } border)
{
border.BorderBrush = isSelected
? Application.Current?.Resources["AccentFillColorDefaultBrush"] as IBrush
: Application.Current?.Resources["CardStrokeColorDefaultBrush"] as IBrush;
border.BorderThickness = isSelected ? new Thickness(2) : new Thickness(1);
}
}
private async Task NavigateToStep(int step)
{
if (_isTransitioning || step == _currentStep) return;
_isTransitioning = true;
// 获取当前步骤的控件
Grid? currentStepControl = _currentStep switch
{
1 => this.FindControl<Grid>("TypingStep"),
2 => this.FindControl<Grid>("ThemeStep"),
3 => this.FindControl<Grid>("DataLocationStep"),
4 => this.FindControl<Grid>("WelcomeStep"),
_ => null
};
// 获取目标步骤的控件
Grid? nextStepControl = step switch
{
1 => this.FindControl<Grid>("TypingStep"),
2 => this.FindControl<Grid>("ThemeStep"),
3 => this.FindControl<Grid>("DataLocationStep"),
4 => this.FindControl<Grid>("WelcomeStep"),
_ => null
};
if (currentStepControl == null || nextStepControl == null)
{
_isTransitioning = false;
return;
}
await AnimateOpacityAsync(currentStepControl, 1, 0, AnimationDurationMs);
currentStepControl.IsVisible = false;
nextStepControl.IsVisible = true;
nextStepControl.Opacity = 0;
await AnimateOpacityAsync(nextStepControl, 0, 1, AnimationDurationMs);
_currentStep = step;
_isTransitioning = false;
}
private async Task PlayExitAnimationAsync()
{
try
{
var contentGrid = this.FindControl<Grid>("ContentGrid");
if (contentGrid is null)
if (contentGrid != null)
{
await Task.Delay(200);
return;
}
var fadeOutAnimation = new Animation
{
Duration = TimeSpan.FromMilliseconds(200),
Easing = new CubicEaseIn(),
Children =
{
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 1.0) },
KeyTime = TimeSpan.FromMilliseconds(0)
},
new KeyFrame
{
Setters = { new Setter(OpacityProperty, 0.0) },
KeyTime = TimeSpan.FromMilliseconds(200)
}
}
};
await fadeOutAnimation.RunAsync(contentGrid);
Console.WriteLine("[OobeWindow] Exit animation completed");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[OobeWindow] Error playing exit animation: {ex.Message}");
await AnimateOpacityAsync(contentGrid, 1, 0, AnimationDurationMs);
}
}
private double ResolveEntranceOffset()
private static async Task AnimateOpacityAsync(Control element, double from, double to, int durationMs)
{
var boundsHeight = Bounds.Height > 0 ? Bounds.Height : Height;
var scaledOffset = boundsHeight * 0.05;
return Math.Clamp(scaledOffset, 20, 48);
var steps = 20;
var delay = durationMs / steps;
for (int i = 0; i <= steps; i++)
{
var progress = (double)i / steps;
var eased = EaseOutCubic(progress);
element.Opacity = from + (to - from) * eased;
await Task.Delay(delay);
}
}
private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - t, 3);
private static double EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2);
private static double EaseOutBack(double t)
{
const double c1 = 1.70158;
const double c3 = c1 + 1;
var t1 = t - 1;
return 1 + c3 * Math.Pow(t1, 3) + c1 * Math.Pow(t1, 2);
}
}
// 枚举定义(使用 Services 命名空间中的 ThemeMode
public enum MonetSource
{
Wallpaper,
Custom,
Disabled
}

View File

@@ -20,10 +20,21 @@
<views:SplashWindow />
</Design.DataContext>
<Grid RowDefinitions="*,Auto"
Background="#0B0B0B">
<Grid Grid.Row="0">
<Grid x:Name="CompactHero"
<Grid RowDefinitions="*,Auto">
<!-- 背景图片 -->
<Image x:Name="BackgroundImage"
Grid.RowSpan="2"
Stretch="UniformToFill"
IsVisible="False"
Opacity="0"/>
<!-- 半透明遮罩层 -->
<Border x:Name="BackgroundOverlay"
Grid.RowSpan="2"
Background="#0B0B0B"
Opacity="0.85"/>
<Grid Grid.Row="0"
Margin="24">
<TextBlock x:Name="AppNameText"
Text="LanMountain Desktop"
@@ -34,27 +45,6 @@
Foreground="#F6F7FB" />
</Grid>
<Grid x:Name="FullscreenHero"
IsVisible="False">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="24">
<Border Width="240"
Height="240"
Background="Transparent">
<Image Source="/Assets/logo_nightly.png"
Stretch="Uniform" />
</Border>
<TextBlock Text="LanMountain Desktop"
HorizontalAlignment="Center"
FontSize="26"
FontWeight="SemiBold"
Foreground="#F6F7FB" />
</StackPanel>
</Grid>
</Grid>
<Border Grid.Row="1"
Padding="24,18,24,24"
Background="Transparent">

View File

@@ -7,7 +7,6 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Views;
@@ -15,25 +14,14 @@ public partial class SplashWindow : Window, ISplashStageReporter
{
private const int DebugModeClickThreshold = 5;
private static readonly TimeSpan FadeAnimationDuration = TimeSpan.FromMilliseconds(160);
private static readonly TimeSpan SlideAnimationDuration = TimeSpan.FromMilliseconds(260);
private readonly StartupVisualMode _mode;
private int _versionTextClickCount;
private bool _isDebugModeOpened;
private bool _isOpened;
private bool _layoutConfigured;
private bool _dismissed;
private PixelPoint _targetPosition;
private PixelPoint _slideHiddenPosition;
public SplashWindow()
: this(StartupVisualMode.Fade)
{
}
public SplashWindow(StartupVisualMode mode)
{
_mode = mode;
AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded;
Opened += OnWindowOpened;
@@ -41,12 +29,40 @@ public partial class SplashWindow : Window, ISplashStageReporter
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{
InitializeBackgroundImage();
if (this.FindControl<Border>("VersionTextBorder") is { } versionBorder)
{
versionBorder.PointerPressed += OnVersionTextClick;
}
}
private void InitializeBackgroundImage()
{
try
{
var imageInfo = LauncherBackgroundService.LoadBackgroundImage();
if (imageInfo is { IsValid: true, Bitmap: not null })
{
if (this.FindControl<Image>("BackgroundImage") is { } backgroundImage)
{
backgroundImage.Source = imageInfo.Bitmap;
backgroundImage.IsVisible = true;
backgroundImage.Opacity = 1;
}
Logger.Info("[SplashWindow] 背景图片加载成功");
}
else if (imageInfo is { Exists: true, IsValid: false })
{
Logger.Warn($"[SplashWindow] 背景图片校验失败: {imageInfo.ErrorMessage}");
}
}
catch (Exception ex)
{
Logger.Warn($"[SplashWindow] 加载背景图片失败: {ex.Message}");
}
}
private async void OnWindowOpened(object? sender, EventArgs e)
{
if (_isOpened)
@@ -55,20 +71,9 @@ public partial class SplashWindow : Window, ISplashStageReporter
}
_isOpened = true;
ConfigureForVisualMode();
if (_mode == StartupVisualMode.Fade)
{
Opacity = 0d;
await AnimateOpacityAsync(0d, 1d, FadeAnimationDuration).ConfigureAwait(false);
return;
}
Opacity = 1d;
if (_mode == StartupVisualMode.SlideSplash)
{
await AnimateWindowPositionAsync(_slideHiddenPosition, _targetPosition, SlideAnimationDuration, EaseOutCubic).ConfigureAwait(false);
}
}
public async Task DismissAsync()
@@ -79,26 +84,16 @@ public partial class SplashWindow : Window, ISplashStageReporter
}
_dismissed = true;
ConfigureForVisualMode();
if (_mode == StartupVisualMode.SlideSplash)
if (!Dispatcher.UIThread.CheckAccess())
{
var from = Position;
await AnimateWindowPositionAsync(from, _slideHiddenPosition, SlideAnimationDuration, EaseInCubic).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(async () => await DismissAsync());
return;
}
else if (_mode == StartupVisualMode.Fade)
{
await AnimateOpacityAsync(Opacity, 0d, FadeAnimationDuration).ConfigureAwait(false);
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (IsVisible)
{
Close();
}
});
}
public void Report(string stage, string message)
{
@@ -192,46 +187,6 @@ public partial class SplashWindow : Window, ISplashStageReporter
UpdateStatus("[Debug Mode] Splash Preview");
}
private void ConfigureForVisualMode()
{
if (_layoutConfigured)
{
return;
}
_layoutConfigured = true;
var compactHero = this.FindControl<Grid>("CompactHero");
var fullscreenHero = this.FindControl<Grid>("FullscreenHero");
if (_mode == StartupVisualMode.Fade)
{
compactHero?.SetCurrentValue(IsVisibleProperty, true);
fullscreenHero?.SetCurrentValue(IsVisibleProperty, false);
Background = new SolidColorBrush(Color.Parse("#0B0B0B"));
Width = 480;
Height = 320;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
return;
}
compactHero?.SetCurrentValue(IsVisibleProperty, false);
fullscreenHero?.SetCurrentValue(IsVisibleProperty, true);
Background = Brushes.Black;
WindowStartupLocation = WindowStartupLocation.Manual;
var screen = Screens?.Primary ?? Screens?.All.FirstOrDefault();
var workingArea = screen?.WorkingArea ?? new PixelRect(0, 0, 1920, 1080);
var scale = Math.Max(screen?.Scaling ?? 1d, 0.01d);
Width = workingArea.Width / scale;
Height = workingArea.Height / scale;
_targetPosition = new PixelPoint(workingArea.X, workingArea.Y);
_slideHiddenPosition = new PixelPoint(workingArea.X + workingArea.Width, workingArea.Y);
Position = _mode == StartupVisualMode.SlideSplash
? _slideHiddenPosition
: _targetPosition;
}
private void OnVersionTextClick(object? sender, PointerPressedEventArgs e)
{
if (_isDebugModeOpened)
@@ -290,20 +245,6 @@ public partial class SplashWindow : Window, ISplashStageReporter
}, duration, EaseOutCubic).ConfigureAwait(false);
}
private async Task AnimateWindowPositionAsync(
PixelPoint from,
PixelPoint to,
TimeSpan duration,
Func<double, double> easing)
{
await AnimateAsync(progress =>
{
var currentX = (int)Math.Round(from.X + ((to.X - from.X) * progress));
var currentY = (int)Math.Round(from.Y + ((to.Y - from.Y) * progress));
Position = new PixelPoint(currentX, currentY);
}, duration, easing).ConfigureAwait(false);
}
private async Task AnimateAsync(Action<double> update, TimeSpan duration, Func<double, double> easing)
{
if (duration <= TimeSpan.Zero)
@@ -345,6 +286,4 @@ public partial class SplashWindow : Window, ISplashStageReporter
var inverse = 1d - value;
return 1d - (inverse * inverse * inverse);
}
private static double EaseInCubic(double value) => value * value * value;
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -150,6 +150,37 @@ public partial class App : Application
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
// 监听系统主题变化
PropertyChanged += OnAppPropertyChanged;
}
private void OnAppPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == ActualThemeVariantProperty)
{
// 系统主题变化时,检查是否需要更新
var themeMode = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ThemeMode;
if (string.Equals(themeMode, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
var newThemeVariant = (ThemeVariant?)e.NewValue;
var isDark = newThemeVariant == ThemeVariant.Dark;
// 同步到设置
var currentThemeState = _settingsFacade.Theme.Get();
if (currentThemeState.IsNightMode != isDark)
{
_settingsFacade.Theme.Save(currentThemeState with { IsNightMode = isDark });
}
// 应用主题
Dispatcher.UIThread.Post(() =>
{
ApplyThemeFromSettings();
RefreshTrayIconContent();
}, DispatcherPriority.Background);
}
}
}
public override void Initialize()
@@ -762,9 +793,30 @@ public partial class App : Application
private void ApplyThemeFromSettings()
{
var snapshot = _appearanceThemeService.GetCurrent();
var themeMode = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).ThemeMode;
// 处理跟随系统主题模式
if (string.Equals(themeMode, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
// 使用 Avalonia 的系统主题检测
var systemTheme = ActualThemeVariant;
RequestedThemeVariant = systemTheme;
// 同步 IsNightMode 到设置
var isSystemDark = systemTheme == ThemeVariant.Dark;
var currentThemeState = _settingsFacade.Theme.Get();
if (currentThemeState.IsNightMode != isSystemDark)
{
_settingsFacade.Theme.Save(currentThemeState with { IsNightMode = isSystemDark });
}
}
else
{
RequestedThemeVariant = snapshot.IsNightMode
? ThemeVariant.Dark
: ThemeVariant.Light;
}
ApplyAdaptiveThemeResources();
}
@@ -1054,6 +1106,7 @@ public partial class App : Application
var themeChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.CornerRadiusStyle), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -349,6 +349,11 @@
"settings.appearance.title": "Appearance",
"settings.appearance.description": "Adjust theme source, system material, and window chrome.",
"settings.appearance.theme_header": "Theme",
"settings.appearance.theme_mode_label": "Theme mode",
"settings.appearance.theme_mode_desc": "Choose light, dark, or follow system theme.",
"settings.appearance.theme_mode.light": "Light",
"settings.appearance.theme_mode.dark": "Dark",
"settings.appearance.theme_mode.follow_system": "Follow system",
"settings.color.enable_night_mode_toggle": "Enable night mode",
"settings.color.use_system_chrome_toggle": "Use system window chrome",
"settings.color.theme_color_label": "Theme accent color",

View File

@@ -292,6 +292,11 @@
"settings.appearance.title": "外観",
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
"settings.appearance.theme_header": "テーマ",
"settings.appearance.theme_mode_label": "テーマモード",
"settings.appearance.theme_mode_desc": "ライト、ダーク、またはシステムに従うを選択してください。",
"settings.appearance.theme_mode.light": "ライト",
"settings.appearance.theme_mode.dark": "ダーク",
"settings.appearance.theme_mode.follow_system": "システムに従う",
"settings.color.enable_night_mode_toggle": "夜モードを有効にする",
"settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用",
"settings.color.theme_color_label": "テーマのアクセントカラー",

View File

@@ -338,6 +338,11 @@
"settings.appearance.title": "외관",
"settings.appearance.description": "테마 소스, 시스템 소재 및 창 외관을 조정합니다.",
"settings.appearance.theme_header": "테마",
"settings.appearance.theme_mode_label": "테마 모드",
"settings.appearance.theme_mode_desc": "라이트, 다크 또는 시스템 설정 따르기를 선택하세요.",
"settings.appearance.theme_mode.light": "라이트",
"settings.appearance.theme_mode.dark": "다크",
"settings.appearance.theme_mode.follow_system": "시스템 설정 따르기",
"settings.color.enable_night_mode_toggle": "야간 모드 활성화",
"settings.color.use_system_chrome_toggle": "시스템 창 제목 표시줄 사용",
"settings.color.theme_color_label": "테마 강조 색상",

View File

@@ -344,6 +344,11 @@
"settings.appearance.title": "外观",
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
"settings.appearance.theme_header": "主题",
"settings.appearance.theme_mode_label": "主题模式",
"settings.appearance.theme_mode_desc": "选择日间、夜间或跟随系统主题。",
"settings.appearance.theme_mode.light": "日间",
"settings.appearance.theme_mode.dark": "夜间",
"settings.appearance.theme_mode.follow_system": "跟随系统",
"settings.color.enable_night_mode_toggle": "启用夜间模式",
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
"settings.color.theme_color_label": "主题强调色",

View File

@@ -27,6 +27,8 @@ public sealed class AppSettingsSnapshot
public string? SelectedWallpaperSeed { get; set; }
public string ThemeMode { get; set; } = "light";
public string? WallpaperPath { get; set; }
public string WallpaperType { get; set; } = "Image";

View File

@@ -22,6 +22,7 @@ public sealed class Program
public static void Main(string[] args)
{
AppLogger.Initialize();
AppDataPathProvider.Initialize(args);
DevPluginOptions.Parse(args);
RegisterGlobalExceptionLogging();
var restartParentProcessId = LauncherRuntimeMetadata.GetRestartParentProcessId(args);

View File

@@ -0,0 +1,66 @@
namespace LanMountainDesktop.Services;
public static class AppDataPathProvider
{
private static string? _overriddenDataRoot;
public static void Initialize(string[] args)
{
var dataRoot = ResolveDataRootFromArgs(args);
if (!string.IsNullOrWhiteSpace(dataRoot))
{
_overriddenDataRoot = Path.GetFullPath(dataRoot);
AppLogger.Info("AppDataPath", $"Data root overridden by launcher: '{_overriddenDataRoot}'.");
}
else
{
var envDataRoot = Environment.GetEnvironmentVariable("LMD_DATA_ROOT");
if (!string.IsNullOrWhiteSpace(envDataRoot))
{
_overriddenDataRoot = Path.GetFullPath(envDataRoot);
AppLogger.Info("AppDataPath", $"Data root overridden by environment variable: '{_overriddenDataRoot}'.");
}
}
}
public static string GetDataRoot()
{
if (!string.IsNullOrWhiteSpace(_overriddenDataRoot))
{
return _overriddenDataRoot;
}
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
}
public static string GetSettingsDirectory()
{
return GetDataRoot();
}
public static string GetPluginMarketDirectory()
{
return Path.Combine(GetDataRoot(), "PluginMarket");
}
public static string GetWallpapersDirectory()
{
return Path.Combine(GetDataRoot(), "Wallpapers");
}
private static string? ResolveDataRootFromArgs(string[] args)
{
const string prefix = "--data-root=";
foreach (var arg in args)
{
if (arg.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return arg[prefix.Length..];
}
}
return null;
}
}

View File

@@ -24,8 +24,7 @@ public sealed class AppDatabaseService
public AppDatabaseService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dataDirectory = Path.Combine(appData, "LanMountainDesktop");
var dataDirectory = AppDataPathProvider.GetDataRoot();
_databasePath = Path.Combine(dataDirectory, "app.db");
}

View File

@@ -27,8 +27,7 @@ public sealed class AppSettingsService
public AppSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
var settingsDirectory = AppDataPathProvider.GetSettingsDirectory();
_settingsPath = Path.Combine(settingsDirectory, "settings.json");
}

View File

@@ -30,8 +30,7 @@ public sealed class LauncherSettingsService
public LauncherSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
var settingsDirectory = AppDataPathProvider.GetSettingsDirectory();
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}

View File

@@ -76,9 +76,7 @@ internal sealed class SqliteComponentDomainStorage :
public SqliteComponentDomainStorage(string? settingsRoot = null)
{
_settingsRoot = string.IsNullOrWhiteSpace(settingsRoot)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop")
? AppDataPathProvider.GetDataRoot()
: settingsRoot.Trim();
_dbPath = Path.Combine(_settingsRoot, "component-state.db");
_layoutJsonPath = Path.Combine(_settingsRoot, "desktop-layout-settings.json");

View File

@@ -33,7 +33,8 @@ public sealed record ThemeAppearanceSettingsState(
string CornerRadiusStyle = GlobalAppearanceSettings.DefaultCornerRadiusStyle,
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
string? SelectedWallpaperSeed = null);
string? SelectedWallpaperSeed = null,
string ThemeMode = ThemeAppearanceValues.ThemeModeLight);
public sealed record StatusBarSettingsState(
IReadOnlyList<string> TopStatusComponentIds,
IReadOnlyList<string> PinnedTaskbarActions,

View File

@@ -167,10 +167,7 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
public WallpaperMediaService()
{
var appDataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
_wallpapersDirectory = Path.Combine(appDataRoot, "Wallpapers");
_wallpapersDirectory = AppDataPathProvider.GetWallpapersDirectory();
}
public WallpaperMediaType DetectMediaType(string? path)
@@ -269,7 +266,21 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
cornerRadiusStyle,
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
snapshot.SelectedWallpaperSeed);
snapshot.SelectedWallpaperSeed,
NormalizeThemeMode(snapshot.ThemeMode));
}
private static string NormalizeThemeMode(string? value)
{
if (string.Equals(value, ThemeAppearanceValues.ThemeModeDark, StringComparison.OrdinalIgnoreCase))
{
return ThemeAppearanceValues.ThemeModeDark;
}
if (string.Equals(value, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
return ThemeAppearanceValues.ThemeModeFollowSystem;
}
return ThemeAppearanceValues.ThemeModeLight;
}
public void Save(ThemeAppearanceSettingsState state)
@@ -326,6 +337,13 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
changedKeys.Add(nameof(AppSettingsSnapshot.SelectedWallpaperSeed));
}
var normalizedThemeMode = NormalizeThemeMode(state.ThemeMode);
if (!string.Equals(snapshot.ThemeMode, normalizedThemeMode, StringComparison.OrdinalIgnoreCase))
{
snapshot.ThemeMode = normalizedThemeMode;
changedKeys.Add(nameof(AppSettingsSnapshot.ThemeMode));
}
if (changedKeys.Count == 0)
{
return;
@@ -1026,10 +1044,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
{
_pluginRuntimeService = pluginRuntimeService;
var dataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"PluginMarket");
var dataRoot = AppDataPathProvider.GetPluginMarketDirectory();
var cacheService = new AirAppMarketCacheService(dataRoot);
_indexService = new AirAppMarketIndexService(cacheService);
if (_pluginRuntimeService is not null)
@@ -1049,10 +1064,7 @@ internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsServi
return;
}
var dataRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"PluginMarket");
var dataRoot = AppDataPathProvider.GetPluginMarketDirectory();
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
}

View File

@@ -26,9 +26,7 @@ internal sealed class SettingsService : ISettingsService
public SettingsService()
{
var root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
var root = AppDataPathProvider.GetDataRoot();
_pluginSettingsPath = Path.Combine(root, "plugin-settings.json");
}

View File

@@ -13,6 +13,10 @@ public static class ThemeAppearanceValues
public const string ColorSchemeFollowSystem = "follow_system";
public const string ColorSchemeNative = "native";
public const string ThemeModeLight = "light";
public const string ThemeModeDark = "dark";
public const string ThemeModeFollowSystem = "follow_system";
public const string MaterialNone = "none";
public const string MaterialMica = "mica";
public const string MaterialAcrylic = "acrylic";

View File

@@ -1,35 +0,0 @@
<linker>
<!-- Avalonia and UI framework assemblies that should not be trimmed -->
<assembly fullname="Avalonia" preserve="all" />
<assembly fullname="Avalonia.Controls" preserve="all" />
<assembly fullname="Avalonia.Core" preserve="all" />
<assembly fullname="Avalonia.Dialogs" preserve="all" />
<assembly fullname="Avalonia.Desktop" preserve="all" />
<assembly fullname="Avalonia.Themes.Fluent" preserve="all" />
<assembly fullname="Avalonia.Fonts.Inter" preserve="all" />
<!-- FluentUI packages -->
<assembly fullname="FluentAvaloniaUI" preserve="all" />
<assembly fullname="FluentIcons.Avalonia" preserve="all" />
<assembly fullname="FluentIcons.Avalonia.Fluent" preserve="all" />
<!-- Media and rendering -->
<assembly fullname="LibVLCSharp" preserve="all" />
<assembly fullname="LibVLCSharp.Avalonia" preserve="all" />
<assembly fullname="WebView.Avalonia" preserve="all" />
<assembly fullname="WebView.Avalonia.Desktop" preserve="all" />
<!-- MVVM and utilities -->
<assembly fullname="CommunityToolkit.Mvvm" preserve="all" />
<assembly fullname="YamlDotNet" preserve="all" />
<assembly fullname="DotNetCampus.AvaloniaInkCanvas" preserve="all" />
<assembly fullname="PortAudioSharp2" preserve="all" />
<!-- System assemblies with reflection usage -->
<assembly fullname="System.Drawing.Common" preserve="all" />
<assembly fullname="System.Runtime.WindowsRuntime" preserve="all" />
<assembly fullname="System.ComponentModel.TypeConverter" preserve="all" />
<assembly fullname="System.Reflection" preserve="all" />
<assembly fullname="System.Reflection.Emit" preserve="all" />
<assembly fullname="System.Reflection.Emit.Lightweight" preserve="all" />
</linker>

View File

@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Media;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.ComponentSystem;
@@ -576,10 +579,36 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
ThemeColorModes = CreateThemeColorModes();
ThemeModeOptions = CreateThemeModeOptions();
_isInitializing = true;
Load();
_isInitializing = false;
}
partial void OnSelectedThemeModeChanged(SelectionOption value)
{
if (_isInitializing || value is null)
{
return;
}
// 根据选择的主题模式更新夜间模式状态
var newIsNightMode = value.Value switch
{
ThemeAppearanceValues.ThemeModeDark => true,
ThemeAppearanceValues.ThemeModeLight => false,
ThemeAppearanceValues.ThemeModeFollowSystem => Application.Current?.ActualThemeVariant == ThemeVariant.Dark,
_ => IsNightMode
};
if (IsNightMode != newIsNightMode)
{
IsNightMode = newIsNightMode;
}
PersistCurrentState(restartRequired: false);
}
public event Action<string>? RestartRequested;
@@ -595,6 +624,27 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _themeColor = string.Empty;
[ObservableProperty]
private IReadOnlyList<SelectionOption> _themeModeOptions = [];
[ObservableProperty]
private SelectionOption _selectedThemeMode = new(ThemeAppearanceValues.ThemeModeLight, "Light");
[ObservableProperty]
private string _themeModeLabel = string.Empty;
[ObservableProperty]
private string _themeModeDescription = string.Empty;
[ObservableProperty]
private string _themeModeLightText = string.Empty;
[ObservableProperty]
private string _themeModeDarkText = string.Empty;
[ObservableProperty]
private string _themeModeFollowSystemText = string.Empty;
[ObservableProperty]
private Color _customSeedPickerValue = DefaultSeedColor;
@@ -797,16 +847,6 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
UpdatePreview(theme);
}
partial void OnIsNightModeChanged(bool value)
{
if (_isInitializing)
{
return;
}
PersistCurrentState(restartRequired: false);
}
partial void OnUseSystemChromeChanged(bool value)
{
if (_isInitializing)
@@ -887,7 +927,11 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
PageTitle = L("settings.appearance.title", "Appearance");
PageDescription = L("settings.appearance.description", "Adjust theme source, material background, and window chrome.");
ThemeHeader = L("settings.appearance.theme_header", "Theme");
NightModeLabel = L("settings.color.enable_night_mode_toggle", "Enable night mode");
ThemeModeLabel = L("settings.appearance.theme_mode_label", "Theme mode");
ThemeModeDescription = L("settings.appearance.theme_mode_desc", "Choose light, dark, or follow system preference.");
ThemeModeLightText = L("settings.appearance.theme_mode.light", "Light");
ThemeModeDarkText = L("settings.appearance.theme_mode.dark", "Dark");
ThemeModeFollowSystemText = L("settings.appearance.theme_mode.follow_system", "Follow system");
UseSystemChromeLabel = L("settings.color.use_system_chrome_toggle", "Use system window chrome");
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
@@ -957,6 +1001,26 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
SelectedSystemMaterialMode = SystemMaterialModes.FirstOrDefault(option =>
string.Equals(option.Value, savedSystemMaterialMode, StringComparison.OrdinalIgnoreCase))
?? SystemMaterialModes[0];
// 应用主题模式设置
var savedThemeMode = NormalizeThemeMode(theme.ThemeMode);
SelectedThemeMode = ThemeModeOptions.FirstOrDefault(option =>
string.Equals(option.Value, savedThemeMode, StringComparison.OrdinalIgnoreCase))
?? ThemeModeOptions.FirstOrDefault(o => o.Value == ThemeAppearanceValues.ThemeModeLight)
?? new SelectionOption(ThemeAppearanceValues.ThemeModeLight, ThemeModeLightText);
}
private static string NormalizeThemeMode(string? value)
{
if (string.Equals(value, ThemeAppearanceValues.ThemeModeDark, StringComparison.OrdinalIgnoreCase))
{
return ThemeAppearanceValues.ThemeModeDark;
}
if (string.Equals(value, ThemeAppearanceValues.ThemeModeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
return ThemeAppearanceValues.ThemeModeFollowSystem;
}
return ThemeAppearanceValues.ThemeModeLight;
}
private void PersistCurrentState(bool restartRequired)
@@ -984,6 +1048,16 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
}
}
private IReadOnlyList<SelectionOption> CreateThemeModeOptions()
{
return
[
new SelectionOption(ThemeAppearanceValues.ThemeModeLight, ThemeModeLightText),
new SelectionOption(ThemeAppearanceValues.ThemeModeDark, ThemeModeDarkText),
new SelectionOption(ThemeAppearanceValues.ThemeModeFollowSystem, ThemeModeFollowSystemText)
];
}
private ThemeAppearanceSettingsState BuildPendingState(bool usePickerSeed)
{
var themeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(SelectedThemeColorMode?.Value, ThemeColor);
@@ -998,7 +1072,8 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
GlobalAppearanceSettings.NormalizeCornerRadiusStyle(CornerRadiusStyle),
themeColorMode,
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
_selectedWallpaperSeed);
_selectedWallpaperSeed,
SelectedThemeMode?.Value ?? ThemeAppearanceValues.ThemeModeLight);
}
private void UpdatePreview(ThemeAppearanceSettingsState pendingState)

View File

@@ -6,6 +6,19 @@
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.AboutSettingsPage"
x:DataType="vm:AboutSettingsPageViewModel">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Default">
<ImageBrush x:Key="AboutBannerBrush" Source="/Assets/about_banner_light.png" Stretch="Uniform" AlignmentX="Center" AlignmentY="Center" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<ImageBrush x:Key="AboutBannerBrush" Source="/Assets/about_banner_dark.png" Stretch="Uniform" AlignmentX="Center" AlignmentY="Center" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="StackPanel.about-page-container">
<Setter Property="HorizontalAlignment" Value="Stretch" />
@@ -38,10 +51,7 @@
Classes="about-hero-card"
Height="240"
PointerPressed="OnAboutHeroCardPointerPressed">
<Image Source="/Assets/about_banner.png"
Stretch="Uniform"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
<Panel Background="{DynamicResource AboutBannerBrush}" />
</Border>
<TextBlock Classes="settings-subsection-title"

View File

@@ -13,12 +13,21 @@
Text="{Binding ThemeHeader}"
Margin="0,0,0,4" />
<ui:SettingsExpander Header="{Binding NightModeLabel}">
<ui:SettingsExpander Header="{Binding ThemeModeLabel}"
Description="{Binding ThemeModeDescription}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="WeatherMoon" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsNightMode}" />
<ComboBox Width="200"
ItemsSource="{Binding ThemeModeOptions}"
SelectedItem="{Binding SelectedThemeMode}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:SelectionOption">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>