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