Compare commits

...

63 Commits

Author SHA1 Message Date
lincube
458494d131 Add update contracts, IPC progress & providers
Introduce a new update subsystem: shared contracts for manifests, messages, paths and state (LanMountainDesktop.Shared.Contracts.Update). Add IPC and reporting infrastructure for installer progress (IUpdateProgressReporter, LauncherUpdateProgressIpcServer, NullUpdateProgressReporter) and integrate progress/complete reporting into UpdateEngineService. Add multiple update service components and providers (CompositeManifestProvider, GithubReleaseManifestProvider, PlondsApiManifestProvider, UpdateDownloadEngine, UpdateOrchestrator, UpdateInstallGateway, CLI launcher bridge, launcher bridge interfaces, observable helper, state store, progress subject, JSON context). Update settings and models to support UseGhProxyMirror and PLONDS/GitHub fallback logic, plus localization strings and UI/viewmodel files for update settings and progress. Misc: installer script tweak and a small change in Plonds generator. This adds end-to-end support for checking, downloading and reporting update progress and results.
2026-05-03 19:31:04 +08:00
lincube
01670147f6 Bump packages; fix resume flag & Sentry attach
Update workspace settings and dependency versions, plus small service fixes. .arts/settings.json adds editor clawMode and activityBar location. Directory.Packages.props upgrades many packages (e.g. Avalonia to 12.0.2, CommunityToolkit.Mvvm, Downloader, Sentry to 6.4.1, Microsoft.* previews, and more). ResumableDownloadService renamed ResumeDownloadIfCan to EnableAutoResumeDownload. SentryCrashTelemetryService now adds the log-tail as an attachment via scope.AddAttachment(byte[], name, contentType) instead of creating an Attachment object.
2026-05-01 13:59:52 +08:00
lincube
0348324fa3 Add LauncherPathResolver and refactor data paths
Introduce LauncherPathResolver to centralize resolving the launcher executable and .Launcher data directory (with write-permission fallback). Refactor DataLocationResolver to use a fixed ".Launcher" launcher data folder, expose explicit ResolveLauncherDataPath/ResolveDesktopDataPath/ResolveConfigPath/ResolveLauncherLogsPath/ResolveLauncherStatePath methods, and fix config load/save and directory creation to avoid dependency cycles. Update LauncherClient, UpdateWorkflowService and PluginMarketInstallService to use the new resolver (remove duplicated ResolveLauncherPath logic) and improve error messages when the launcher is missing.
2026-05-01 11:31:40 +08:00
lincube
fc4d0c4cd8 Support .laapp/plugin.json and improve market models
Add support for the new plugin package contract (.laapp + plugin.json) while keeping backward compatibility with legacy .lmdp/manifest.json, and improve market metadata resolution and launcher handling.

Key changes:
- LanMountainDesktop.Launcher: PluginInstallerService now recognizes plugin.json and .laapp, preserves legacy manifest/package names, searches for manifests with a helper, and removes existing packages matching either extension.
- LanMountainDesktop.PluginTemplate: README updated to document .laapp, plugin.json, runtime contract and packaging expectations.
- Tests: New and extended tests for PluginInstallerService and a PluginMarketIndexDocumentTests covering nested index parsing and metadata enrichment.
- LauncherClient & PluginMarketInstallService: ResolveLauncherPath now probes multiple candidate locations (useful for dev and packaged layouts); LauncherClient also adjusted launcher arguments to use the updated CLI form.
- SettingsDomainServices: Added BuildCapabilities to safely build capability lists from entries (null checks, projection, de-dup via DistinctBy).
- AirAppMarketMetadataResolverService & PluginMarketModels: Prefer existing manifest/publication/compatibility values when enriching entries, add ApiVersion/Path fields, normalize compatibility logic and package source URL/path handling; handle Sha256/size/publication dates more robustly.
- Misc: Added localization spec/checklist/tasks under .trae for a localization fix initiative.

These changes enable the new plugin packaging format, improve robustness of market data enrichment, make launcher discovery more flexible for different environments, and add tests and docs to cover the new behaviors.
2026-04-30 00:02:52 +08:00
lincube
eb066b53f1 Introduce render mode & static component previews
Add DesktopComponentRenderMode and thread the render mode through runtime context and creation APIs so controls can be created for library previews. Replace image-based preview system with static preview Controls: viewmodels and ComponentLibraryWindow now use PreviewControl, and ComponentPreviewImageService/related types and tests were removed. Add ComponentPreviewRuntimeQuiescer to attach/detach preview controls (stop timers, disable input) for safe static previews. Simplify component-library collapse state/presenter by removing transient expanded opacity handling. Update runtime registry, services, views and tests to support the new flow.
2026-04-29 19:43:29 +08:00
lincube
5ea242af9a Lock swipe handling to initiating pointer
Track the pointer id that starts a swipe and restrict subsequent pointer events to that id. Added nullable _desktopSwipePointerId and _swipePointerId fields, IsDesktopSwipePointer/IsSwipePointer helpers, and set/cleared the ids at swipe start/end. Guard pointer moved/released/capture-lost handlers to ignore other pointers and avoid unintended cancellation or interference from additional touches or mouse input. Changes in MainWindow.DesktopPaging.cs and TransparentOverlayWindow.axaml.cs improve multi-pointer robustness for desktop paging and overlay swipe interactions.
2026-04-29 17:25:51 +08:00
lincube
abfa64b3d7 Avalonia12 (#7)
* ava12升级

* Enable centralized package versioning

Add <Project> and <PropertyGroup> with <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> to Directory.Packages.props to enable centralized package version management across the repository. This allows package versions to be controlled from this single file instead of individual project files.

* Migrate codebase to Avalonia 12 APIs

Apply Avalonia 12 migration changes: replace SystemDecorations with WindowDecorations and remove ExtendClientAreaChromeHints/ExtendClientAreaTitleBarHeightHint usages; update BindingPlugins removal logic (no-op); switch clipboard usage to ClipboardExtensions.SetTextAsync; update Bitmap.CopyPixels calls to the new signature. Replace TextBox.Watermark with PlaceholderText, convert NumberBox styles to FANumberBox and adjust templates, change Checked/Unchecked handlers to IsCheckedChanged, and adapt FluentIcons usages (SymbolIconSource -> FASymbol/FAFont/FluentIcon equivalents). Fix MainWindow partial classes to inherit Window and correct missing variables/fields/usings. Add migration docs/specs/tasks under .trae and include a small TestFluentIcons project for icon testing.

* Migrate to Avalonia 12 and Plugin SDK v5

Upgrade project to the Avalonia 12 baseline and Plugin SDK v5: centralize Avalonia packages, remove legacy WebView.Avalonia usage (use NativeWebView/WebView2 EnvironmentRequested), and update Fluent/Material icon/package usages. Bump multiple package/project versions to 5.0.0 and Avalonia 12.0.1, update plugin template and README/docs to SDK v5, and add PLUGIN_SDK_V5_MIGRATION.md.

Also fix runtime/behavior bugs: make DataLocationResolver use a fixed bootstrap launcher data path and avoid recursive ResolveDataRoot; add legacy-state handling and extraction in OobeStateService; and update component settings tests to reflect migrated storage (DB/backup) and reset cache for test reloads. Various csproj, tests, and docs updated to reflect the migration and ensure build/test compatibility.

* Update icon glyphs and symbol mappings

Replace and refine icon sources across settings pages and controls: many FAFontIconSource glyphs were updated to specific Seagull Fluent Icons codepoints, some FASymbolIconSource usages were replaced with FAFontIconSource, and a number of symbol-to-Symbol enum mappings were adjusted (e.g. "Bell" -> AlertOn, "Shield" -> ShieldLock). Also clarified a comment in SettingsWindow and fixed a trailing newline in StudySettingsPage. Changes standardize icon visuals and bridge FluentIcons glyphs into FluentAvalonia icon sources.

* fix.修复合并产生的问题。
2026-04-29 12:14:29 +08:00
lincube
cbaf2a0c38 Add privacy agreement UI, models, and service
Introduce privacy/telemetry support: add PrivacyConfig and PrivacyAgreementState models, and a PrivacyAgreementService that saves/validates agreement state with HMAC integrity protection (privacy-agreement.state.json). Update AppJsonContext to include new types. Extend OOBE UI (OobeWindow.axaml/.cs) with a Data Location redesign and a new Privacy step (telemetry toggles, telemetry ID, agreement checkbox) and wire up handlers to save privacy-config.json and agreement state. Add a PrivacyPolicyWindow using Markdown.Avalonia to display the privacy policy; add CommunityToolkit.Mvvm and Markdown.Avalonia package references.
2026-04-27 23:01:49 +08:00
lincube
0e45c836c9 fix.解决合并时遇到的问题。 2026-04-26 00:48:59 +08:00
lincube
0b603384b4 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.
2026-04-25 18:41:26 +08:00
lincube
0085c66514 Introduce HostLaunchPlan and refine launch flow
Add HostLaunchPlan/HostLaunchPlanBuilder to encapsulate host path, package root, working dir, forwarded args and env; add unit tests for builder. Refactor LauncherFlowCoordinator to use HostLaunchPlan when starting hosts, improve IPC handling and startup logic (shorter soft/hard timeouts, more frequent reconnects and shell status polling, activation recovery via existing host). Move argument formatting and environment setup into the plan, include package/working/args metadata in start attempts. Update Commands to prefer ProcessPath for launcher base directory. App and Program: start single-instance activation listener earlier and harden ActivateMainWindow to handle shell initialization state and return richer activation status codes. SingleInstanceService: signal listener readiness (ManualResetEventSlim) and wait briefly when starting, and dispose it. Various logging and minor error handling improvements.
2026-04-23 23:07:37 +08:00
lincube
d4901e436f Add launcher debug settings, recovery & version fixes
Introduce a persistent LauncherDebugSettingsStore and wire it into ErrorWindow and SplashWindow so dev-mode and custom host path can be saved/loaded. Harden DeploymentLocator/FlexibleHostLocator to safely normalize and validate saved debug paths and log warnings for malformed values. Add a WaitingForShell startup state and recoverable-activation logic across App and LauncherFlowCoordinator (with registry updates) so Launcher can attach to an in-progress desktop shell rather than failing. Clean up ErrorDebugWindow UI/flow (WasAccepted flag, localization fixes, event wiring) and improve splash version population. Improve AppVersionProvider to trim surrounding quotes, robustly parse version.json via JsonDocument and read string properties; add unit tests for AppVersionProvider, DeploymentLocator and LauncherDebugSettingsStore. Also quote Exec commands in the csproj and harden scripts/Generate-VersionFile.ps1 (argument normalization, LiteralPath, error handling).
2026-04-23 19:04:39 +08:00
lincube
2d9391f930 Add HostShutdownGate and shutdown handling
Introduce HostShutdownGate to serialize and record the first host shutdown request (Restart preferred over later Exit). Add tests (HostShutdownGateTests) and a tray-menu spec describing shutdown requirements. Integrate the gate into App: expose IsShutdownInProgress, ignore tray/settings/component-library actions during shutdown, reuse/track the fused component library window, ensure edit-mode exit on failures, and close the library during shutdown. Add TrySubmitShutdown to commit shutdown intent, schedule forced termination, perform exit cleanup, and invoke desktop lifetime shutdown. Update HostApplicationLifecycleService to use the new TrySubmitShutdown flow for Exit/Restart. Harden DesktopTrayService.Dispose to clear icons and dispose the tray icon safely. These changes ensure irreversible shutdown commits, prevent UI reopening during shutdown, preserve restart intent, and avoid duplicate or conflicting shutdown actions.
2026-04-23 14:18:09 +08:00
lincube
927dc8d1fd Add launcher coordinator IPC and startup reservation
Introduce a launcher coordinator to reserve startup ownership and prevent duplicate host launches. Adds a NamedPipe-based IPC server/client (LauncherCoordinatorIpcServer/Client), coordinator messages/models, and PublicShellStatus/activation types for richer shell reporting. Enhances StartupAttemptRecord and StartupAttemptRegistry to track coordinator pid/pipe, heartbeat, reserved-before-host-start, and public IPC status, plus new reservation/heartbeat APIs and takeover logic. Wire coordinator into App and LauncherFlowCoordinator to attach secondary launchers, publish coordinator status, probe existing hosts, and include more detailed launch result details. Also adds unit tests and docs describing coordinator and startup visuals behavior.
2026-04-23 09:45:05 +08:00
lincube
33591a0a63 Add startup visual modes and attempt registry
Implement startup visual behavior, de-duplicate startup attempts, and improve failure UX.

Key changes:
- Add spec and docs for startup visuals and timing contract (.trae/specs and docs/LAUNCHER_STARTUP_VISUALS.md).
- Introduce StartupVisualPreferences contract and resolver; create SplashWindow via resolved mode.
- Add StartupAttemptRecord model and a file-backed StartupAttemptRegistry to persist and coordinate in-progress startup attempts (attach/adopt, soft/hard timeouts, IPC/connect state, lifecycle updates).
- Update LauncherFlowCoordinator to: adopt/attach to existing attempts, track IPC connection and soft/hard timeouts (30s/120s), show delayed UI state, attempt foreground recovery via public IPC, compose detailed launch result metadata, and mark registry states (soft timeout, detached waiting, succeeded, failed).
- Add TryActivateExistingInstanceAsync to attempt activating an existing desktop via IPC.
- Change failure flow: ShowFailureWindowAsync now returns user choice; ErrorWindow updated to present Activate/Wait/Open Logs/Exit semantics and new layouts/styles; improved button wiring and debug/dev mode handling.
- Add UI and resource tweaks (ErrorWindow and SplashWindow changes), project asset link for nightly logo, and unit tests for StartupVisualPreferences.

These changes prevent duplicate desktop processes during slow startups, provide clearer UX for delayed startups, and persist startup attempt state across Launcher invocations for safer recovery/attach behavior.
2026-04-23 09:03:35 +08:00
lincube
001d77968f Stamp release versions and harden launcher
Add automatic release version stamping and multiple launcher reliability improvements. The Release workflow now runs scripts/Set-ReleaseVersion.ps1 in build jobs to inject tag-derived Version/AssemblyVersion into project metadata; several .csproj/Directory.Build.props and app.manifest files were changed to use a dev placeholder. Introduced AppVersionProvider (and related runtime metadata) to centralize version resolution and updated DeploymentLocator to use it and to prefer package-root/version.json. Launcher startup flow was hardened: added startup success tracking, public-activation recovery path, improved success/fallback semantics, and related IPC handling. UI/UX fixes include OOBE entrance/exit animation improvements (scaling-aware, concurrent fade+translate) and minor window lifecycle reorder in DesktopShellHost. CommandContext now recognizes restart and key=value args. New DesktopTrayService and .trae spec files (spec, checklist, tasks) document shell/tray hardening work. Miscellaneous logging, comments and housekeeping edits across launcher and shared contracts to support the above.
2026-04-23 00:27:01 +08:00
lincube
e20462ac2b Make settings window independent and taskbar-aware
Convert the settings window into an independent top-level window with its own taskbar icon and open-or-focus semantics. Removed Owner/anchor/toggle semantics from SettingsWindowService and added ScreenReferenceWindow for centering; settings windows now ShowInTaskbar = true and are truly destroyed on close. Added SettingsWindowPlacementHelper and tests for placement/centering. Main window now respects an AppSettingsSnapshot.ShowInTaskbar flag (new setting exposed in GeneralSettings UI) and slide/visibility animations and "back to Windows" behavior no longer affect the independent settings window. Updated various callers to use OpenIndependentSettingsModule, adjusted window transitions/X offsets, and added/updated spec files documenting the feature and animation boundary.
2026-04-22 20:46:43 +08:00
lincube
aa7c118d13 Add external public IPC host/client and plugin SDK
Introduce a new LanMountainDesktop.Shared.IPC project implementing a public IPC host and client (LanMountainDesktopIpcClient, PublicIpcHostService), IPC constants and routed notify IDs, DTOs and DI helpers for registering public services. Update Plugin SDK to allow plugins to contribute public IPC services and registrations, add related descriptors/records and extension helpers. Migrate Launcher/App to use the new public IPC for startup/loading notifications and wiring (including TryConnect helper), switch LoadingStateReporter to use the external notification publisher, and add host-side public services (app info, shell control, plugin catalog). Include integration tests and spec/checklist/docs for the external IPC public API.
2026-04-22 14:55:30 +08:00
lincube
f51ec309a6 Add plugin isolation IPC scaffolding and host phase one docs (#5) 2026-04-22 10:25:46 +08:00
lincube
9224c9a33a Harden OOBE, launch-source and elevation flow
Introduce a per-user OOBE state model and hardened launch/elevation handling. Adds OobeStateFile/OobeLaunchDecision models, OobeStateService (persisting %LOCALAPPDATA%/.launcher/state/oobe-state.json), and LauncherExecutionContext to capture elevation and user SID. CommandContext now normalizes/infers launch-source values (normal, postinstall, apply-update, plugin-install, debug-preview) and exposes maintenance checks. LauncherFlowCoordinator propagates richer launcher context details for diagnostics and suppresses OOBE for elevated/maintenance contexts. PluginInstallerService avoids requesting elevation for user-scoped installs and returns a clear error when installation target is outside the current user's LocalAppData. LauncherClient maps and surfaces result codes, UpdateWorkflow and installer invocation now pass explicit --launch-source values, and WelcomeOobeStep persists OOBE completion via the new service. Adds unit tests (CommandContext, OobeStateService, PluginInstallerService), docs/specs/checklists for the contract, and makes internals visible to tests.
2026-04-22 09:25:22 +08:00
lincube
703ed7b48a Refactor launcher startup, logging & host resolution
Improve launcher startup flow, logging, and host resolution. Key changes: add detailed startup logging and standardized preview messages; unify CLI vs GUI handling and error/result reporting (write result file when requested); refactor DeploymentLocator to a more robust host resolution (new HostResolutionResult, explicit/portable/published/debug resolution paths, legacy fallback); overhaul LauncherFlowCoordinator to better handle IPC stages, activation retries, window lifecycle, plugin/update flows and error reporting; add CommandContext helpers (IsGui/IsPreview/ExplicitAppRoot) and JSON context options; tighten async usage and ConfigureAwait calls; add better UI error handling and consistent exit codes. Several UX/debug conveniences and robustness fixes included.
2026-04-22 07:31:54 +08:00
lincube
5af7ac8b56 Normalize release artifacts before publishing 2026-04-21 21:19:04 +08:00
lincube
4cb52e56c7 Launcher (#4)
* 激进的更新

* 试试

* fix.可爱的我一直在修CI(

* fix.启动器一定要能够启动

* feat.尝试弄了AOT的启动器。

* fix.修CI,好像是因为Linux那边有个问题,反正修就对了。

* fix.ci难修,为什么liunx跑不起来呢?

* Update build.yml

* Update LanMountainDesktop.csproj

* changed.调整了启动逻辑,优化了更新页面。

* changed.优化了更新体验

* feat.依旧试增量更新这一块,看看velopack

* fix.我们试验性地修复了启动器无法正常启动的问题,原因可能是这个画面没有启动,就GUI没显示。然后还把编译问题修了一下。

* fix.继续修ci,ci怎么天天炸

* changed.velopack,试试rust

* fix.修ci,修融合桌面,修启动器

* fix.GitHub Action工作流怎么天天出问题

* feat.引入velopack,不好,是rust(至少内存很安全了。

* chore: migrate release pipeline to signed filemap and wire rainyun s3

* fix: make optional s3 upload step workflow-parse safe

* fix: make delta pack generation robust for empty diffs and linux paths

* chore: rotate launcher update public key for pdc signing

* fix: restore stable launcher update public key

* fix: sync launcher public key with update signing secret

* fix: normalize PEM line endings in signing key validation

* fix: rotate launcher public key to match ci signing secret

* fix: compare signing keys by SPKI instead of PEM text

* refactor update backend to host-managed PDC pipeline

* fix release workflow env key collisions

* relax publish-pdc precheck to require S3 only

* set GH_TOKEN for PDCC installer step

* ci: add local pdc mock fallback for release publish

* ci: fix pdc mock process log redirection

* ci: fallback pdcc signing key to update private key

* ci: ensure pdcc signing passphrase env is always set

* ci: create pdcc publish root before invoking client

* ci: set pdcc version variable from release version

* ci: decouple pdcc installer version from publish config version

* ci: package pdcc subchannels with generated filemap and changelog

* ci: make local pdc mock diff return empty for fast fallback

* ci: fix pdcc variable mapping and pdc signing prechecks

* Update App.axaml.cs

* ci: wire aws cli credentials for rainyun s3

* ci: pin pdcc client version separately from app version

* ci: harden local pdc mock transport handling

* ci: publish pdcc subchannels in one pass

* ci: add pdcc publish heartbeat and timeout

* ci: fix pdcc publish workdir bootstrap

* feat.Penguin Logistics Online Network Distribution System

* ci: fix plonds s3 probe and signing fallback

* ci: validate signing key and quiet missing baselines

* ci: relax aws checksum mode for rainyun s3

* ci: avoid multipart uploads to rainyun s3

* ci: handle empty plonds baselines safely

* ci.plonds

* Rebuild release pipeline around PLONDS and DDSS

* Fix Windows installer script path in release workflow
2026-04-21 20:59:52 +08:00
lincube
03e32ee6cb feat.网速显示组件引入了一套更好的等距。 2026-04-15 15:42:11 +08:00
lincube
c2cc62b58b feat.淡入淡出动画。 2026-04-15 10:49:04 +08:00
lincube
9c529f2992 feat.SDK更新 2026-04-14 16:47:32 +08:00
lincube
1e9ead8bee feat.SDK加入了FA的引用。 2026-04-14 12:25:28 +08:00
lincube
5f7b3a1e7d removed.移除了不附带.NET 10的轻量版安装包。 2026-04-14 00:52:16 +08:00
lincube
b12dd68ba7 fix.开发者调试工具设置无法正常持久化的问题。修复了插件无法进行更新的问题。 2026-04-14 00:22:02 +08:00
lincube
1b22e9df4a feat.新增了插件开发文档 2026-04-13 19:54:37 +08:00
lincube
ce5acf5bd7 fix.修复了快捷方式组件无法正常透明的问题。 2026-04-13 16:26:23 +08:00
lincube
b933f3badf changed.调整了开发者选项 2026-04-13 13:14:58 +08:00
lincube
76d13ac024 feat.开发者调试工具 2026-04-13 08:02:47 +08:00
lincube
99a82d64e3 change.插件设置支持View 2026-04-13 01:23:11 +08:00
lincube
692ca3de3d Update CHANGELOG.md 2026-04-12 20:20:15 +08:00
lincube
d62226ffa0 fix. 试验性的修复了轻量版的Dotnet问题 2026-04-12 17:28:33 +08:00
lincube
91ab52ce8b change.插件sdk更新 2026-04-12 13:52:52 +08:00
lincube
4a89c2388b feat.便签组件 2026-04-12 12:14:25 +08:00
lincube
cb96180118 feat.白板笔色自适应主题 2026-04-12 01:10:12 +08:00
lincube
cf4b8e2132 fix.央广网新闻组件第二行显示修复,课程表显示修复。 2026-04-11 03:43:41 +08:00
lincube
e8ba847328 fix.我又改了一下融合桌面的设置窗口。 2026-04-11 00:35:27 +08:00
lincube
2156922039 feat.试验性地改了一下融合桌面的组件库,反正还是不能用。 2026-04-10 22:13:53 +08:00
lincube
e795e9964e feat.增加了无.net10的安装包版本,实验性的修改了融合桌面设置下的组件库样式。 2026-04-10 12:20:05 +08:00
lincube
11130cfdb3 feat.更新界面多标题修复。支持了,应用启动台不显示应用卡片背景。。。 2026-04-09 19:15:06 +08:00
lincube
66ae0b0270 fix.课表组件日间模式字体颜色修复 2026-04-09 00:53:28 +08:00
lincube
a671db8b69 更新 README.md 2026-04-08 23:32:39 +08:00
lincube
8c94253f92 fix.快捷方式组件的透明问题修复。顺便修了一下电源菜单。 2026-04-08 17:39:19 +08:00
lincube
6849a467d6 fead.快捷方式组件。fix.优化了噪音检测组件与白板组件的性能 2026-04-08 16:22:32 +08:00
lincube
e69bbf8b19 feat.加入快捷方式组件 2026-04-08 02:09:17 +08:00
lincube
d30af21317 docs.加入changelog 2026-04-08 01:45:26 +08:00
lincube
8583465a67 fead.圆角,终于统一 2026-04-08 00:55:10 +08:00
lincube
e1d5a0c6de fead.添加了电源菜单 2026-04-07 12:18:15 +08:00
lincube
5fa2031ad6 fead.消息盒子组件 2026-04-07 00:49:33 +08:00
lincube
0662565dca fead.为文件管理组件添加了跨平台的支持 2026-04-05 14:02:07 +08:00
lincube
12a2f6729b fead.文件管理组件加入 2026-04-04 03:28:51 +08:00
lincube
5d2449fa8f fead.加入jiangtokoto数据源 2026-04-04 02:13:26 +08:00
lincube
00339f0ed0 fix.修Rinshub,怎么不是色色就是逆天 2026-04-03 22:55:35 +08:00
lincube
021c7ff245 fix.还是在修智教Hub组件 2026-04-03 22:07:38 +08:00
lincube
675096b6c4 fead.做了状态栏加了更多的胶囊组件。然后我稍微修了一下智教Hub组件 2026-04-03 21:25:15 +08:00
lincube
1c3cc76f21 fead.做了状态栏文字组件,支持了位置放置。 2026-04-03 13:14:20 +08:00
lincube
44b87ba12e fead.桌面组件 2026-04-03 11:42:00 +08:00
lincube
35976c3f3d fead.做桌面组件ing,智教hub加了rinshub 2026-04-03 01:17:47 +08:00
lincube
88bd92e40a fead.Hub组件支持双击打开图片,支持三指翻页退出应用 2026-04-02 21:12:06 +08:00
590 changed files with 85318 additions and 5838 deletions

View File

@@ -1,3 +1,5 @@
{
"diffEditor.renderSideBySide": false
"diffEditor.renderSideBySide": false,
"clawMode.mode": "editor",
"workbench.activityBar.location": "default"
}

View File

@@ -1,127 +1,65 @@
# 版本号自动同步说明
# 版本同步说明
## 📋 概述
## 目标
从本次更新开始Release 工作流已配置为**自动同步版本号**,确保应用的每个版本号来源都保持一致。
发布版的用户可见版本必须统一指向“应用版本”,不能再出现:
## 🔄 版本号流转链路
- Launcher UI 显示 `1.0.0`
- 应用设置页显示 `0.8.x`
- `version.json`、安装包、Release 资产名称各写各的
```
Git Tag (v1.0.1)
[Release 工作流 prepare 任务]
提取版本号: 1.0.1
[Update version in .csproj] ✨ 新增步骤
自动更新 .csproj 文件版本号
dotnet restore/build
构建时读取更新后的版本号
应用内显示版本号 (MainWindow.Localization.cs 动态读取)
```
## 默认仓库状态
## 🎯 工作原理
仓库内的静态版本现在故意保留为开发占位值:
### 1. 版本号提取
当推送 Git Tag 时(如 `git tag v1.0.1`Release 工作流的 `prepare` 任务自动提取版本号:
- TAG: `v1.0.1` → VERSION: `1.0.1`
- `Directory.Build.props`
- `LanMountainDesktop/LanMountainDesktop.csproj`
- `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
- `LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj`
- `LanMountainDesktop/app.manifest`
- `LanMountainDesktop.Launcher/app.manifest`
### 2. 自动更新 .csproj
在三个平台的构建任务中,新增了 **"Update version in .csproj"** 步骤:
这些值只是提醒“当前不是正式注入构建”,不能代表发布版本。
**Windows (PowerShell)**:
```powershell
$VERSION = "1.0.1"
(Get-Content file.csproj) -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>" | Set-Content file.csproj
```
## Release 工作流怎么做
**Linux/macOS (Bash)**:
```bash
VERSION="1.0.1"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
```
Release 工作流会先从 tag 提取版本:
### 3. 构建和发布
更新后的版本号被用于:
- 程序集版本 (`AssemblyVersion`)
- 包文件名 (`LanMountainDesktop-1.0.1-win-x64.zip`)
- 应用内显示 (About 页面)
- GitHub Release 标题
- `v0.8.5.2` -> `0.8.5.2`
- 程序集四段版本 -> `0.8.5.2`
## 📍 涉及的文件
随后显式执行:
自动更新的文件:
1. `LanMountainDesktop/LanMountainDesktop.csproj`
- `scripts/Set-ReleaseVersion.ps1`
## ✅ 使用流程
这个步骤会同步更新:
### 发布新版本
- 主程序 `.csproj``Version`
- Launcher `.csproj``Version`
- Shared.Contracts `.csproj``Version`
- `Directory.Build.props`
- 主程序 `app.manifest`
- Launcher `app.manifest`
```bash
# 1. 更新代码(可选:代码中的版本号现在会自动更新)
git add .
git commit -m "feat: Add new features"
之后构建和发布阶段继续通过 MSBuild 属性注入:
# 2. 创建版本标签
git tag v1.0.1
# 或带注释的标签
git tag -a v1.0.1 -m "Release v1.0.1"
- `Version`
- `AssemblyVersion`
- `FileVersion`
- `InformationalVersion`
# 3. 推送标签到 GitHub
git push origin v1.0.1
因此最终会统一落到:
# 4. Release 工作流自动运行:
# - 自动更新 .csproj 文件
# - 构建所有平台
# - 创建 GitHub Release
# - 附带所有平台的发布包
```
- Launcher UI 读取到的应用版本
- 应用设置页显示的版本
- `version.json`
- 程序集文件版本
- Windows manifest
- 安装包版本
- GitHub Release 资产名称
## 🔒 版本号一致性保证
## 维护规则
现在应用的三个版本号来源完全同步:
| 来源 | 说明 | 自动更新 |
|------|------|--------|
| `.csproj` <Version> | 项目文件版本 | ✅ 是 |
| 程序集版本 | 编译时读取 | ✅ 是 |
| 应用内显示 | About 页面 | ✅ 是 |
| 发布包文件名 | Release 工作流 | ✅ 是 |
| GitHub Release | Release 工作流 | ✅ 是 |
## ⚠️ 注意事项
### 不需要手动更新
- ❌ 不需要在 `.csproj` 中手动修改 Version
- ❌ 不需要修改多个地方的版本号
### 只需执行
- ✅ 创建 Git Tag: `git tag v1.0.1`
- ✅ 推送 Tag: `git push origin v1.0.1`
- ✅ 其他由工作流自动处理
## 📊 版本号格式
支持的格式:
-`v1.0.0` (builds -> 1.0.0)
-`v1.2.3` (builds -> 1.2.3)
-`v2.0.0-rc1` (builds -> 2.0.0-rc1, 如果需要)
## 🛠️ 工作流文件
更新的工作流文件:
- `.github/workflows/release.yml` - Release 工作流
## 📝 相关文件
- [MULTIPLATFORM_RELEASE_GUIDE.md](./MULTIPLATFORM_RELEASE_GUIDE.md) - 多平台发布指南
- [WORKFLOWS_GUIDE.md](./WORKFLOWS_GUIDE.md) - 工作流使用指南
---
**最后更新**: 2026-03-04
**工作流版本**: 2.0 (自动版本同步)
- 日常开发不要手动把仓库默认版本改成正式版本号。
- 正式发版只需要打 tag版本同步交给工作流。
- 如果新增新的版本承载点,必须同时补到 `Set-ReleaseVersion.ps1` 和 Release 工作流里。

View File

@@ -1,4 +1,4 @@
name: Build
name: Build
on:
push:
@@ -10,6 +10,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
jobs:
build-windows:
@@ -31,6 +32,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -63,12 +65,22 @@ jobs:
sudo apt-get install -y \
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev
# Ubuntu 24.04+ moved several packages to t64 names.
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
# Prefer modern WebKit package, fallback for older images.
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -95,10 +107,14 @@ jobs:
fetch-depth: 0
submodules: recursive
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -129,6 +145,7 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Pack SDK and template packages
shell: pwsh

View File

@@ -1,4 +1,4 @@
name: Quality Check
name: Quality Check
on:
pull_request:
@@ -9,6 +9,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
jobs:
analyze:
@@ -24,12 +25,13 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event.pull_request.head.sha }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}

166
.github/workflows/ddss-publish.yml vendored Normal file
View File

@@ -0,0 +1,166 @@
name: DDSS
on:
workflow_run:
workflows:
- PLONDS
types:
- completed
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
env:
DOTNET_VERSION: '10.0.x'
jobs:
publish:
if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "$RAW_TAG" == v* ]]; then
TAG="$RAW_TAG"
else
TAG="v$RAW_TAG"
fi
else
gh run download "${{ github.event.workflow_run.id }}" -n plonds-run-metadata -D plonds-run-metadata
TAG="$(tr -d '\r\n' < plonds-run-metadata/tag.txt)"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "S3_BASE_URL=${{ vars.S3_ENDPOINT }}/${{ vars.S3_BUCKET }}/lanmountain/update/releases/${TAG}/assets" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
mkdir -p release-assets
gh release download "$RELEASE_TAG" -D release-assets
find release-assets -maxdepth 1 -type f | sort
- name: Upload release assets to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
aws --version
for file in release-assets/*; do
[[ -f "$file" ]] || continue
name="$(basename "$file")"
if [[ "$name" == "ddss.json" || "$name" == "ddss.json.sig" ]]; then
continue
fi
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
existing_sha="$(aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api head-object --bucket "$S3_BUCKET" --key "$key" --query 'Metadata.sha256' --output text 2>/dev/null || true)"
if [[ "$existing_sha" == "$sha256" ]]; then
echo "Skip existing asset: $name"
continue
fi
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done
- name: Build DDSS manifest
shell: bash
run: |
set -euo pipefail
mkdir -p ddss-output
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- \
build-ddss \
--release-tag "$RELEASE_TAG" \
--assets-dir release-assets \
--output-dir ddss-output \
--private-key "$UPDATE_PRIVATE_KEY_PATH" \
--repository "${{ github.repository }}" \
--s3-base-url "$S3_BASE_URL"
- name: Upload DDSS manifest to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" ddss-output/ddss.json ddss-output/ddss.json.sig --clobber
- name: Upload DDSS manifest to Rainyun S3
env:
AWS_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
AWS_DEFAULT_REGION: ${{ vars.S3_REGION }}
AWS_REGION: ${{ vars.S3_REGION }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
shell: bash
run: |
set -euo pipefail
for file in ddss-output/ddss.json ddss-output/ddss.json.sig; do
name="$(basename "$file")"
key="lanmountain/update/releases/${RELEASE_TAG}/assets/${name}"
sha256="$(sha256sum "$file" | awk '{print $1}')"
aws --endpoint-url "$S3_ENDPOINT" --region "$AWS_REGION" s3api put-object \
--bucket "$S3_BUCKET" \
--key "$key" \
--body "$file" \
--metadata "sha256=$sha256"
done

235
.github/workflows/plonds-build.yml vendored Normal file
View File

@@ -0,0 +1,235 @@
name: PLONDS
on:
release:
types:
- published
- prereleased
workflow_dispatch:
inputs:
tag:
description: 'Release tag'
required: true
type: string
baseline_tag:
description: 'Optional baseline tag'
required: false
type: string
channel:
description: 'Update channel'
required: false
type: choice
default: stable
options:
- stable
- preview
env:
DOTNET_VERSION: '10.0.x'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Resolve release context
shell: bash
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
TAG="${{ github.event.release.tag_name }}"
if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then
CHANNEL="preview"
else
CHANNEL="stable"
fi
BASELINE_TAG=""
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHANNEL="${{ github.event.inputs.channel }}"
BASELINE_TAG="${{ github.event.inputs.baseline_tag }}"
fi
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "RELEASE_VERSION=${TAG#v}" >> "$GITHUB_ENV"
echo "RELEASE_CHANNEL=${CHANNEL}" >> "$GITHUB_ENV"
echo "BASELINE_TAG_INPUT=${BASELINE_TAG}" >> "$GITHUB_ENV"
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: preview
- name: Prepare signing key
env:
UPDATE_PRIVATE_KEY_PEM: ${{ secrets.UPDATE_PRIVATE_KEY_PEM }}
PLONDS_SIGNING_KEY: ${{ secrets.PLONDS_SIGNING_KEY }}
PDC_SIGNING_KEY: ${{ secrets.PDC_SIGNING_KEY }}
shell: bash
run: |
set -euo pipefail
KEY="${PLONDS_SIGNING_KEY:-}"
if [[ -z "$KEY" ]]; then KEY="${UPDATE_PRIVATE_KEY_PEM:-}"; fi
if [[ -z "$KEY" ]]; then KEY="${PDC_SIGNING_KEY:-}"; fi
if [[ -z "$KEY" ]]; then
echo "No signing key is configured."
exit 1
fi
printf '%s' "$KEY" > update-private-key.pem
echo "UPDATE_PRIVATE_KEY_PATH=$PWD/update-private-key.pem" >> "$GITHUB_ENV"
- name: Build PLONDS tool
run: dotnet build PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj -c Release
- name: Resolve baseline plan
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$tag = $env:RELEASE_TAG
$baselineInput = $env:BASELINE_TAG_INPUT
$currentRelease = gh release view $tag --repo $repo --json tagName,isPrerelease,assets,publishedAt | ConvertFrom-Json
$allReleases = gh api "repos/$repo/releases?per_page=100" | ConvertFrom-Json
$platforms = @('windows-x64', 'windows-x86', 'linux-x64')
$entries = foreach ($platform in $platforms) {
$assetName = "files-$platform.zip"
$currentAsset = $currentRelease.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $currentAsset) {
throw "Current release $tag does not contain required asset $assetName"
}
$baselineRelease = $null
if (-not [string]::IsNullOrWhiteSpace($baselineInput)) {
$normalizedBaseline = if ($baselineInput.StartsWith('v')) { $baselineInput } else { "v$baselineInput" }
$baselineRelease = $allReleases | Where-Object { $_.tag_name -eq $normalizedBaseline } | Select-Object -First 1
if (-not $baselineRelease) {
throw "Specified baseline tag not found: $normalizedBaseline"
}
}
else {
$baselineRelease = $allReleases |
Where-Object {
$_.tag_name -ne $tag -and
-not $_.draft -and
[bool]$_.prerelease -eq [bool]$currentRelease.isPrerelease -and
($_.assets | Where-Object { $_.name -eq $assetName } | Measure-Object).Count -gt 0
} |
Select-Object -First 1
}
[pscustomobject]@{
platform = $platform
assetName = $assetName
baselineTag = if ($baselineRelease) { $baselineRelease.tag_name } else { $null }
baselineVersion = if ($baselineRelease) { ($baselineRelease.tag_name -replace '^v', '') } else { $null }
isFullPayload = -not $baselineRelease
}
}
$plan = [pscustomobject]@{
tag = $tag
version = $env:RELEASE_VERSION
channel = $env:RELEASE_CHANNEL
platforms = $entries
}
$plan | ConvertTo-Json -Depth 8 | Set-Content plonds-plan.json -Encoding utf8
Get-Content plonds-plan.json
- name: Download payload zips
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$repo = '${{ github.repository }}'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentDir = Join-Path $PWD "plonds-input/current/$($entry.platform)"
New-Item -ItemType Directory -Path $currentDir -Force | Out-Null
gh release download $plan.tag --repo $repo -p $entry.assetName -D $currentDir
if (-not [string]::IsNullOrWhiteSpace($entry.baselineTag)) {
$baselineDir = Join-Path $PWD "plonds-input/baseline/$($entry.platform)"
New-Item -ItemType Directory -Path $baselineDir -Force | Out-Null
gh release download $entry.baselineTag --repo $repo -p $entry.assetName -D $baselineDir
}
}
- name: Build delta assets
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$plan = Get-Content plonds-plan.json | ConvertFrom-Json
foreach ($entry in $plan.platforms) {
$currentZip = Join-Path $PWD "plonds-input/current/$($entry.platform)/$($entry.assetName)"
$args = @(
'run', '--project', 'PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj', '--configuration', 'Release', '--',
'build-delta',
'--platform', $entry.platform,
'--current-version', $plan.version,
'--current-tag', $plan.tag,
'--current-zip', $currentZip,
'--output-dir', 'plonds-output',
'--private-key', $env:UPDATE_PRIVATE_KEY_PATH,
'--channel', $plan.channel
)
if ([bool]$entry.isFullPayload) {
$args += @('--is-full-payload', 'true')
}
else {
$baselineZip = Join-Path $PWD "plonds-input/baseline/$($entry.platform)/$($entry.assetName)"
$args += @('--baseline-tag', $entry.baselineTag, '--baseline-version', $entry.baselineVersion, '--baseline-zip', $baselineZip)
}
dotnet @args
}
dotnet run --project PenguinLogisticsOnlineNetworkDistributionSystem/src/Plonds.Tool/Plonds.Tool.csproj --configuration Release -- `
build-index `
--release-tag $plan.tag `
--version $plan.version `
--channel $plan.channel `
--platform-summaries-dir plonds-output/platform-summaries `
--output-dir plonds-output `
--private-key $env:UPDATE_PRIVATE_KEY_PATH
- name: Upload PLONDS assets to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -euo pipefail
gh release upload "$RELEASE_TAG" plonds-output/release-assets/* --clobber
- name: Persist run metadata
shell: bash
run: |
mkdir -p plonds-run-metadata
printf '%s' "$RELEASE_TAG" > plonds-run-metadata/tag.txt
- name: Upload run metadata artifact
uses: actions/upload-artifact@v4
with:
name: plonds-run-metadata
path: plonds-run-metadata/tag.txt
if-no-files-found: error
retention-days: 7

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -19,6 +19,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.slnx
DOTNET_gcServer: 1
jobs:
prepare:
@@ -29,14 +30,22 @@ jobs:
informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
is_prerelease: ${{ steps.version.outputs.is_prerelease }}
release_channel: ${{ steps.version.outputs.release_channel }}
steps:
- name: Checkout repository metadata
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get release info
id: version
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}"
IS_PRERELEASE="false"
else
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
@@ -46,19 +55,40 @@ jobs:
else
TAG="v${RAW_TAG}"
fi
CHECKOUT_REF="${GITHUB_SHA}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
CHECKOUT_REF="refs/tags/${TAG}"
else
CHECKOUT_REF="${GITHUB_SHA}"
fi
if [[ "${{ github.event.inputs.is_prerelease }}" == "true" ]]; then
IS_PRERELEASE="true"
else
IS_PRERELEASE="false"
fi
fi
VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0")
done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
if [[ "${IS_PRERELEASE}" == "true" ]]; then
RELEASE_CHANNEL="preview"
else
RELEASE_CHANNEL="stable"
fi
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "assembly_version=${ASSEMBLY_VERSION}" >> "$GITHUB_OUTPUT"
echo "informational_version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "checkout_ref=${CHECKOUT_REF}" >> "$GITHUB_OUTPUT"
echo "is_prerelease=${IS_PRERELEASE}" >> "$GITHUB_OUTPUT"
echo "release_channel=${RELEASE_CHANNEL}" >> "$GITHUB_OUTPUT"
build-windows:
needs: prepare
@@ -66,9 +96,15 @@ jobs:
strategy:
fail-fast: false
matrix:
arch: [x64, x86]
name: Build_Windows_${{ matrix.arch }}
include:
- arch: x64
self_contained: true
suffix: ''
- arch: x86
self_contained: true
suffix: ''
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -81,6 +117,14 @@ jobs:
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -93,91 +137,132 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher (AOT)
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$launcherPublishDir = "publish/launcher-win-$arch"
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
-o ./$launcherPublishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:SelfContained=true `
-r win-$arch `
-p:PublishAot=true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:EnableCompressionInSingleFile=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
if ($LASTEXITCODE -ne 0) {
Write-Error "Launcher AOT publish failed"
exit 1
}
shell: pwsh
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
- name: Publish Main App
run: |
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-${{ matrix.arch }}" } else { "publish/windows-${{ matrix.arch }}-lite" }
if ($selfContained) {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
} else {
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
}
shell: pwsh
- name: Restructure for Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch"
$appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch"
New-Item -ItemType Directory -Path $newStructure -Force | Out-Null
$appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force
if (Test-Path $launcherPublishDir) {
Copy-Item -Path "$launcherPublishDir\*" -Destination $newStructure -Recurse -Force
}
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force | Out-Null
Remove-Item -Path $publishDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $launcherPublishDir -Recurse -Force -ErrorAction SilentlyContinue
Move-Item -Path $newStructure -Destination $publishDir -Force
shell: pwsh
- name: Install Inno Setup and 7z
run: |
choco install innosetup -y --no-progress
choco install 7zip -y --no-progress
shell: pwsh
- name: Build Installer
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish\windows-$arch"
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
$suffix = "${{ matrix.suffix }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$outputDir = "build-installer"
# Verify source directory exists
if (-not (Test-Path -Path $publishDir)) {
Write-Error "Publish directory not found: $publishDir"
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
exit 1
}
# Create output directory
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# Verify installer script exists
if (-not (Test-Path -Path $installerScript)) {
Write-Error "Installer script not found: $installerScript"
exit 1
}
# Find Inno Setup compiler (choco may install a shim in PATH)
$isccPath = $null
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($isccCommand) {
$isccPath = $isccCommand.Source
}
$candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe",
(Get-Command iscc.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -ErrorAction SilentlyContinue),
"$env:ProgramFiles(x86)\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
)
) | Where-Object { $_ -and (Test-Path $_) }
$isccPath = $candidatePaths | Select-Object -First 1
if (-not $isccPath) {
foreach ($candidate in $candidatePaths) {
if ($candidate -and (Test-Path -Path $candidate)) {
$isccPath = $candidate
break
}
}
}
if (-not $isccPath) {
Write-Host "ISCC.exe was not found in PATH or known locations."
Write-Host "Checked locations:"
$candidatePaths | ForEach-Object { Write-Host " - $_" }
Write-Host "Chocolatey bin listing (if exists):"
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
Write-Error "Inno Setup compiler not found."
exit 1
}
Write-Host "Found Inno Setup at: $isccPath"
# Build installer with iscc.exe
Write-Host "Building installer for Windows $arch with version $version..."
if (-not (Test-Path $installerScript)) {
Write-Error "Installer script not found: $(Join-Path $PWD $installerScript)"
exit 1
}
$publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path
$installerScript = (Resolve-Path $installerScript).Path
@@ -187,34 +272,69 @@ jobs:
"/DPublishDir=$publishDir",
"/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch",
"/DMyAppSuffix=$suffix",
"/DIsSelfContained=$selfContained",
$installerScript
)
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
# Execute the compiler
& $isccPath @compileArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
exit 1
}
# Check if build was successful
$installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $installerFile) {
Write-Error "Failed to create installer"
exit 1
}
Write-Host "✅ Successfully created: $($installerFile.Name)"
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Upload Installer
- name: Package Payload Zip
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$payloadRoot = Join-Path (Join-Path $PWD "publish/windows-$arch") "app-$version"
if (-not (Test-Path $payloadRoot)) {
Write-Error "Payload root not found: $payloadRoot"
exit 1
}
$stageDir = Join-Path $PWD "payload-stage/windows-$arch"
$releaseDir = Join-Path $PWD "release-assets"
Remove-Item -Path $stageDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $stageDir -Force | Out-Null
New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null
Get-ChildItem -Path $payloadRoot -Recurse -File | ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($payloadRoot, $_.FullName).Replace('\', '/')
if ($relative -eq '.current' -or $relative -eq '.partial' -or $relative -eq '.destroy' -or $relative.StartsWith('.current/') -or $relative.StartsWith('.partial/') -or $relative.StartsWith('.destroy/')) {
return
}
$destination = Join-Path $stageDir ($relative -replace '/', [System.IO.Path]::DirectorySeparatorChar)
$destinationDir = Split-Path -Path $destination -Parent
if (-not [string]::IsNullOrWhiteSpace($destinationDir)) {
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
}
Copy-Item -Path $_.FullName -Destination $destination -Force
}
$payloadZip = Join-Path $releaseDir "files-windows-$arch.zip"
if (Test-Path $payloadZip) {
Remove-Item $payloadZip -Force
}
Compress-Archive -Path (Join-Path $stageDir '*') -DestinationPath $payloadZip -Force
shell: pwsh
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: build-installer/*.exe
path: |
release-assets/files-windows-${{ matrix.arch }}.zip
build-installer/*.exe
if-no-files-found: error
retention-days: 30
@@ -222,7 +342,7 @@ jobs:
needs: prepare
runs-on: ubuntu-latest
name: Build_Linux
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -238,12 +358,25 @@ jobs:
libfontconfig1 libfreetype6 \
libx11-6 libxrandr2 libxinerama1 \
libxi6 libxcursor1 libxext6 \
libxrender1 libxkbcommon-x11-0
libxrender1 libxkbcommon-x11-0 \
clang zlib1g-dev zip rsync
sudo apt-get install -y libasound2t64 || sudo apt-get install -y libasound2
sudo apt-get install -y libportaudio2t64 || sudo apt-get install -y libportaudio2
sudo apt-get install -y libwebkit2gtk-4.1-dev || sudo apt-get install -y libwebkit2gtk-4.0-dev
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -256,11 +389,29 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher (AOT)
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishAot=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Main App
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/linux-x64 \
-o ./publish/linux-x64-app \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=false \
@@ -274,6 +425,24 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Restructure for Launcher
run: |
version="${{ needs.prepare.outputs.version }}"
publishDir="publish/linux-x64"
appDir="app-$version"
launcherDir="publish/launcher-linux-x64"
mkdir -p "$publishDir"
mv "publish/linux-x64-app" "$publishDir/$appDir"
if [ -d "$launcherDir" ]; then
cp -r "$launcherDir"/* "$publishDir/"
chmod +x "$publishDir/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
touch "$publishDir/$appDir/.current"
rm -rf "$launcherDir"
- name: Package as DEB
run: |
version="${{ needs.prepare.outputs.version }}"
@@ -283,41 +452,17 @@ jobs:
arch="amd64"
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
# Verify source directory exists
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
# Create DEB package structure
mkdir -p "build-deb/DEBIAN"
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
mkdir -p "build-deb/usr/share/pixmaps"
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
# Copy application files
cp -r "$source"/* "build-deb/usr/local/bin/"
# Verify copy was successful
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
echo "DEB package contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
cp -r "$source"/* "build-deb/usr/local/bin/"
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop.Launcher|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
@@ -334,49 +479,69 @@ jobs:
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
} > "build-deb/DEBIAN/postinst"
# Create control file (NOTE: No leading spaces in control file)
{
printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch"
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' " A desktop application for LanMountain."
printf '%s\n' 'Maintainer: LanMountain Team <dev@example.com>'
printf '%s\n' 'Description: LanMountain Desktop Application'
printf '%s\n' ' A desktop application for LanMountain.'
} > "build-deb/DEBIAN/control"
# Set proper permissions
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop.Launcher" 2>/dev/null || chmod 755 "build-deb/usr/local/bin"/*
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
# Create DEB file
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
ls -lh "${package_name}_${package_version}_${arch}.deb"
else
echo "Error: Failed to build DEB package"
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
- name: Package Payload Zip
run: |
version="${{ needs.prepare.outputs.version }}"
payload_root="publish/linux-x64/app-$version"
release_dir="$PWD/release-assets"
stage_dir="$PWD/payload-stage/linux-x64"
if [ ! -d "$payload_root" ]; then
echo "Payload root not found: $payload_root"
exit 1
fi
- name: Upload
rm -rf "$stage_dir"
mkdir -p "$stage_dir" "$release_dir"
rsync -a \
--exclude '.current' \
--exclude '.partial' \
--exclude '.destroy' \
"$payload_root/" "$stage_dir/"
(
cd "$stage_dir"
zip -qr "$release_dir/files-linux-x64.zip" .
)
- name: Upload Release Assets
uses: actions/upload-artifact@v4
with:
name: release-linux
path: "*.deb"
name: release-linux-x64
path: |
release-assets/files-linux-x64.zip
*.deb
if-no-files-found: error
retention-days: 30
build-macos:
needs: prepare
runs-on: macos-latest
continue-on-error: true
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
name: Build_macOS_${{ matrix.arch }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -385,10 +550,21 @@ jobs:
submodules: recursive
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: brew install portaudio
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
dotnet-quality: 'preview'
- name: Stamp release version metadata
shell: pwsh
run: |
./scripts/Set-ReleaseVersion.ps1 `
-Version "${{ needs.prepare.outputs.version }}" `
-AssemblyVersion "${{ needs.prepare.outputs.assembly_version }}"
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -401,11 +577,29 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
- name: Publish Launcher (AOT)
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj \
-c Release \
-o ./publish/launcher-macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishAot=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish Main App
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }} \
-o ./publish/macos-${{ matrix.arch }}-app \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=false \
@@ -419,45 +613,50 @@ jobs:
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package as DMG
- name: Package Payload Zip
run: |
release_dir="$PWD/release-assets"
stage_dir="$PWD/payload-stage/macos-${{ matrix.arch }}"
payload_root="publish/macos-${{ matrix.arch }}-app"
rm -rf "$stage_dir"
mkdir -p "$stage_dir" "$release_dir"
rsync -a "$payload_root/" "$stage_dir/"
(
cd "$stage_dir"
zip -qr "$release_dir/files-macos-${{ matrix.arch }}.zip" .
)
- name: Restructure and Package as DMG
continue-on-error: true
run: |
version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}"
source="publish/macos-$arch"
app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}"
# Verify source directory exists
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
# Create app bundle structure
launcherDir="publish/launcher-macos-$arch"
appSourceDir="publish/macos-$arch-app"
mkdir -p "${app_name}.app/Contents/MacOS"
mkdir -p "${app_name}.app/Contents/Resources"
# Copy application files
cp -r "$source"/* "${app_name}.app/Contents/MacOS/"
# Verify copy was successful
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
echo "App bundle contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: App bundle is empty after copy"
exit 1
appDir="app-$version"
mkdir -p "${app_name}.app/Contents/MacOS/$appDir"
cp -r "$appSourceDir"/* "${app_name}.app/Contents/MacOS/$appDir/"
if [ -d "$launcherDir" ]; then
cp -r "$launcherDir"/* "${app_name}.app/Contents/MacOS/"
chmod +x "${app_name}.app/Contents/MacOS/LanMountainDesktop.Launcher" 2>/dev/null || true
fi
# Create Info.plist
touch "${app_name}.app/Contents/MacOS/$appDir/.current"
mkdir -p "${app_name}.app/Contents/Resources"
{
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
printf '%s\n' '<plist version="1.0">'
printf '%s\n' '<dict>'
printf '%s\n' ' <key>CFBundleExecutable</key>'
printf '%s\n' ' <string>LanMountainDesktop</string>'
printf '%s\n' ' <string>LanMountainDesktop.Launcher</string>'
printf '%s\n' ' <key>CFBundleName</key>'
printf '%s\n' ' <string>LanMountain Desktop</string>'
printf '%s\n' ' <key>CFBundleVersion</key>'
@@ -471,96 +670,99 @@ jobs:
printf '%s\n' '</dict>'
printf '%s\n' '</plist>'
} > "${app_name}.app/Contents/Info.plist"
# Create DMG
mkdir -p dmg-temp
cp -r "${app_name}.app" dmg-temp/
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
echo "Successfully created: ${package_name}.dmg"
ls -lh "${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
# Cleanup
rm -rf dmg-temp "${app_name}.app"
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
- name: Upload
- name: Upload Release Assets
if: always()
uses: actions/upload-artifact@v4
with:
name: release-macos-${{ matrix.arch }}
path: "*.dmg"
if-no-files-found: error
path: |
release-assets/files-macos-${{ matrix.arch }}.zip
*.dmg
if-no-files-found: ignore
retention-days: 30
github-release:
needs: [ prepare, build-windows, build-linux, build-macos ]
needs: [prepare, build-windows, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download artifacts
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
path: release-files
pattern: release-*
merge-multiple: true
- name: List artifacts structure
- name: Normalize release files
run: |
echo "🔍 Artifact directory structure:"
find artifacts -type f -o -type d | sort
echo ""
echo "📊 Files found:"
find artifacts -type f -exec ls -lh {} \;
echo ""
echo "📁 Full tree:"
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
mkdir -p release-bundle
- name: Flatten artifacts for release
run: |
echo "📦 Organizing artifacts..."
mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
echo ""
echo "✅ Files ready for release:"
ls -lh release-files/ || echo "⚠️ No files found in release-files"
echo ""
echo "📋 Total files:"
file_count=$(find release-files -type f | wc -l)
echo "$file_count"
if [ "$file_count" -eq 0 ]; then
echo "Error: No installer/package files found for release"
mapfile -t downloaded_files < <(find release-files -type f)
if [ "${#downloaded_files[@]}" -eq 0 ]; then
echo "No downloaded release artifacts were found."
exit 1
fi
- name: Create Release
for file in "${downloaded_files[@]}"; do
base_name="$(basename "$file")"
target_path="release-bundle/$base_name"
if [ -e "$target_path" ]; then
echo "Duplicate release asset name detected: $base_name"
echo "Conflicting file: $file"
exit 1
fi
cp "$file" "$target_path"
done
- name: Validate release files
run: |
echo "Release files:"
find release-bundle -maxdepth 1 -type f -exec ls -lh {} \;
if [ ! -f release-bundle/files-windows-x64.zip ] || [ ! -f release-bundle/files-windows-x86.zip ] || [ ! -f release-bundle/files-linux-x64.zip ]; then
echo "Required payload zips are missing."
exit 1
fi
file_count=$(find release-bundle -maxdepth 1 -type f | wc -l)
if [ "$file_count" -eq 0 ]; then
echo "No release files were produced."
exit 1
fi
- name: Create or Update Release
uses: ncipollo/release-action@v1
with:
tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true
draft: false
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
artifacts: "release-files/**"
prerelease: ${{ needs.prepare.outputs.is_prerelease == 'true' }}
artifacts: 'release-bundle/*'
body: |
## Release ${{ needs.prepare.outputs.version }}
### Windows
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer
Installation: Double-click the .exe file and follow the wizard.
### Linux
- **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64)
### Installers
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x64.exe`
- `LanMountainDesktop-Setup-${{ needs.prepare.outputs.version }}-x86.exe`
- `LanMountainDesktop_${{ needs.prepare.outputs.version }}_amd64.deb`
### Payload Archives
- `files-windows-x64.zip`
- `files-windows-x86.zip`
- `files-linux-x64.zip`
### macOS
- **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor
- **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
See commits for changes.
- macOS assets are best-effort and will not block the release.
Release keeps only the stable installer and payload outputs. PLONDS delta assets and external mirror metadata are generated by follow-up workflows.
token: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -512,3 +512,6 @@ nul
/*.deb
/*.dmg
/*.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

@@ -0,0 +1,106 @@
# Avalonia 12 迁移计划
## 当前状态
项目已完成以下迁移准备:
* `Directory.Packages.props` 中 Avalonia 包已升级到 `12.0.1`
* `FluentAvaloniaUI` 已升级到 `3.0.0-preview1`
* `Avalonia.Diagnostics` 已替换为 `AvaloniaUI.DiagnosticsSupport`
* `Avalonia.Controls.WebView` 已升级到 `12.0.0`
* `ClassIsland.Markdown.Avalonia` 已升级到 `12.0.0`
## 构建错误清单26 errors
### 1. 窗口装饰 API 移除8 errors
**文件**`LanMountainDesktop/Views/SettingsWindow.axaml.cs`4 errors
* `ExtendClientAreaChromeHints` 不存在line 166, 179
* `SystemDecorations` 已过时,需改用 `WindowDecorations`line 168, 177
**文件**`LanMountainDesktop/Views/ComponentEditorWindow.axaml.cs`4 errors
* `ExtendClientAreaChromeHints` 不存在line 63, 72
* `SystemDecorations` 已过时,需改用 `WindowDecorations`line 65, 70
**AXAML 文件**13 个文件使用 `SystemDecorations` 属性(编译警告)
### 2. 变量/字段未找到8 errors
**文件**`LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
* `centerLeft` 不存在line 759, 766, 778
* `positions` 不存在line 1266
**文件**`LanMountainDesktop/Views/MainWindow.DesktopPaging.cs`
* `child` 不存在line 312
* `_isThreeFingerOrRightDragSwipeActive` 不存在line 517, 828, 847, 850
### 3. API 变更3 errors
**文件**`LanMountainDesktop/App.axaml.cs`
* `BindingPlugins` 不可访问line 532, 537
**文件**`LanMountainDesktop/Views/Components/DesktopComponentFailureView.cs`
* `IClipboard.SetTextAsync` 不存在line 187
**文件**`LanMountainDesktop/Services/MonetColorService.cs`
* `Bitmap.CopyPixels` 参数不匹配line 91
### 4. 第三方库变更1 error
**文件**`LanMountainDesktop/Views/SettingsWindow.axaml.cs`
* `FluentIcons.Avalonia.SymbolIconSource` 不存在line 215
### 5. 过时属性警告(需同步修复)
* `TextBox.Watermark``PlaceholderText`7 处 .cs + 7 处 .axaml
## 迁移步骤
### Phase 1: 修复窗口装饰 API高优先级
1. 重写 `SettingsWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
2. 重写 `ComponentEditorWindow.ApplyChromeMode()` 使用 Avalonia 12 新 API
3. 批量替换所有 `.axaml` 中的 `SystemDecorations``WindowDecorations`
### Phase 2: 修复 MainWindow 编译错误(高优先级)
1. 检查 `MainWindow.ComponentSystem.cs``centerLeft``positions` 的作用域问题
2. 检查 `MainWindow.DesktopPaging.cs``child``_isThreeFingerOrRightDragSwipeActive` 的作用域问题
3. 确认这些变量是否被意外删除或重命名
### Phase 3: 修复 Avalonia 12 API 变更(中优先级)
1. `App.axaml.cs`: 替换 `BindingPlugins.DataValidators` 的访问方式
2. `DesktopComponentFailureView.cs`: 使用新的剪贴板 API
3. `MonetColorService.cs`: 更新 `Bitmap.CopyPixels` 调用签名
### Phase 4: 修复第三方库变更(中优先级)
1. `SettingsWindow.axaml.cs`: 替换 `FluentIcons.Avalonia.SymbolIconSource` 为 v3 等效 API
### Phase 5: 清理过时属性(低优先级)
1. 批量替换 `Watermark``PlaceholderText`(所有 .cs 和 .axaml
## 验证步骤
* 每阶段修复后运行 `dotnet build LanMountainDesktop.slnx -c Debug`
* 最终运行 `dotnet test LanMountainDesktop.slnx -c Debug`

View File

@@ -0,0 +1,805 @@
# LanMountainDesktop Launcher 全面改进计划
## 概述
本计划旨在将 LanMountainDesktop 的 Launcher 改进为符合原子化架构的独立启动器,参考 ClassIsland 的极简设计,同时保留阑山桌面的特色功能。
## 目标
1. **P0 (必须完成)**: 重写 Launcher 为极简模式,移除与主程序的耦合
2. **P1 (应该完成)**: 将 OOBE、Splash、更新、插件管理迁移到主程序
3. **P2 (推荐完成)**: 实现 Launcher 自更新机制
4. **P3 (可选优化)**: 性能优化和代码清理
5. **P4 (长期规划)**: 增强功能和可扩展性
## 当前问题
1. Launcher 是 Avalonia 应用,启动慢、内存占用高
2. Launcher 引用了 PluginSdk与主程序有耦合
3. 主程序引用了 Launcher构建关系复杂
4. Launcher 职责过多OOBE + Splash + 更新 + 插件 + 启动)
5. 缺少 Launcher 自更新机制
6. GitHub Actions 工作流需要适配新的目录结构
## 改进后架构
```
安装根目录/
├── LanMountainDesktop.exe ← 启动器(唯一入口,极简,~100行代码
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe ← 主程序
│ └── ... (所有依赖)
└── .launcher/ ← 启动器数据(可选)
└── snapshots/ ← 版本快照
```
## 详细实施步骤
### P0: 基础架构重构
#### 1. 重写 Launcher 为极简模式
**文件**: `LanMountainDesktop.Launcher/Program.cs`
**目标**:
- 代码量控制在 100 行以内
- 零外部依赖(不使用 Avalonia
- 只负责:版本选择、启动主程序、清理旧版本
**完整实现代码**:
```csharp
// LanMountainDesktop.Launcher/Program.cs
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Launcher;
internal static class Program
{
private const string HostExecutableName = "LanMountainDesktop.exe";
private const string HostExecutableNameLinux = "LanMountainDesktop";
[STAThread]
private static int Main(string[] args)
{
var rootDir = GetRootDirectory();
// 1. 查找最佳版本
var installation = FindBestVersion(rootDir);
if (installation == null)
{
ShowError("找不到有效的 LanMountainDesktop 版本,请重新安装。");
return 1;
}
// 2. 清理旧版本(异步,不阻塞)
_ = Task.Run(() => CleanupOldVersions(rootDir));
// 3. 启动主程序
return LaunchHost(installation, args);
}
private static string GetRootDirectory()
{
return Path.GetFullPath(
Path.GetDirectoryName(Environment.ProcessPath) ?? "");
}
private static string? FindBestVersion(string rootDir)
{
var exeName = OperatingSystem.IsWindows()
? HostExecutableName
: HostExecutableNameLinux;
return Directory.GetDirectories(rootDir)
.Where(x => IsValidVersionDirectory(x, exeName))
.OrderBy(x => File.Exists(Path.Combine(x, ".current")) ? 0 : 1)
.ThenByDescending(x => ParseVersion(Path.GetFileName(x)))
.FirstOrDefault();
}
private static bool IsValidVersionDirectory(string path, string exeName)
{
var dirName = Path.GetFileName(path);
return dirName.StartsWith("app-") &&
!File.Exists(Path.Combine(path, ".destroy")) &&
!File.Exists(Path.Combine(path, ".partial")) &&
File.Exists(Path.Combine(path, exeName));
}
private static Version ParseVersion(string dirName)
{
// app-1.0.0 or app-1.0.0-123
var parts = dirName.Split('-');
if (parts.Length >= 2 && Version.TryParse(parts[1], out var v))
return v;
return new Version(0, 0);
}
private static void CleanupOldVersions(string rootDir)
{
try
{
var oldVersions = Directory.GetDirectories(rootDir)
.Where(x => File.Exists(Path.Combine(x, ".destroy")));
foreach (var dir in oldVersions)
{
try { Directory.Delete(dir, recursive: true); } catch { }
}
}
catch { /* 忽略清理失败 */ }
}
private static int LaunchHost(string installation, string[] args)
{
var exeName = OperatingSystem.IsWindows()
? HostExecutableName
: HostExecutableNameLinux;
var exePath = Path.Combine(installation, exeName);
// Linux/macOS: 确保可执行权限
if (!OperatingSystem.IsWindows())
{
EnsureExecutable(exePath);
}
var startInfo = new ProcessStartInfo
{
FileName = exePath,
WorkingDirectory = Path.GetDirectoryName(installation),
UseShellExecute = true
};
foreach (var arg in args)
startInfo.ArgumentList.Add(arg);
// 传递环境变量
startInfo.EnvironmentVariables["LMD_PACKAGE_ROOT"] =
Path.GetDirectoryName(installation);
startInfo.EnvironmentVariables["LMD_VERSION"] =
Path.GetFileName(installation).Replace("app-", "");
try
{
Process.Start(startInfo);
return 0;
}
catch (Exception ex)
{
ShowError($"启动失败: {ex.Message}");
return 1;
}
}
private static void EnsureExecutable(string path)
{
try
{
Process.Start(new ProcessStartInfo
{
FileName = "chmod",
Arguments = $"+x \"{path}\"",
CreateNoWindow = true
})?.WaitForExit();
}
catch { }
}
private static void ShowError(string message)
{
if (OperatingSystem.IsWindows())
{
// Win32 MessageBox
try
{
MessageBox(IntPtr.Zero, message, "LanMountainDesktop", 0x10);
}
catch
{
Console.Error.WriteLine(message);
}
}
else
{
Console.Error.WriteLine(message);
}
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type);
}
```
#### 2. 修改 Launcher 项目文件
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
**完整内容**:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<!-- 图标资源 -->
<ItemGroup>
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
```
#### 3. 移除主程序对 Launcher 的引用
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
**修改**: 删除以下行
```xml
<!-- 删除这一行 -->
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" />
```
#### 4. 修改主程序支持新架构
**文件**: `LanMountainDesktop/Program.cs`
**修改**: 添加环境变量读取
```csharp
// 在 Program.cs 中添加
internal static class LaunchContext
{
public static string? PackageRoot =>
Environment.GetEnvironmentVariable("LMD_PACKAGE_ROOT");
public static string? Version =>
Environment.GetEnvironmentVariable("LMD_VERSION");
public static bool IsLaunchedByLauncher =>
!string.IsNullOrEmpty(PackageRoot);
}
```
---
### P1: 功能迁移
#### 5. 将 OOBE 迁移到主程序
**新建文件**: `LanMountainDesktop/Services/Oobe/OobeService.cs`
```csharp
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services.Oobe;
public class OobeService
{
private readonly string _oobeStatePath;
public OobeService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
_oobeStatePath = Path.Combine(appData, "LanMountainDesktop", ".oobe_completed");
}
public bool IsFirstRun()
{
return !File.Exists(_oobeStatePath);
}
public void MarkCompleted()
{
var dir = Path.GetDirectoryName(_oobeStatePath);
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.WriteAllText(_oobeStatePath, DateTime.UtcNow.ToString("O"));
}
}
```
**新建文件**: `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Oobe.OobeWindow"
Title="欢迎使用阑山桌面"
Width="800"
Height="600"
WindowStartupLocation="CenterScreen">
<Grid>
<!-- OOBE 界面内容 -->
<TextBlock Text="欢迎使用阑山桌面" FontSize="24" HorizontalAlignment="Center" Margin="0,50,0,0"/>
<Button Content="开始使用" HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="0,0,0,50" Click="OnStartClick"/>
</Grid>
</Window>
```
**修改文件**: `LanMountainDesktop/App.axaml.cs`
```csharp
// 在 OnFrameworkInitializationCompleted 中添加
private async Task InitializeOobeAsync()
{
var oobeService = new OobeService();
if (oobeService.IsFirstRun())
{
var oobeWindow = new Views.Oobe.OobeWindow();
await oobeWindow.ShowDialog();
oobeService.MarkCompleted();
}
}
```
#### 6. 将 Splash 迁移到主程序
**新建文件**: `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LanMountainDesktop.Views.Splash.SplashWindow"
Title="阑山桌面"
Width="400"
Height="300"
WindowStartupLocation="CenterScreen"
ShowInTaskbar="False"
SystemDecorations="None">
<Grid Background="{DynamicResource SystemAccentColor}">
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center">
<Image Source="/Assets/logo_nightly.png" Width="100" Height="100"/>
<TextBlock Text="阑山桌面" FontSize="20" Margin="0,20,0,0" HorizontalAlignment="Center"/>
<TextBlock x:Name="StatusText" Text="正在启动..." Margin="0,10,0,0" HorizontalAlignment="Center"/>
</StackPanel>
</Grid>
</Window>
```
**修改文件**: `LanMountainDesktop/App.axaml.cs`
```csharp
// 在初始化时显示 Splash
private SplashWindow? _splashWindow;
private void ShowSplash()
{
_splashWindow = new SplashWindow();
_splashWindow.Show();
}
private void CloseSplash()
{
_splashWindow?.Close();
_splashWindow = null;
}
```
#### 7. 将更新逻辑迁移到主程序
**新建目录**: `LanMountainDesktop/Services/Update/`
**新建文件**: `LanMountainDesktop/Services/Update/UpdateService.cs`
```csharp
using System.Net.Http.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services.Update;
public class UpdateService
{
private readonly HttpClient _httpClient;
private readonly string _currentVersion;
public UpdateService()
{
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop");
_currentVersion = GetType().Assembly.GetName().Version?.ToString() ?? "1.0.0";
}
public async Task<UpdateCheckResult> CheckForUpdateAsync(UpdateChannel channel)
{
// 调用 GitHub Release API
var releases = await _httpClient.GetFromJsonAsync<List<GitHubRelease>>(
"https://api.github.com/repos/ClassIsland/LanMountainDesktop/releases");
var latest = channel == UpdateChannel.Stable
? releases?.FirstOrDefault(r => !r.Prerelease)
: releases?.FirstOrDefault();
if (latest == null)
return new UpdateCheckResult { HasUpdate = false };
var latestVersion = latest.TagName.TrimStart('v');
var hasUpdate = new Version(latestVersion) > new Version(_currentVersion);
return new UpdateCheckResult
{
HasUpdate = hasUpdate,
Version = latestVersion,
DownloadUrl = latest.Assets.FirstOrDefault()?.BrowserDownloadUrl
};
}
}
public class UpdateCheckResult
{
public bool HasUpdate { get; set; }
public string? Version { get; set; }
public string? DownloadUrl { get; set; }
}
public enum UpdateChannel { Stable, Preview }
public class GitHubRelease
{
public string TagName { get; set; } = "";
public bool Prerelease { get; set; }
public List<GitHubAsset> Assets { get; set; } = new();
}
public class GitHubAsset
{
public string BrowserDownloadUrl { get; set; } = "";
}
```
#### 8. 将插件管理迁移到主程序
**新建目录**: `LanMountainDesktop/Services/Plugins/`
**新建文件**: `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
```csharp
namespace LanMountainDesktop.Services.Plugins;
public class PluginUpdateService
{
private readonly string _pluginsDirectory;
public PluginUpdateService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
_pluginsDirectory = Path.Combine(appData, "LanMountainDesktop", "plugins");
}
public async Task CheckAndUpdatePluginsAsync()
{
// 检查插件更新
// 下载并安装更新
}
}
```
---
### P2: 自更新机制
#### 9. 实现 Launcher 自更新
**修改文件**: `LanMountainDesktop.Launcher/Program.cs`
```csharp
// 在 Main 方法开头添加自更新检查
private static void CheckForLauncherUpdate()
{
var rootDir = GetRootDirectory();
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
if (File.Exists(updatePath))
{
// 有新版本 Launcher替换自身
try
{
var currentPath = Environment.ProcessPath;
var backupPath = currentPath + ".old";
// 重命名当前版本
if (File.Exists(backupPath))
File.Delete(backupPath);
File.Move(currentPath!, backupPath);
// 移动新版本
File.Move(updatePath, currentPath!);
// 删除备份
File.Delete(backupPath);
// 重启自己
Process.Start(new ProcessStartInfo
{
FileName = currentPath,
UseShellExecute = true
});
Environment.Exit(0);
}
catch (Exception ex)
{
// 回滚
Console.Error.WriteLine($"Launcher 更新失败: {ex.Message}");
}
}
}
```
#### 10. 主程序支持更新 Launcher
**新建文件**: `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
```csharp
namespace LanMountainDesktop.Services.Update;
public class LauncherUpdateService
{
private readonly HttpClient _httpClient;
public LauncherUpdateService()
{
_httpClient = new HttpClient();
}
public async Task<bool> UpdateLauncherAsync(string downloadUrl)
{
var rootDir = LaunchContext.PackageRoot
?? Path.GetDirectoryName(Environment.ProcessPath)!;
var updatePath = Path.Combine(rootDir, "LanMountainDesktop.Launcher.Update.exe");
// 下载新版本
var response = await _httpClient.GetAsync(downloadUrl);
await using var fs = File.Create(updatePath);
await response.Content.CopyToAsync(fs);
return true;
}
public void RestartWithNewLauncher()
{
var launcherPath = Path.Combine(
LaunchContext.PackageRoot ?? "",
"LanMountainDesktop.exe");
Process.Start(new ProcessStartInfo
{
FileName = launcherPath,
UseShellExecute = true
});
// 退出主程序,让 Launcher 接管
Environment.Exit(0);
}
}
```
---
### P3: 清理旧代码
#### 11. 删除文件清单
**删除以下文件/目录**:
```
LanMountainDesktop.Launcher/
├── App.axaml ← 删除
├── App.axaml.cs ← 删除
├── Views/ ← 删除整个目录
│ ├── OobeWindow.axaml
│ ├── OobeWindow.axaml.cs
│ ├── SplashWindow.axaml
│ └── SplashWindow.axaml.cs
├── Services/ ← 删除大部分
│ ├── LauncherFlowCoordinator.cs ← 删除
│ ├── OobeStateService.cs ← 删除
│ ├── UpdateCheckService.cs ← 删除
│ ├── UpdateEngineService.cs ← 删除
│ ├── PluginInstallerService.cs ← 删除
│ └── PluginUpgradeQueueService.cs ← 删除
└── Models/ ← 删除(如不再需要)
```
---
### P4: GitHub Actions 工作流修改
#### 12. 修改 release.yml
**关键修改点**:
1. **Launcher 单独编译**:
```yaml
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./publish/launcher-win-x64 `
--self-contained `
-r win-x64 `
-p:PublishSingleFile=false `
-p:PublishTrimmed=false `
-p:DebugType=none
```
2. **目录结构调整**:
```yaml
- name: Restructure for Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-x64"
$launcherDir = "publish/launcher-win-x64"
$appDir = "app-$version"
# 创建新结构
$newStructure = "publish-launcher/windows-x64"
New-Item -ItemType Directory -Path $newStructure -Force
# 移动主程序到 app-{version}/
$appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force
# 复制 Launcher 到根目录
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
# 创建 .current 标记
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
```
3. **Linux/macOS 同样调整**:
- Linux: 修改 DEB 打包流程
- macOS: 修改 DMG 打包流程
#### 13. 修改 build.yml
**修改**: 移除 Launcher 相关构建步骤,因为 Launcher 现在完全独立
---
### P5: 图标资源处理
#### 14. Launcher 图标配置
**方案**: 使用链接方式引用主程序图标
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
```xml
<ItemGroup>
<!-- 链接主程序的图标 -->
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
```
#### 15. 安装程序配置
**文件**: `LanMountainDesktop/installer/LanMountainDesktop.iss` (Inno Setup)
**关键配置**:
```ini
[Setup]
AppName=阑山桌面
AppVersion={#MyAppVersion}
DefaultDirName={autopf}\LanMountainDesktop
OutputBaseFilename=LanMountainDesktop-Setup-{#MyAppVersion}-x64
SetupIconFile=..\Assets\logo_nightly.ico
UninstallDisplayIcon={app}\LanMountainDesktop.exe
[Files]
; Launcher
Source: "..\..\publish\windows-x64\LanMountainDesktop.exe"; DestDir: "{app}"; Flags: ignoreversion
; 主程序版本目录
Source: "..\..\publish\windows-x64\app-{#MyAppVersion}\*"; DestDir: "{app}\app-{#MyAppVersion}"; Flags: ignoreversion recursesubdirs
[Icons]
; 桌面快捷方式
Name: "{autodesktop}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
; 开始菜单
Name: "{group}\阑山桌面"; Filename: "{app}\LanMountainDesktop.exe"; IconFilename: "{app}\LanMountainDesktop.exe"
```
---
## 文件变更清单
### 修改文件
1. `LanMountainDesktop.Launcher/Program.cs` - 完全重写
2. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 简化依赖
3. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
4. `LanMountainDesktop/Program.cs` - 添加 LaunchContext
5. `LanMountainDesktop/App.axaml.cs` - 添加 OOBE/Splash/更新入口
6. `.github/workflows/release.yml` - 调整打包流程
7. `.github/workflows/build.yml` - 适配新构建流程
### 新增文件
1. `LanMountainDesktop/Services/Oobe/OobeService.cs`
2. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml`
3. `LanMountainDesktop/Views/Oobe/OobeWindow.axaml.cs`
4. `LanMountainDesktop/Views/Splash/SplashWindow.axaml`
5. `LanMountainDesktop/Views/Splash/SplashWindow.axaml.cs`
6. `LanMountainDesktop/Services/Update/UpdateService.cs`
7. `LanMountainDesktop/Services/Update/LauncherUpdateService.cs`
8. `LanMountainDesktop/Services/Plugins/PluginUpdateService.cs`
### 删除文件
1. `LanMountainDesktop.Launcher/App.axaml`
2. `LanMountainDesktop.Launcher/App.axaml.cs`
3. `LanMountainDesktop.Launcher/Views/` 目录
4. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
5. `LanMountainDesktop.Launcher/Services/OobeStateService.cs`
6. `LanMountainDesktop.Launcher/Services/UpdateCheckService.cs`
7. `LanMountainDesktop.Launcher/Services/UpdateEngineService.cs`
8. `LanMountainDesktop.Launcher/Services/PluginInstallerService.cs`
9. `LanMountainDesktop.Launcher/Services/PluginUpgradeQueueService.cs`
---
## 风险与回滚方案
### 风险
1. **启动失败**: 新 Launcher 可能有 bug 导致无法启动
2. **更新中断**: 更新逻辑迁移可能导致更新失败
3. **图标丢失**: 图标配置错误导致快捷方式无图标
### 回滚方案
1. 保留原 Launcher 代码分支
2. 准备紧急修复版本
3. 用户可手动下载完整安装包恢复
---
## 验证清单
- [ ] Launcher 能正常启动主程序
- [ ] 版本选择逻辑正确
- [ ] 旧版本清理正常
- [ ] OOBE 流程正常
- [ ] Splash 显示正常
- [ ] 更新检查正常
- [ ] 插件安装正常
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常
---
## 实施顺序建议
### 第一阶段(立即实施)
1. 重写 Launcher Program.cs
2. 修改 Launcher.csproj
3. 移除主程序对 Launcher 的引用
4. 测试基本启动功能
### 第二阶段(功能迁移)
1. 迁移 OOBE 到主程序
2. 迁移 Splash 到主程序
3. 迁移更新逻辑到主程序
4. 迁移插件管理到主程序
### 第三阶段CI/CD
1. 修改 release.yml
2. 修改 build.yml
3. 测试打包流程
4. 验证安装程序
### 第四阶段(优化)
1. 实现 Launcher 自更新
2. 性能优化
3. 清理旧代码

View File

@@ -0,0 +1,717 @@
# LanMountainDesktop Launcher 改进计划 V2
## 核心设计理念
**Launcher 是核心协调器,不是极简启动器**
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Launcher 职责定位 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Launcher 负责(启动前 & 退出后): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • OOBE 首次引导 │ │
│ │ • 启动动画 (Splash) │ │
│ │ • 插件安装 │ │
│ │ • 插件更新 │ │
│ │ • 应用增量更新安装(不是下载!) │ │
│ │ • 应用静默更新安装 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 主程序负责(运行时): │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ • 多线程下载(有完整 Downloader │ │
│ │ • 更新渠道切换 │ │
│ │ • 下载管理 │ │
│ │ • 与 Launcher 通讯(启动进度) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 关键优势: │
│ • Launcher 在应用启动前运行 → 可以安装更新而不担心文件占用 │
│ • Launcher 在应用退出后运行 → 可以完成待处理的安装任务 │
│ • 主程序专注下载 → 利用完整的多线程下载器提高效率 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 为什么保留 Avalonia
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 保留 Avalonia 的理由 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 启动画面 (Splash) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ • 需要显示启动进度 │ │
│ │ • 需要显示品牌 Logo │ │
│ │ • 需要流畅的动画效果 │ │
│ │ • 纯 Win32 实现复杂且不易维护 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 2. OOBE 首次引导 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ • 需要多步骤向导界面 │ │
│ │ • 需要丰富的交互控件 │ │
│ │ • 需要与主程序一致的视觉风格 │ │
│ │ • Avalonia 提供完整的 UI 框架 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ 3. 与主程序的技术栈一致 │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ • 共享主题和资源 │ │
│ │ • 共享控件和样式 │ │
│ │ • 便于维护和迭代 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## 改进后的架构设计
### 目录结构(保持不变)
```
安装根目录/
├── LanMountainDesktop.exe ← LauncherAvalonia 应用)
├── app-1.0.0/ ← 版本目录
│ ├── .current ← 当前版本标记
│ ├── LanMountainDesktop.exe ← 主程序
│ └── ... (所有依赖)
└── .launcher/ ← Launcher 数据目录
├── update/ ← 更新缓存
│ └── incoming/ ← 下载的更新包(主程序下载到这里)
└── snapshots/ ← 版本快照
```
### 核心流程设计
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 启动流程(含通讯机制) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户启动 LanMountainDesktop.exe (Launcher) │
│ ↓ │
│ 2. Launcher 检查是否有待处理的更新安装 │
│ ↓ │
│ 3. 有更新──Yes──▶ 显示 Splash "正在安装更新..." │
│ ↓ ↓ │
│ No 安装更新(增量/静默) │
│ ↓ ↓ │
│ 4. 检查是否首次运行 ──Yes──▶ 显示 OOBE 窗口 │
│ ↓ No ↓ │
│ 5. 显示 Splash "正在启动..." 完成 OOBE │
│ ↓ │
│ 6. 启动主程序进程(带通讯参数) │
│ ↓ │
│ 7. Launcher 保持运行,监听主程序进度 ─────── IPC 通讯 ───────▶ 主程序 │
│ ↓ │
│ 8. 主程序报告启动进度 ─────── IPC 通讯 ───────▶ Launcher 更新 Splash │
│ ↓ │
│ 9. 主程序完全启动 ──Yes──▶ Launcher 关闭 Splash进入后台/退出 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 退出流程
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ 退出流程(处理待安装任务) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 主程序准备退出 │
│ ↓ │
│ 2. 检查是否有待安装的更新/插件 ──Yes──▶ 重启 Launcher 并传递参数 │
│ ↓ No ↓ │
│ 3. 正常退出 Launcher 在应用退出后运行 │
│ ↓ │
│ 安装待处理的任务 │
│ ↓ │
│ 完成后再次启动主程序 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Launcher 与主程序的通讯机制
### IPC 方案选择
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ IPC 通讯方案 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 方案 1: 命令行参数 + 退出码(推荐用于启动阶段) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Launcher 启动主程序: │ │
│ │ LanMountainDesktop.exe --launcher-pid 12345 --ipc-port 50000 │ │
│ │ │ │
│ │ 主程序通过命名管道/HTTP 与 Launcher 通讯 │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 方案 2: 命名管道(推荐用于进度报告) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Launcher 创建命名管道: \\.\pipe\LanMountainDesktop_Launcher │ │
│ │ 主程序连接并发送进度消息 │ │
│ │ │ │
│ │ 消息格式: JSON │ │
│ │ {"stage": "initializing", "progress": 30, "message": "加载设置..."} │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ 方案 3: 共享内存/文件(简单状态同步) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Launcher 和主程序读写同一个状态文件 │ │
│ │ .launcher/state/startup_status.json │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### 通讯协议设计
```csharp
// 共享契约LanMountainDesktop.Shared.Contracts
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public enum StartupStage
{
Initializing,
LoadingSettings,
LoadingPlugins,
InitializingUI,
Ready
}
public record StartupProgressMessage
{
public StartupStage Stage { get; init; }
public int ProgressPercent { get; init; } // 0-100
public string? Message { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
public static class LauncherIpc
{
public const string PipeName = "LanMountainDesktop_Launcher";
public const string EnvironmentVariablePrefix = "LMD_";
}
```
## 详细实施步骤
### P0: 架构调整(核心)
#### 1. 调整 Launcher 项目引用
**文件**: `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj`
**修改**:
- 保留 Avalonia 依赖
- 移除 PluginSdk 引用Launcher 不需要)
- 添加 Shared.Contracts 引用(用于 IPC
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Assetsogo_nightly.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 保留 Avalonia -->
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.12" />
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<!-- 图标资源 -->
<ItemGroup>
<Content Include="..\LanMountainDesktop\Assets\logo_nightly.ico" Link="Assets\logo_nightly.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
```
#### 2. 移除主程序对 Launcher 的引用
**文件**: `LanMountainDesktop/LanMountainDesktop.csproj`
**修改**: 删除 Launcher 引用
```xml
<!-- 删除 -->
<!-- <ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" ReferenceOutputAssembly="false" /> -->
```
#### 3. 创建 IPC 通讯契约
**新建文件**: `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs`
```csharp
namespace LanMountainDesktop.Shared.Contracts.Launcher;
public enum StartupStage
{
Initializing,
LoadingSettings,
LoadingPlugins,
InitializingUI,
Ready
}
public record StartupProgressMessage
{
public StartupStage Stage { get; init; }
public int ProgressPercent { get; init; }
public string? Message { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
}
public static class LauncherIpcConstants
{
public const string PipeName = "LanMountainDesktop_Launcher";
public const string LauncherPidEnvVar = "LMD_LAUNCHER_PID";
public const string PackageRootEnvVar = "LMD_PACKAGE_ROOT";
public const string VersionEnvVar = "LMD_VERSION";
}
```
### P1: Launcher 端实现
#### 4. 实现 IPC 服务端
**新建文件**: `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs`
```csharp
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipeServer;
private readonly Action<StartupProgressMessage> _onProgress;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
public async Task StartAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipeServer = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Message);
await _pipeServer.WaitForConnectionAsync(_cts.Token);
using var reader = new StreamReader(_pipeServer);
var json = await reader.ReadToEndAsync(_cts.Token);
if (!string.IsNullOrEmpty(json))
{
var message = JsonSerializer.Deserialize<StartupProgressMessage>(json);
if (message != null)
{
_onProgress(message);
}
}
_pipeServer.Disconnect();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Console.Error.WriteLine($"IPC error: {ex.Message}");
}
}
}
public void Dispose()
{
_cts.Cancel();
_pipeServer?.Dispose();
_cts.Dispose();
}
}
```
#### 5. 修改 Launcher 启动流程
**修改文件**: `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs`
```csharp
public async Task<LauncherResult> RunAsync()
{
// 1. 清理旧版本
_deploymentLocator.CleanupDestroyedDeployments();
// 2. 检查并安装待处理的更新(主程序下载的)
var pendingUpdate = _updateEngine.CheckPendingUpdate();
if (pendingUpdate.HasUpdate)
{
_splashWindow?.UpdateStatus("正在安装更新...");
var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
if (!updateResult.Success)
{
return updateResult;
}
}
// 3. 检查并安装待处理的插件更新
var pendingPlugins = _pluginUpgradeQueueService.CheckPendingUpgrades();
if (pendingPlugins.HasUpgrades)
{
_splashWindow?.UpdateStatus("正在更新插件...");
var pluginResult = _pluginUpgradeQueueService.ApplyPendingUpgrades();
if (!pluginResult.Success)
{
return pluginResult;
}
}
// 4. OOBE
if (_oobeStateService.IsFirstRun())
{
_splashWindow?.Hide();
foreach (var step in _oobeSteps)
{
await step.RunAsync(CancellationToken.None);
}
_splashWindow?.Show();
}
// 5. 启动 IPC 服务端监听主程序进度
using var ipcServer = new LauncherIpcServer(msg =>
{
_splashWindow?.UpdateProgress(msg.ProgressPercent, msg.Message);
});
_ = ipcServer.StartAsync();
// 6. 启动主程序
_splashWindow?.UpdateStatus("正在启动...");
var hostResult = LaunchHostWithIpc();
if (!hostResult.Success)
{
return hostResult;
}
// 7. 等待主程序报告就绪或超时
await WaitForHostReadyOrTimeoutAsync(TimeSpan.FromSeconds(30));
return new LauncherResult { Success = true };
}
```
### P2: 主程序端实现
#### 6. 实现 IPC 客户端
**新建文件**: `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs`
```csharp
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Services.Launcher;
public class LauncherIpcClient : IDisposable
{
private NamedPipeClientStream? _pipeClient;
public async Task ConnectAsync(CancellationToken cancellationToken = default)
{
_pipeClient = new NamedPipeClientStream(
".",
LauncherIpcConstants.PipeName,
PipeDirection.Out);
await _pipeClient.ConnectAsync(5000, cancellationToken);
}
public async Task ReportProgressAsync(StartupProgressMessage message)
{
if (_pipeClient?.IsConnected != true)
return;
var json = JsonSerializer.Serialize(message);
using var writer = new StreamWriter(_pipeClient, leaveOpen: true);
await writer.WriteAsync(json);
await writer.FlushAsync();
}
public void Dispose()
{
_pipeClient?.Dispose();
}
}
```
#### 7. 主程序启动时报告进度
**修改文件**: `LanMountainDesktop/App.axaml.cs`
```csharp
public override async void OnFrameworkInitializationCompleted()
{
// 检查是否从 Launcher 启动
var launcherPid = Environment.GetEnvironmentVariable(LauncherIpcConstants.LauncherPidEnvVar);
if (!string.IsNullOrEmpty(launcherPid))
{
// 连接到 Launcher 的 IPC 服务端
_launcherIpc = new LauncherIpcClient();
await _launcherIpc.ConnectAsync();
// 报告启动进度
await _launcherIpc.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Initializing,
ProgressPercent = 10,
Message = "正在初始化..."
});
}
// 初始化设置
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.LoadingSettings,
ProgressPercent = 30,
Message = "正在加载设置..."
});
InitializeSettings();
// 加载插件
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.LoadingPlugins,
ProgressPercent = 50,
Message = "正在加载插件..."
});
await InitializePluginsAsync();
// 初始化 UI
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.InitializingUI,
ProgressPercent = 80,
Message = "正在初始化界面..."
});
InitializeUI();
// 就绪
await _launcherIpc?.ReportProgressAsync(new StartupProgressMessage
{
Stage = StartupStage.Ready,
ProgressPercent = 100,
Message = "就绪"
});
base.OnFrameworkInitializationCompleted();
}
```
### P3: 更新流程整合
#### 8. 主程序下载更新
**主程序职责**:
```csharp
// 主程序中的更新服务
public class AppUpdateService
{
public async Task DownloadUpdateAsync(string version, string downloadUrl)
{
// 使用多线程下载器下载更新包
var downloader = new MultiThreadedDownloader();
var targetPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
".launcher",
"update",
"incoming",
$"update-{version}.zip");
await downloader.DownloadAsync(downloadUrl, targetPath);
// 标记为待安装
File.WriteAllText(
Path.Combine(Path.GetDirectoryName(targetPath)!, ".pending"),
version);
}
}
```
#### 9. Launcher 安装更新
**Launcher 职责**:
```csharp
// Launcher 中的更新安装服务
public class UpdateInstallationService
{
public async Task<InstallResult> InstallPendingUpdateAsync()
{
var pendingPath = Path.Combine(
_appRoot,
".launcher",
"update",
"incoming",
".pending");
if (!File.Exists(pendingPath))
return InstallResult.NoUpdate;
var version = File.ReadAllText(pendingPath);
var updatePackagePath = Path.Combine(
Path.GetDirectoryName(pendingPath)!,
$"update-{version}.zip");
// 创建新版本目录
var newVersionDir = Path.Combine(_appRoot, $"app-{version}");
Directory.CreateDirectory(newVersionDir);
File.WriteAllText(Path.Combine(newVersionDir, ".partial"), "");
// 解压更新包
ZipFile.ExtractToDirectory(updatePackagePath, newVersionDir);
// 验证文件完整性
// ...
// 切换版本标记
var currentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (currentDir != null)
{
File.Delete(Path.Combine(currentDir, ".current"));
File.WriteAllText(Path.Combine(currentDir, ".destroy"), "");
}
File.WriteAllText(Path.Combine(newVersionDir, ".current"), "");
File.Delete(Path.Combine(newVersionDir, ".partial"));
// 清理待安装标记
File.Delete(pendingPath);
File.Delete(updatePackagePath);
return InstallResult.Success;
}
}
```
### P4: GitHub Actions 工作流
#### 10. 修改 release.yml
**关键修改点**:
```yaml
# 1. Launcher 单独编译(保留 Avalonia
- name: Publish Launcher
run: |
dotnet publish LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj `
-c Release `
-o ./publish/launcher-win-x64 `
--self-contained `
-r win-x64 `
-p:PublishSingleFile=false `
-p:DebugType=none
# 2. 目录结构调整
- name: Restructure for Launcher
run: |
$version = "${{ needs.prepare.outputs.version }}"
$publishDir = "publish/windows-x64"
$launcherDir = "publish/launcher-win-x64"
$appDir = "app-$version"
# 创建新结构
$newStructure = "publish-launcher/windows-x64"
New-Item -ItemType Directory -Path $newStructure -Force
# 移动主程序到 app-{version}/
$appPath = Join-Path $newStructure $appDir
Move-Item -Path $publishDir -Destination $appPath -Force
# 复制 Launcher 到根目录
Copy-Item -Path "$launcherDir\*" -Destination $newStructure -Recurse -Force
# 创建 .current 标记
New-Item -ItemType File -Path (Join-Path $appPath ".current") -Force
```
## 文件变更清单
### 修改文件
1. `LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj` - 调整引用
2. `LanMountainDesktop/LanMountainDesktop.csproj` - 移除 Launcher 引用
3. `LanMountainDesktop.Launcher/Services/LauncherFlowCoordinator.cs` - 添加 IPC 和更新安装
4. `LanMountainDesktop/App.axaml.cs` - 添加 IPC 客户端和进度报告
5. `.github/workflows/release.yml` - 调整打包流程
### 新增文件
1. `LanMountainDesktop.Shared.Contracts/Launcher/LauncherIpc.cs` - IPC 契约
2. `LanMountainDesktop.Launcher/Services/Ipc/LauncherIpcServer.cs` - IPC 服务端
3. `LanMountainDesktop/Services/Launcher/LauncherIpcClient.cs` - IPC 客户端
4. `LanMountainDesktop.Launcher/Services/Update/UpdateInstallationService.cs` - 更新安装
### 删除文件
1. 主程序对 Launcher 的项目引用(已存在)
## 实施顺序
### 第一阶段:基础架构
1. 创建 IPC 契约Shared.Contracts
2. 调整 Launcher 项目引用
3. 移除主程序对 Launcher 的引用
4. 测试基本启动
### 第二阶段IPC 实现
1. 实现 Launcher IPC 服务端
2. 实现主程序 IPC 客户端
3. 测试进度报告
### 第三阶段:更新流程
1. 主程序实现下载功能
2. Launcher 实现安装功能
3. 测试完整更新流程
### 第四阶段CI/CD
1. 修改 GitHub Actions
2. 测试打包流程
3. 验证安装程序
## 验证清单
- [ ] Launcher 能正常启动主程序
- [ ] Launcher 显示 Splash 并接收进度更新
- [ ] 主程序能向 Launcher 报告启动进度
- [ ] 主程序能下载更新
- [ ] Launcher 能安装待处理的更新
- [ ] OOBE 流程正常
- [ ] 插件更新流程正常
- [ ] GitHub Actions 打包成功
- [ ] 安装程序图标正常
- [ ] 快捷方式图标正常

View File

@@ -0,0 +1,112 @@
---
name: "refactoring-insight"
description: "Analyzes codebase for refactoring opportunities: large files, code duplication, god classes, naming inconsistencies, tight coupling, and missing abstractions. Invoke when user asks for refactoring insight/analysis or wants to improve code architecture."
---
# Refactoring Insight
Deep codebase analysis skill that identifies structural problems and produces prioritized refactoring recommendations.
## When to Invoke
- User asks for "refactoring insight", "refactoring analysis", "code quality analysis", "architecture review"
- User wants to understand what should be refactored in the codebase
- User asks "where are the code smells?" or "what needs refactoring?"
## Analysis Dimensions
Run all 6 dimensions in parallel where possible. For each dimension, use search agents to gather data, then synthesize findings.
### 1. Large Files / God Classes
- Find all .cs files over 300 lines, sorted by line count descending
- Identify partial classes and sum their total line count across files
- Flag classes with 15+ methods or constructors taking 8+ parameters
- Focus on: Views/, ViewModels/, Services/, plugins/
**Output**: Table of files with line counts and responsibility summary.
### 2. Code Duplication
Search for these specific duplication patterns:
- **Service boilerplate**: Repeated DI registration, `new` instantiation instead of DI
- **Data service pattern**: Services that fetch/parse/transform data similarly (Load → Map → Save)
- **Localization pattern**: `private readonly LocalizationService _localizationService = new();` and `L()` helper method repetitions
- **Helper method duplication**: Methods like `ResolveUnifiedMainRadiusValue`, `NormalizeConfig`, `ParticleState` classes copied across files
- **Error handling pattern**: Identical try-catch blocks repeated in multiple methods
- **Settings snapshot pattern**: `_settingsFacade.Settings.LoadSnapshot<T>(scope)` call sites
**Output**: List of duplicated patterns with file locations and line numbers.
### 3. Tight Coupling
- Services instantiated via `new` instead of DI injection
- ViewModels directly accessing infrastructure-layer APIs (e.g., `LoadSnapshot/SaveSnapshot`)
- Hard-coded dependencies (GitHub repo owner/name, default values)
- `Application.Current` upcasting to access services: `(Application.Current as App)?.SomeService`
- Platform-specific code embedded in cross-platform services without interface abstraction
**Output**: Table of coupling violations with severity (high/medium/low).
### 4. Naming Inconsistencies
- Service suffix inconsistency: `Service` vs `Store` vs `Helper` vs `Provider` vs `Manager` vs `Factory` for similar responsibilities
- Model suffix inconsistency: `Snapshot` vs `State` vs `Types` for similar concepts
- Platform prefix inconsistency: `Windows`/`Linux` full name vs `Mac` abbreviation
- Confusing names: services with similar names but different responsibilities (e.g., `NotificationService` vs `NotificationListenerService`)
**Output**: Categorized list of naming inconsistencies.
### 5. Missing Abstractions
- Services without corresponding interfaces (check for `I<ServiceName>` pattern)
- Common patterns that could be extracted into base classes:
- `SettingsPageViewModelBase` for shared ViewModel boilerplate
- `JsonFileSettingsService<TSnapshot>` for repeated settings persistence
- `SettingsDomainServiceBase<TState>` for Load-Map-Save pattern
- `DesktopComponentWidgetBase` for shared Widget code
- `ComponentEditorViewBase` enhancements (e.g., `_suppressEvents` pattern)
- Static singleton/Factory providers repeating thread-safe lazy-load boilerplate
**Output**: List of missing abstractions with proposed base class/interface names.
### 6. Misplaced Responsibilities
- Files in wrong directories (e.g., data access in Settings/, UI services mixed with data services)
- ViewModels containing business logic or file system operations
- Widget code-behind files with excessive logic (>200 lines)
- Platform-specific services not organized into subdirectories
**Output**: List of misplaced files/classes with recommended new locations.
## Output Format
Produce a structured report with:
1. **Summary table**: Total metrics (file count, duplication count, etc.)
2. **Priority-ranked findings**: P0 (must fix), P1 (should fix), P2 (recommended), P3 (nice to have)
3. **Each finding includes**: Problem description, affected files with links, specific line numbers, recommended action, estimated impact
### Priority Criteria
- **P0**: Files over 1000 lines with mixed responsibilities; patterns duplicated 10+ times; god classes with 20+ dependencies
- **P1**: Patterns duplicated 5-9 times; services without interfaces that are widely used; DI bypass affecting testability
- **P2**: Patterns duplicated 3-4 times; naming inconsistencies affecting readability; misplaced files
- **P3**: Minor naming variations; single-instance duplications; organizational improvements
## Project-Specific Context
This skill is aware of the LanMountainDesktop project structure:
- `LanMountainDesktop/Services/` — Business and infrastructure services
- `LanMountainDesktop/Services/Settings/` — Settings subsystem
- `LanMountainDesktop/ViewModels/` — View models
- `LanMountainDesktop/Views/Components/` — Desktop widget components
- `LanMountainDesktop/Views/ComponentEditors/` — Widget editor views
- `LanMountainDesktop/plugins/` — Plugin runtime
- `LanMountainDesktop.PluginSdk/` — Plugin SDK public API
- `LanMountainDesktop.Shared.Contracts/` — Host/plugin shared contracts
- `LanMountainDesktop.Appearance/` — Appearance and corner radius infrastructure
When analyzing, respect the project's architectural boundaries documented in `docs/ARCHITECTURE.md` and `docs/ECOSYSTEM_BOUNDARIES.md`.

View File

@@ -0,0 +1,21 @@
# Checklist
- [x] `Directory.Packages.props` contains the Avalonia 12 dependency baseline.
- [x] Main host references `Avalonia.Controls.WebView`.
- [x] Source no longer references `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
- [x] `BrowserWidget` uses `NativeWebView.Source`, `Navigate`, `Refresh()`, `NavigationStarted`, and `EnvironmentRequested`.
- [x] WebView blanking navigates to `about:blank`.
- [x] Plugin SDK package version is `5.0.0`.
- [x] `PluginSdkInfo.ApiVersion` is `5.0.0`.
- [x] Plugin template package version default is `5.0.0`.
- [x] Plugin template manifest `apiVersion` is `5.0.0`.
- [x] Launcher data location config resolution cannot recurse through `ResolveDataRoot()`.
- [x] `OobeStateServiceTests` pass.
- [x] `dotnet build LanMountainDesktop.slnx -c Debug` has 0 errors.
- [x] `dotnet test LanMountainDesktop.slnx -c Debug` completes without a test host stack overflow.
- [ ] Windows host smoke test completed.
- [ ] Windows Launcher smoke test completed.
- [ ] Settings window FluentAvalonia 3 smoke test completed.
- [ ] Component editor Material smoke test completed.
- [ ] BrowserWidget navigation/refresh/page activation smoke test completed.
- [ ] WebView2 missing-runtime diagnostic smoke test completed.

View File

@@ -0,0 +1,49 @@
# Avalonia 12 Full Stack Migration
## Summary
LanMountainDesktop has moved its desktop stack to the Avalonia 12 baseline. The migration covers the main host, Launcher, Plugin SDK, plugin runtime loading policy, official WebView usage, ClassIsland Markdown, FluentAvalonia, FluentIcons, and Material-related dependencies.
## Requirements
### Requirement: Centralized Avalonia 12 dependency baseline
The solution SHALL use central package management for direct Avalonia-facing projects and keep the core UI dependency baseline on Avalonia `12.0.1`.
Required package baseline:
- `Avalonia*` `12.0.1`
- `Avalonia.Controls.WebView` `12.0.0`
- `ClassIsland.Markdown.Avalonia` `12.0.0`
- `FluentAvaloniaUI` `3.0.0-preview1`
- `FluentIcons.Avalonia` `2.1.325`
- `Material.Avalonia` `3.16.0`
- `Material.Icons.Avalonia` `3.0.2`
### Requirement: Official WebView
The host SHALL use `Avalonia.Controls.NativeWebView` for the browser widget and SHALL NOT reference `WebView.Avalonia`, `AvaloniaWebView`, or `.UseDesktopWebView()`.
Windows WebView2 user data configuration SHALL be supplied through `EnvironmentRequested` using `WindowsWebView2EnvironmentRequestedEventArgs.UserDataFolder`.
### Requirement: Plugin SDK v5
The Plugin SDK API baseline SHALL be `5.0.0`. SDK v4 plugins are treated as incompatible until rebuilt.
The SDK SHALL keep the existing public UI extension shape, including `SettingsPageBase` and Avalonia `Control` based desktop components.
### Requirement: Launcher data location stability
Launcher data location configuration SHALL be read from a fixed bootstrap Launcher data directory so resolving the selected data root cannot recursively require resolving itself.
### Requirement: OOBE state compatibility
The Launcher SHALL read current OOBE state from the resolved `Launcher/state` directory and SHALL continue to migrate the legacy `.launcher/state/first_run_completed` marker.
## Acceptance
- `dotnet build LanMountainDesktop.slnx -c Debug` completes with 0 errors.
- `OobeStateServiceTests` pass.
- Full `dotnet test LanMountainDesktop.slnx -c Debug` no longer aborts from `DataLocationResolver` recursion.
- Plugin template defaults to SDK package version `5.0.0` and manifest `apiVersion` `5.0.0`.
- Current developer documentation points to SDK v5 and Avalonia 12.

View File

@@ -0,0 +1,18 @@
# Tasks
- [x] Centralize Avalonia 12 package versions in `Directory.Packages.props`.
- [x] Move the host, Launcher, Plugin SDK, DesktopHost, Shared.Contracts, and Avalonia-facing projects onto central package versions.
- [x] Replace third-party `WebView.Avalonia` usage with official `NativeWebView`.
- [x] Configure WebView2 user data through `EnvironmentRequested`.
- [x] Move FluentAvalonia usages to the FA3 control names and package baseline.
- [x] Move FluentIcons usage to `FluentIcons.Avalonia` and remove the old `.Fluent` package.
- [x] Update Plugin SDK package version and API baseline to `5.0.0`.
- [x] Update plugin runtime shared assembly policy for Avalonia 12 / FluentAvalonia / FluentIcons / Material.
- [x] Fix Avalonia 12 compile breaks in window chrome, binding plugin access, clipboard, bitmap copy, and icon source usage.
- [x] Fix Launcher data location recursion by using a fixed bootstrap config path.
- [x] Fix OOBE state tests and legacy marker compatibility.
- [x] Update PluginTemplate defaults to SDK v5.
- [x] Add SDK v5 migration documentation.
- [x] Update current docs from SDK v4 / Avalonia 11 examples to SDK v5 / Avalonia 12.
- [x] Run full solution tests and record any remaining non-upgrade failures.
- [ ] Perform Windows manual smoke test for host, Launcher, settings, component editor, BrowserWidget, and WebView2 missing-runtime handling.

View File

@@ -0,0 +1,11 @@
# External IPC Public API Checklist
- [x] Host can expose strong-typed public IPC services.
- [x] External .NET client can connect and call built-in services.
- [x] Host publishes launcher startup and loading-state notifications through routed notify.
- [x] Launcher consumes routed notify instead of the old primary custom named-pipe path.
- [x] Plugin SDK exposes public IPC contribution primitives.
- [x] Plugin runtime can discover and register plugin public IPC services.
- [x] Public catalog includes built-in and plugin-contributed services.
- [x] `catalog.changed` is emitted when new services are added after startup.
- [ ] Add example external client sample.

View File

@@ -0,0 +1,24 @@
# External IPC Public API Spec
## Goal
Provide a single `dotnetCampus.Ipc` based external integration layer for:
- Host public APIs
- Launcher/OOBE startup progress and loading-state notifications
- plugin-contributed public services and live event push
## Delivered
- `LanMountainDesktop.Shared.IPC` project
- `[IpcPublic]` based built-in public contracts
- `PublicIpcHostService` and `LanMountainDesktopIpcClient`
- Launcher migrated to Host public IPC notifications
- Plugin SDK public IPC contribution API
- Host runtime integration for plugin public IPC services
## Out of Scope
- plugin process isolation
- non-.NET strong-typed public IPC clients
- live plugin public service removal without restart

View File

@@ -0,0 +1,12 @@
# External IPC Public API Tasks
- [x] Add `LanMountainDesktop.Shared.IPC`
- [x] Expose built-in `[IpcPublic]` services
- [x] Add routed notify constants and public IPC client/host wrappers
- [x] Start Host public IPC during app startup
- [x] Move Launcher startup progress consumption to the new IPC base
- [x] Add plugin public IPC registration/contributor SDK
- [x] Register plugin-contributed public services into Host catalog
- [x] Add integration tests for strong-typed public service access and plugin registration descriptors
- [ ] Expand built-in public service surface beyond the first minimal set
- [ ] Add non-.NET bridge guidance and samples

View File

@@ -0,0 +1,166 @@
# 融合桌面组件库窗口重设计规格
## Why
当前融合桌面组件库窗口FusedDesktopComponentLibraryWindow的UI设计较为基础与Windows 11小组件编辑面板相比缺乏现代化的交互体验和视觉层次。用户需要一个更直观、更美观的界面来浏览和添加组件到系统桌面负一屏
参考Windows 11小组件编辑面板的设计特点
* 左侧分类列表,右侧选中组件的详细预览
* 大型组件预览区域,让用户清楚看到组件效果
* 底部明显的"添加"操作按钮
* 简洁的关闭按钮X在右上角
* 深色主题配合毛玻璃效果
## What Changes
* **重新设计窗口布局**:从左右分栏(分类列表+组件网格)改为左侧面板+右侧预览区的布局
* **添加组件详情预览区**:选中组件后右侧显示大尺寸预览和组件信息
* **优化关闭按钮**使用标准的X图标按钮不使用圆形样式
* **添加底部操作栏**:包含"添加到桌面"主操作按钮和"查找更多组件"链接
* **复用阑山桌面组件库分类**使用相同的分类ID、图标和本地化文本
* **移除搜索功能**参考Windows 11设计暂不提供搜索
## Impact
* 受影响文件:
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml`
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs`
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml`
* `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
* `LanMountainDesktop/ViewModels/ComponentLibraryWindowViewModel.cs`(可能需要添加新属性)
## ADDED Requirements
### Requirement: 窗口布局重设计
系统应提供一个类似于Windows 11小组件编辑面板的组件库窗口。
#### Scenario: 窗口整体结构
* **GIVEN** 用户从托盘菜单打开融合桌面组件库
* **WHEN** 窗口显示时
* **THEN** 窗口应呈现:
* 顶部标题栏:左侧显示"添加小组件"标题右侧有关闭按钮X
* 左侧面板:分类列表(复用阑山桌面组件库的分类和图标)
* 右侧主区域:选中组件的大尺寸预览 + 组件信息 + 添加按钮
* 底部:"查找更多组件"链接
#### Scenario: 分类列表交互
* **GIVEN** 左侧显示组件分类列表
* **WHEN** 用户点击某个分类
* **THEN** 右侧应显示该分类下的第一个组件的预览
* **AND** 分类项应有选中状态视觉反馈
* **AND** 分类图标和名称应与阑山桌面组件库保持一致
#### Scenario: 组件预览区
* **GIVEN** 用户选中一个组件
* **WHEN** 预览区显示时
* **THEN** 应显示:
* 组件标题(大字号)
* 大尺寸组件预览图(接近实际尺寸)
* 组件描述/功能说明
* 底部"添加到桌面"按钮
#### Scenario: 添加组件操作
* **GIVEN** 用户查看组件预览
* **WHEN** 用户点击"添加到桌面"按钮
* **THEN** 组件应被添加到系统桌面(负一屏)中央
* **AND** 窗口应关闭
#### Scenario: 关闭按钮样式
* **GIVEN** 窗口标题栏有关闭按钮
* **THEN** 关闭按钮应使用标准的X图标
* **AND** 不使用圆形背景或特殊样式
* **AND** 使用 `DesignCornerRadiusSm` 动态资源
#### Scenario: 查找更多组件链接
* **GIVEN** 窗口底部显示"查找更多组件"链接
* **WHEN** 用户点击该链接
* **THEN** 应打开设置窗口的插件目录页面(后续将改为插件市场)
## MODIFIED Requirements
### Requirement: 组件列表展示
原实现使用网格展示所有组件,新实现改为:
* 左侧列表仅显示分类复用阑山桌面组件库的分类ID和图标映射
* 右侧预览区一次只显示一个组件的详细信息
* ~~移除搜索功能~~根据Windows 11设计暂不提供搜索
### Requirement: 关闭按钮圆角规范
原实现关闭按钮使用硬编码 `CornerRadius="18"`,应改为使用动态资源 `DesignCornerRadiusSm`
### Requirement: 分类图标复用
分类图标映射应与阑山桌面组件库保持一致:
* Clock -> Symbol.Clock
* Date -> Symbol.CalendarDate
* Weather -> Symbol.WeatherSunny
* Board -> Symbol.Edit
* Media -> Symbol.Play
* Info -> Symbol.Info
* Calculator -> Symbol.Calculator
* Study -> Symbol.Hourglass
* 其他 -> Symbol.Apps
## REMOVED Requirements
* ~~搜索功能~~根据Windows 11小组件面板设计暂不提供搜索功能

View File

@@ -0,0 +1,35 @@
# Tasks
- [x] Task 1: 修改 FusedDesktopComponentLibraryWindow.axaml 窗口布局
- [x] SubTask 1.1: 重新设计标题栏使用标准X关闭按钮移除圆形样式使用 DesignCornerRadiusSm
- [x] SubTask 1.2: 调整窗口整体布局为左侧面板+右侧预览区
- [x] SubTask 1.3: 添加底部"查找更多组件"链接区域
- [x] Task 2: 修改 FusedDesktopComponentLibraryControl.axaml 控件布局
- [x] SubTask 2.1: 重新设计左侧面板:仅保留分类列表(移除搜索框)
- [x] SubTask 2.2: 重新设计右侧预览区:组件标题 + 大尺寸预览 + 描述 + 添加按钮
- [x] SubTask 2.3: 优化分类列表项样式,添加选中状态视觉反馈
- [x] SubTask 2.4: 复用阑山桌面组件库的分类图标映射
- [x] Task 3: 更新 ViewModel 支持新交互模式
- [x] SubTask 3.1: 在 ComponentLibraryWindowViewModel 中添加 SelectedComponent 属性
- [x] SubTask 3.2: 添加组件描述属性支持
- [x] Task 4: 更新 FusedDesktopComponentLibraryControl.axaml.cs 代码逻辑
- [x] SubTask 4.1: 修改分类选择逻辑,选中分类时显示该分类第一个组件
- [x] SubTask 4.2: 添加组件选中逻辑
- [x] SubTask 4.3: 移除搜索相关代码
- [x] SubTask 4.4: 复用阑山桌面组件库的分类图标和本地化方法
- [x] SubTask 4.5: 添加"查找更多组件"链接点击处理(打开设置窗口插件目录)
- [x] Task 5: 验证和测试
- [x] SubTask 5.1: 验证关闭按钮使用动态圆角资源 DesignCornerRadiusSm
- [x] SubTask 5.2: 验证窗口布局符合Windows 11小组件面板风格
- [x] SubTask 5.3: 验证分类图标与阑山桌面组件库一致
- [x] SubTask 5.4: 验证组件添加功能正常工作
- [x] SubTask 5.5: 验证"查找更多组件"链接能打开设置窗口
# Task Dependencies
- Task 3 依赖于 Task 1 和 Task 2 的UI设计确定
- Task 4 依赖于 Task 3 的ViewModel更新
- Task 5 依赖于所有前置任务完成

View File

@@ -0,0 +1,6 @@
- [x] 从桌面、托盘、IPC、组件库进入设置时都会落到同一个设置窗口
- [x] 设置已打开时再次触发设置入口,只会聚焦已有窗口,不会切换成关闭
- [x] 设置窗口始终拥有独立任务栏图标,不受“桌面主窗口在任务栏显示图标”开关影响
- [x] 点击“回到 Windows”后只隐藏或最小化桌面主窗口设置窗口保持可见
- [x] 启用滑入滑出动画后,只有主窗口参与动画,设置窗口不参与
- [x] 点击设置窗口关闭按钮后会真实关闭;再次打开时创建新的居中窗口

View File

@@ -0,0 +1,78 @@
# 独立设置窗口 Spec
## Why
- 当前设置窗口仍然带有桌面壳的 owner / anchor 语义,点击“回到 Windows”或触发桌面动画时容易被一起隐藏或重新定位。
- 产品新增了“在任务栏显示图标”和“启用滑入滑出动画”设置,需要明确边界:它们只影响桌面主窗口,不影响设置窗口。
- 桌面底栏、托盘菜单、IPC、组件库等入口应当始终打开同一个独立设置窗口而不是切换成附属浮窗或开关行为。
## What Changes
- 将设置窗口改为独立顶层窗口,始终使用自己的任务栏按钮和图标。
- `SettingsWindowService.Open` 改为幂等的 open-or-focus重复打开只聚焦已有窗口并在提供目标页时切换到对应页面。
- 移除 `Owner`、锚点定位和 `Toggle` 语义;首次打开按参考屏幕居中,关闭为真实关闭。
- 桌面壳的“回到 Windows”、最小化到托盘/任务栏、滑入滑出动画,只影响 `MainWindow`,不会影响设置窗口。
- 统一桌面、托盘、IPC、组件库等设置入口全部走 `OpenIndependentSettingsModule`
- 设置页文案明确“在任务栏显示图标”只控制桌面主窗口;设置窗口始终保留独立任务栏图标。
## Impact
- Affected code:
- `LanMountainDesktop/Services/Settings/SettingsWindowService.cs`
- `LanMountainDesktop/App.axaml.cs`
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
- `LanMountainDesktop/Views/MainWindow.ComponentSystem.cs`
- `LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml.cs`
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml`
- Affected behavior:
- 设置窗口生命周期
- 设置入口一致性
- 任务栏图标与桌面壳显示边界
---
## ADDED Requirements
### Requirement: 设置窗口为独立顶层窗口
系统 SHALL 将设置窗口作为独立顶层窗口显示,而不是作为桌面主窗口的附属子窗。
#### Scenario: 设置窗口拥有独立任务栏图标
- **WHEN** 用户打开设置窗口
- **THEN** 设置窗口使用独立顶层窗口方式显示
- **AND THEN** 设置窗口在任务栏中保留自己的独立按钮和图标
- **AND THEN** “在任务栏显示图标”开关不会影响设置窗口的任务栏按钮
### Requirement: 设置入口统一为 open-or-focus
系统 SHALL 让所有设置入口打开或聚焦同一个设置窗口实例。
#### Scenario: 已打开时重复触发设置入口
- **WHEN** 设置窗口已经打开,用户再次从桌面、托盘或 IPC 触发打开设置
- **THEN** 系统只聚焦现有设置窗口
- **AND THEN** 如果请求包含目标页,则导航到目标页
- **AND THEN** 不会把已打开的设置窗口当作开关关闭
### Requirement: 设置窗口不参与桌面壳可见性切换
系统 SHALL 让桌面壳的隐藏、最小化和进出场动画只作用于主窗口。
#### Scenario: 回到 Windows 时设置窗口保持可见
- **WHEN** 主窗口执行“回到 Windows”并隐藏到托盘或最小化到任务栏
- **THEN** 设置窗口保持当前可见状态
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
#### Scenario: 桌面滑入滑出动画不作用于设置窗口
- **WHEN** 启用了滑入滑出动画并触发主窗口退场或入场
- **THEN** 只有主窗口参与动画
- **AND THEN** 设置窗口不会消失,也不会跟随主窗口做进出场动画
### Requirement: 关闭设置窗口时真实销毁实例
系统 SHALL 在用户关闭设置窗口时真实关闭该窗口实例。
#### Scenario: 关闭后再次打开
- **WHEN** 用户点击设置窗口右上角关闭按钮
- **THEN** 当前设置窗口实例被关闭并销毁
- **AND THEN** 下次再次打开设置时创建新的设置窗口实例
- **AND THEN** 新窗口按参考屏幕居中显示

View File

@@ -0,0 +1,25 @@
# Tasks
- [x] Task 1: 简化设置窗口打开契约
- [x]`SettingsWindowOpenRequest` 从 owner / anchor 语义改为目标页 + 参考屏幕语义
- [x] 移除 `ISettingsWindowService.Toggle`
- [x] Task 2: 重做设置窗口服务行为
- [x] 设置窗口始终使用 `Show()` 打开
- [x] 设置窗口始终 `ShowInTaskbar = true`
- [x] 已打开时只聚焦并在需要时切页
- [x] 关闭后销毁实例,下次打开重新创建并居中
- [x] Task 3: 统一设置入口并解耦桌面壳
- [x] 桌面底栏设置按钮改为 open-or-focus
- [x] 组件库入口改为复用 `OpenIndependentSettingsModule`
- [x] 移除 `MainWindow` 上的设置窗口锚点逻辑
- [x] Task 4: 明确产品边界
- [x] 调整“在任务栏显示图标”文案,限定为桌面主窗口
- [x] 新增独立设置窗口 feature spec
- [x] 在窗口过渡动画 spec 中补充“设置窗口不参与动画”
- [x] Task 5: 验证
- [x] 运行 `dotnet build LanMountainDesktop.slnx -c Debug`
- [x] 运行与新 helper 相关的测试

View File

@@ -0,0 +1,8 @@
# Launcher OOBE and Elevation Hardening Checklist
- [ ] New install shows OOBE once.
- [ ] Same-user reinstall does not show OOBE again.
- [ ] `postinstall` launch path is handled without misclassifying the user state.
- [ ] `apply-update` and `plugin-install` do not auto-enter OOBE.
- [ ] Default plugin install does not request UAC.
- [ ] Logs include OOBE status, suppression reason, and launch source.

View File

@@ -0,0 +1,43 @@
# Launcher OOBE and Elevation Hardening Spec
## Goal
Stabilize the launcher startup path so that:
- OOBE does not reappear for the same Windows user after reinstall/upgrade.
- Normal startup, OOBE, update checks, incremental downloads, and default plugin installs do not trigger unexpected UAC prompts.
- Only the approved elevation paths remain allowed.
## Scope
- Launcher OOBE state handling
- launch source classification
- elevation boundary cleanup
- plugin install default behavior
- diagnostic logging and troubleshooting guidance
## Behavior
- OOBE state is stored as a per-user truth source at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- `first_run_completed` is treated as a legacy compatibility marker only.
- `launchSource` values are treated as:
- `normal`
- `postinstall`
- `apply-update`
- `plugin-install`
- `debug-preview`
- Automatic OOBE is allowed only for normal user-mode startup.
- `postinstall` may show OOBE only when the launcher is not elevated and user state is available.
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
- Allowed elevation paths are limited to:
- the installer itself
- full installer update application
- user-confirmed legacy uninstall
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
## Acceptance
- Same-user reinstall does not re-enter OOBE.
- Missing or damaged OOBE state does not silently bounce the user back into OOBE loops.
- Default plugin installation path never triggers surprise UAC.
- Logs can explain why OOBE was shown or suppressed and why elevation was or was not requested.

View File

@@ -0,0 +1,9 @@
# Launcher OOBE and Elevation Hardening Tasks
- [ ] Move OOBE state to a single per-user JSON source.
- [ ] Treat `first_run_completed` as legacy migration-only state.
- [ ] Add explicit `launchSource` handling for startup and maintenance flows.
- [ ] Suppress auto-OOBE for maintenance and elevated launch contexts.
- [ ] Remove default elevation from plugin installation into the user data scope.
- [ ] Add structured diagnostics for OOBE decisions and elevation reasons.
- [ ] Update launcher docs and troubleshooting guidance.

View File

@@ -0,0 +1,10 @@
# 验收清单
- [ ] 设置页重启后Launcher 能重新接管并恢复到正确展示形态。
- [ ] 插件升级辅助程序完成后,回拉的是 Launcher 而不是宿主 exe。
- [ ] 已在托盘中的实例再次启动时,不会出现第二个主进程。
- [ ] 托盘初始化失败时,应用不会进入无入口的 `TrayOnly`
- [ ] 托盘运行中丢失时watchdog 能重建或自动恢复前台。
- [ ] Launcher UI 版本与应用设置页版本一致。
- [ ] 发布 tag `vX.Y.Z.W`manifest、程序集、`version.json`、安装包和资产命名一致。
- [ ] 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口、通知动画正常。

View File

@@ -0,0 +1,29 @@
# Launcher Coordinator And Always-On Tray Addendum
## Launcher-to-launcher coordination
- Launcher reserves startup ownership in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json` before it starts the host process.
- The reserved record includes `CoordinatorPid`, `CoordinatorPipeName`, `HeartbeatAtUtc`, `PublicIpcConnected`, `ShellStatus`, and `ReservedBeforeHostStart`.
- Only the active coordinator may call `Process.Start()` for the host. Secondary Launchers attach to the coordinator pipe and request desktop activation or status.
- If the coordinator heartbeat is newer than `10s` and the coordinator pid is alive, a new Launcher must not take over.
- If the coordinator is stale, the next Launcher may take over the same pending attempt instead of creating a second host attempt.
- Normal launches probe Host Public IPC first. If a host is already running, Launcher activates that instance and exits without starting another host.
## Finer shell status
- Public shell IPC exposes `GetShellStatusAsync()`, `ActivateMainWindowWithStatusAsync()`, `EnsureTrayReadyAsync()`, and `EnsureTaskbarEntryAsync()`.
- `PublicShellStatus` separates process, shell state, main-window visibility, tray health, taskbar-entry health, and Public IPC readiness.
- Launcher success/failure details must include coordinator pid, attempt id, host pid, Public IPC status, tray state, and taskbar usability when available.
## Always-on tray and taskbar repair
- The tray icon and menu are mandatory application-liveness indicators and are not controlled by user settings.
- Tray watchdog starts during shell initialization and keeps running until application exit.
- `ShowInTaskbar=true` means hidden/background states prefer `MinimizedToTaskbar`; it never disables the tray.
- `ShowInTaskbar=false` is the only mode that may enter pure `TrayOnly`, and only after `TrayReady`.
- When taskbar entry is requested but missing, shell repair recreates or shows the main window minimized with `ShowInTaskbar=true` while keeping the tray visible.
## Regression coverage
- Unit tests cover active coordinator rejection, stale heartbeat takeover, and host-pid assignment after a reserved attempt.
- Manual QA still needs multi-process Launcher concurrency and real tray loss simulation on Windows.

View File

@@ -0,0 +1,67 @@
# Launcher 外壳托管、托盘兜底与高分屏动画修复
## 背景
当前桌面应用在以下场景存在明显不稳定性:
- 设置页或升级后的“重启”没有统一回到 Launcher。
- 已有实例处于托盘时,再次启动容易误报“窗口未显示”,甚至重复拉起。
- 托盘初始化失败或运行中丢失时,应用可能进入无恢复入口状态。
- Launcher 和宿主的版本来源不一致,发布后容易出现 UI 版本错乱。
- 高分屏和混合缩放环境下Launcher OOBE、主窗口入场和通知动画存在像素/DIP 混用问题。
## 目标
- Launcher 成为正式环境唯一的启动与重启入口。
- 进入 `TrayOnly` 前必须先确认托盘可恢复。
- Launcher UI 显示的版本号等于应用版本号。
- 发布工作流显式同步主程序、Launcher、manifest 和产物版本。
- 动画和定位统一按 DIP 与缩放计算。
## 行为要求
### 1. 重启接管
- 应用内重启、插件升级后的重启都必须优先回到 Launcher。
- Launcher 对 `SecondaryActivationSucceeded` 只认定为一次成功重定向,不允许再做 fallback 二次拉起。
- Launcher 启动成功判定区分三类场景:
- 前台启动:`DesktopVisible``ActivationRedirected`
- 重启到最小化:`BackgroundReady`
- 重启到托盘:`TrayReady + BackgroundReady`
### 2. 托盘硬约束
- 托盘状态机必须至少覆盖:
- `Unavailable`
- `Initializing`
- `Ready`
- `Recovering`
- `Failed`
- `HideMainWindowToTray`、关闭到托盘、重启恢复到托盘前都必须先执行托盘就绪检查。
- 如果托盘不可用:
- 优先回退到任务栏最小化
- 若任务栏入口也不可用,则强制恢复前台可见
- 托盘处于隐藏态期间必须运行 watchdog连续恢复失败时自动恢复主窗口。
### 3. 版本来源
- Launcher 只能显示应用版本,不能显示 Launcher 自身硬编码版本。
- 版本解析优先顺序:
- `version.json`
- 主程序文件版本 / 信息版本
- `app-<version>` 部署目录
- Release 工作流必须显式打版本补丁,避免仓库默认占位值被误当成正式版本。
### 4. 高分屏动画
- 主窗口、通知、Launcher OOBE 的动画位移必须使用 DIP 或基于缩放换算后的尺寸。
- 不允许直接把 `PixelRect` 宽高当作 `TranslateTransform``DesiredSize` 的输入。
- 淡入和位移动画应并行执行,避免先淡入后滑动造成观感异常。
## 验收
- 已在托盘中的实例再次通过 Launcher 启动时,只激活已有实例。
- 设置页重启和插件升级重启后,不再出现“窗口未显示但后台已有多个进程”。
- 托盘失败时应用仍保持可恢复。
- Launcher 与应用设置页显示相同版本。
- 100% / 150% / 200% / 250% 缩放下Launcher OOBE、主窗口入场、通知位置与动画正常。

View File

@@ -0,0 +1,37 @@
# Launcher Slow-Startup And Startup Visual Addendum
## New startup timing contract
- `30s` is a soft timeout, not a failure threshold.
- After `30s`, if the desktop process is still alive or Public IPC is connected, Launcher must stay in a waiting state and must not start another host process.
- `120s` is the hard timeout.
- Before returning `desktop_not_visible`, Launcher must attempt one foreground recovery through `ActivateMainWindowAsync()`.
## Startup attempt de-duplication
- Launcher persists the current startup attempt in `%LocalAppData%\LanMountainDesktop\.launcher\state\startup-attempt.json`.
- A second Launcher process must attach to a live pending attempt instead of calling `Process.Start()` again.
- Closing the splash window does not cancel startup; it transitions the attempt into detached waiting and preserves recovery state for the next Launcher run.
## Startup visual modes
- `EnableSlideTransition = true` forces `StartupVisualMode.SlideSplash` and automatically disables fade.
- `EnableSlideTransition = false && EnableFadeTransition = false` resolves to `StartupVisualMode.StaticSplash`.
- `EnableSlideTransition = false && EnableFadeTransition = true` resolves to `StartupVisualMode.Fade`.
## UX safeguards
- If the host process is still alive at failure time, the failure dialog must prefer:
- `Activate`
- `Wait`
- `Open Logs`
- `Exit`
- Retry is only valid when Launcher is not about to create a duplicate desktop process.
## Launcher coordinator guard
- Startup attempts are now reserved before host launch, so concurrent Launchers cannot all reach `Process.Start()`.
- A live coordinator is identified by `CoordinatorPid`, `CoordinatorPipeName`, and a heartbeat newer than `10s`.
- Secondary Launchers send `activate-desktop` or `attach` to the coordinator pipe and then exit with the coordinator status.
- If Host Public IPC is already available during a normal launch, Launcher activates the existing desktop and does not start a new host process.
- Public shell status now reports tray readiness and taskbar-entry usability separately, allowing Launcher to distinguish "running but hidden" from "not recoverable".

View File

@@ -0,0 +1,14 @@
# 任务拆解
- [x] 为 Launcher/宿主共享新增重启来源、父进程和展示模式参数。
- [x] 修复 Launcher 对 `SecondaryActivationSucceeded` 的重复 fallback 拉起。
- [x] 让 Launcher 成功判定支持 `TrayReady``BackgroundReady`
- [x] 应用重启默认优先回到 Launcher而不是直接回拉宿主 exe。
- [x] 抽出独立托盘服务集中处理创建、刷新、watchdog 与状态流转。
- [x] 在进入 `TrayOnly` 前增加托盘就绪校验与回退策略。
- [x] 为运行中托盘丢失增加 watchdog 和自动恢复逻辑。
- [x] 统一公共 IPC、设置页与 Launcher 的版本读取入口。
- [x] 将仓库默认版本改为开发占位值,并在 Release 工作流中加入显式打版本步骤。
- [x] 修复主窗口入场、通知定位和 Launcher OOBE 的高分屏动画/定位问题。
- [x] 补充规格与版本同步说明文档。
- [ ] 追加针对托盘恢复和启动判定的自动化回归测试。

View File

@@ -0,0 +1,17 @@
# Tray Menu Shutdown Addendum
## Requirements
- Tray menu `Exit App` must commit an irreversible host shutdown request.
- Once shutdown is committed, tray menu actions must not reopen the desktop, settings window, or component library.
- Shutdown cleanup must release Public IPC, plugin runtime, tray icon, fused desktop edit UI, telemetry resources, and the single-instance lock before the forced-exit deadline.
- Forced process termination must be scheduled when the shutdown request is accepted, not only after Avalonia lifetime exit.
- Restart must preserve `RestartRequested` intent and must not route through an exit path that overwrites it.
- Fused desktop component library menu activation must reuse the existing library window and must exit edit mode if opening fails.
## Acceptance
- Selecting `Exit App` from the tray leaves no background host process and allows a later Launcher start to acquire the single-instance lock.
- Selecting `Restart App` starts the Launcher or upgrade helper once, then shuts down the old host as a restart.
- Repeated tray clicks during shutdown are ignored and logged.
- Repeated component-library clicks focus the existing window instead of opening duplicates.

View File

@@ -0,0 +1,11 @@
# Launcher Upgrade Checklist
- [x] Build passes for `LanMountainDesktop.Launcher`.
- [x] `update check` command returns structured JSON result.
- [x] `plugin update` command returns structured JSON result.
- [x] Legacy plugin install arguments still execute.
- [x] OOBE and splash are implemented as separate windows.
- [x] Update and rollback logic use version directory markers.
- [ ] Treat `first_run_completed` as legacy-only compatibility data.
- [ ] Keep the authoritative OOBE state in `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.

View File

@@ -0,0 +1,60 @@
# Launcher Upgrade Spec
## Goal
Upgrade `LanMountainDesktop.Launcher` into the unified Launcher for:
- OOBE first-run entry
- startup splash window
- silent/incremental/rollback update
- plugin install/update
## Scope (Phase 1)
- Avalonia GUI launcher with two windows:
- `OOBEWindow` (first run only)
- `SplashWindow` (every launch)
- Default command `launch`
- CLI commands:
- `update check|download|apply|rollback`
- `plugin install|update`
- Legacy compatibility:
- `--source --plugins-dir --result` still works for plugin install
## Update Behavior
- ClassIsland-style deployment folders:
- `app-<version>-<number>/`
- marker files `.current`, `.partial`, `.destroy`
- Signed file map:
- `files.json`
- `files.json.sig`
- `public-key.pem`
- Incremental update:
- `replace` from archive
- `reuse` from current deployment
- `delete` skip file in target deployment
- Rollback:
- snapshot metadata is written before apply
- automatic rollback on apply failure
- manual rollback via command
## OOBE and Splash
- OOBE is independent from splash.
- OOBE shows only:
- welcome text: `欢迎使用阑山桌面`
- arrow button for continue
- Splash shows only:
- app name: `阑山桌面`
## Extensibility
- `IOobeStep` for future multi-step OOBE
- `ISplashStageReporter` for future startup progress visualization
## Compatibility Addendum
- The current production OOBE state format is a per-user JSON file at `%LOCALAPPDATA%\LanMountainDesktop\.launcher\state\oobe-state.json`.
- `first_run_completed` remains legacy compatibility data only.
- Same-user reinstall or upgrade should not re-enter OOBE.

View File

@@ -0,0 +1,12 @@
# Launcher Upgrade Tasks
- [x] Convert `LanMountainDesktop.Launcher` to Avalonia launcher entry.
- [x] Add OOBE window with first-run marker handling.
- [x] Add splash window for every startup.
- [x] Implement unified command parsing with default `launch`.
- [x] Keep legacy plugin install args compatibility.
- [x] Add plugin pending upgrade queue processing.
- [x] Implement incremental update apply with signed file map.
- [x] Implement snapshot-based rollback and manual rollback command.
- [x] Add update check/download/apply/rollback CLI commands.
- [x] Add launcher spec files under `.trae/specs/launcher-upgrade/`.

View File

@@ -0,0 +1,42 @@
# 本地化修复 Checklist
## MainWindow 修复
- [ ] `TaskbarProfileDisplayNameTextBlock.Text` 在中文下显示"用户"(或保持动态)
- [ ] `TaskbarProfileSettingsActionTextBlock.Text` 在中文下显示"设置"
- [ ] `TaskbarProfileDesktopEditActionTextBlock.Text` 在中文下显示"桌面编辑"
- [ ] `TaskbarProfilePowerActionTextBlock.Text` 在中文下显示"电源"
- [ ] `TaskbarPowerBackTextBlock.Text` 在中文下显示"返回"
- [ ] `TaskbarPowerTitleTextBlock.Text` 在中文下显示"电源"
- [ ] `PowerShutdownTextBlock.Text` 在中文下显示"关机"
- [ ] `PowerRestartTextBlock.Text` 在中文下显示"重启"
- [ ] `PowerLogoutTextBlock.Text` 在中文下显示"注销"
- [ ] `PowerSleepTextBlock.Text` 在中文下显示"睡眠"
- [ ] `PowerLockTextBlock.Text` 在中文下显示"锁定屏幕"
- [ ] `ComponentLibraryTitleTextBlock.Text` 在中文下显示"桌面编辑"
- [ ] `ComponentLibraryEmptyTextBlock.Text` 在中文下显示"左右滑动选择类别,点击进入,然后拖动组件到桌面放置。"
- [ ] `ComponentLibraryBackTextBlock.Text` 在中文下显示"返回"
- [ ] `ComponentLibraryCollapsedChipTextBlock.Text` 在中文下显示"桌面编辑"
## Launcher 修复
- [ ] `SplashWindow` 在中文下显示中文启动文本
- [ ] `DataLocationPromptWindow` 在中文下全部显示中文
- [ ] `ErrorWindow` 在中文下全部显示中文
- [ ] `LoadingDetailsWindow` 在中文下全部显示中文
- [ ] `UpdateWindow` 在中文下显示中文标题
## 组件修复
- [ ] `BrowserWidget` 在中文下显示"浏览器运行时不可用"
- [ ] `WhiteboardWidget` 工具提示在中文下显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
- [ ] `HolidayCalendarWidget` 在中文下显示"节假日倒计时"、"天"
- [ ] `BilibiliHotSearchWidget` 在中文下显示"热门话题"
- [ ] `WallpaperSettingsPage` 自定义颜色 Tooltip 在中文下显示"自定义颜色"
## 资源文件
- [ ] `zh-CN.json` 包含所有新增键值
- [ ] `en-US.json` 包含所有新增键值
- [ ] Launcher 本地化文件包含所有新增键值
## 构建与质量
- [ ] `dotnet build LanMountainDesktop.slnx -c Debug` 编译通过,无错误
- [ ] 无新增警告
- [ ] 无遗漏的硬编码英文(通过 `grep -r 'Text="[a-zA-Z]'` 等检查)

View File

@@ -0,0 +1,85 @@
# 本地化修复 Spec
## Why
- 项目在中文设置下,多处 UI 仍显示英文。
- 主要问题集中在:
1. `MainWindow.axaml` 中任务栏头像弹窗、电源菜单、组件库等文本硬编码为英文,且未被 `ApplyLocalization()` 覆盖。
2. `LanMountainDesktop.Launcher` 的所有视图完全没有接入本地化系统。
3. 部分组件BrowserWidget、WhiteboardWidget、HolidayCalendarWidget 等)存在未覆盖的硬编码英文。
4. 少量设置页面 Tooltip 硬编码英文。
## What Changes
### 1. MainWindow.axaml 硬编码修复
将以下硬编码文本改为由 `ApplyLocalization()` 通过 `L()` 动态设置:
- 任务栏头像弹窗:`User``power.user` / `Settings``settings.title` / `Edit Desktop``button.component_library` / `Power``power.title`
- 电源菜单:`Back``common.back` / `Power``power.title` / `Shutdown``power.shutdown` / `Restart``power.restart` / `Log Out``power.logout` / `Sleep``power.sleep` / `Lock Screen``power.lock_screen`
- 组件库:`Widgets``component_library.title` / `Back``common.back` / `No components.``component_library.empty`
- 悬浮芯片:`Widgets``component_library.title`
### 2. Launcher 视图本地化
`LanMountainDesktop.Launcher/Views/` 下的窗口引入独立本地化机制(复用 `LocalizationService` 或内嵌资源字典):
- `SplashWindow.axaml``LanMountain Desktop``Initializing...`
- `DataLocationPromptWindow.axaml`:全部文本
- `ErrorWindow.axaml`:全部文本
- `LoadingDetailsWindow.axaml`:全部文本
- `UpdateWindow.axaml``Update`
### 3. 组件硬编码修复
- `BrowserWidget.axaml``Browser runtime unavailable.` → 新增键 `browser.widget.unavailable`
- `WhiteboardWidget.axaml``Pen` / `Eraser` / `Clear` / `Export SVG` → 新增键 `whiteboard.tool.pen`
- `HolidayCalendarWidget.axaml``Holiday countdown` / `Days` → 新增键 `holiday.widget.title` / `holiday.widget.days`
- `BilibiliHotSearchWidget.axaml``Trending Topic` → 新增键 `bilihot.widget.trending_topic`
- `WallpaperSettingsPage.axaml``Custom color` Tooltip → 复用 `settings.wallpaper.custom_color_tooltip`
### 4. 本地化资源文件补充
`zh-CN.json``en-US.json` 中补充上述新增键值。
## Impact
- Affected code:
- `LanMountainDesktop/Views/MainWindow.axaml`
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
- `LanMountainDesktop.Launcher/Views/*.axaml`(多个文件)
- `LanMountainDesktop/Views/Components/BrowserWidget.axaml`
- `LanMountainDesktop/Views/Components/WhiteboardWidget.axaml`
- `LanMountainDesktop/Views/Components/HolidayCalendarWidget.axaml`
- `LanMountainDesktop/Views/Components/BilibiliHotSearchWidget.axaml`
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
- `LanMountainDesktop/Localization/zh-CN.json`
- `LanMountainDesktop/Localization/en-US.json`
- Affected behavior:
- 中文设置下上述位置将正确显示中文。
- Launcher 各窗口将支持中英文切换。
---
## Requirements
### Requirement: MainWindow 任务栏弹窗与电源菜单本地化
系统 SHALL 在 `ApplyLocalization()` 中覆盖任务栏头像弹窗和电源菜单的所有文本。
#### Scenario: 中文设置下打开任务栏弹窗
- **WHEN** 语言设置为中文
- **THEN** 弹窗中显示"设置"、"桌面编辑"、"电源"等中文文本
- **AND THEN** 电源菜单中显示"返回"、"关机"、"重启"、"注销"、"睡眠"、"锁定屏幕"等中文文本
### Requirement: Launcher 窗口本地化
系统 SHALL 让 Launcher 的所有窗口文本通过本地化服务获取。
#### Scenario: 中文设置下启动应用
- **WHEN** 语言设置为中文
- **THEN** SplashWindow 显示中文启动文本
- **AND THEN** 数据位置选择、错误页、加载详情页等显示中文
### Requirement: 组件与设置页硬编码修复
系统 SHALL 移除或覆盖所有组件和设置页中的英文硬编码文本。
#### Scenario: 中文设置下查看各组件
- **WHEN** 语言设置为中文
- **THEN** BrowserWidget 显示"浏览器运行时不可用"
- **AND THEN** WhiteboardWidget 工具提示显示"笔"、"橡皮擦"、"清空"、"导出 SVG"
- **AND THEN** HolidayCalendarWidget 显示"节假日倒计时"、"天"
- **AND THEN** BilibiliHotSearchWidget 显示"热门话题"
- **AND THEN** 壁纸设置页自定义颜色 Tooltip 显示"自定义颜色"

View File

@@ -0,0 +1,39 @@
# 本地化修复 Tasks
## Task 1: MainWindow.axaml 硬编码文本移除与代码覆盖
- [ ] 1.1 在 `MainWindow.axaml` 中,将任务栏头像弹窗的 `User``Settings``Edit Desktop``Power``Text` 属性改为空或绑定(保留 x:Name
- [ ] 1.2 在 `MainWindow.axaml` 中,将电源菜单的 `Back``Power``Shutdown``Restart``Log Out``Sleep``Lock Screen``Text` 属性改为空或绑定
- [ ] 1.3 在 `MainWindow.axaml` 中,将组件库的 `Widgets``Back``No components.``Text` 属性改为空或绑定
- [ ] 1.4 在 `MainWindow.axaml` 中,将悬浮芯片的 `Widgets``Text` 属性改为空或绑定
- [ ] 1.5 在 `MainWindow.SettingsHardCut.Stubs.cs``ApplyLocalization()` 中补充上述所有控件的 `L()` 赋值
## Task 2: Launcher 视图本地化
- [ ] 2.1 在 `LanMountainDesktop.Launcher` 中引入 `LocalizationService`(或共享主应用服务)
- [ ] 2.2 为 Launcher 创建独立的 `Localization/` 目录和 `zh-CN.json` / `en-US.json`
- [ ] 2.3 修改 `SplashWindow.axaml`:将 `LanMountain Desktop``Initializing...` 改为动态绑定
- [ ] 2.4 修改 `DataLocationPromptWindow.axaml`:将所有文本改为动态绑定
- [ ] 2.5 修改 `ErrorWindow.axaml`:将所有文本改为动态绑定
- [ ] 2.6 修改 `LoadingDetailsWindow.axaml`:将所有文本改为动态绑定
- [ ] 2.7 修改 `UpdateWindow.axaml`:将 `Update` 改为动态绑定
- [ ] 2.8 在 Launcher 启动流程中初始化语言设置
## Task 3: 组件硬编码修复
- [ ] 3.1 `BrowserWidget.axaml`:将 `Browser runtime unavailable.` 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.2 `WhiteboardWidget.axaml`:将 `Pen``Eraser``Clear``Export SVG` Tooltip 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.3 `HolidayCalendarWidget.axaml`:将 `Holiday countdown``Days` 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.4 `BilibiliHotSearchWidget.axaml`:将 `Trending Topic` 改为绑定,并在代码后置中通过 `L()` 设置
- [ ] 3.5 `WallpaperSettingsPage.axaml`:将 `Custom color` Tooltip 改为绑定到 `settings.wallpaper.custom_color_tooltip`
## Task 4: 本地化资源文件补充
- [ ] 4.1 在 `zh-CN.json` 中补充以下键值:
- `browser.widget.unavailable`
- `whiteboard.tool.pen``whiteboard.tool.eraser``whiteboard.tool.clear``whiteboard.tool.export_svg`
- `holiday.widget.title``holiday.widget.days`
- `bilihot.widget.trending_topic`
- `power.user`(或复用现有键)
- [ ] 4.2 在 `en-US.json` 中补充上述键值的英文版本
- [ ] 4.3 为 Launcher 创建独立的本地化 JSON 文件并填充中英文
## Task 5: 验证
- [ ] 5.1 执行 `dotnet build LanMountainDesktop.slnx -c Debug` 确保编译通过
- [ ] 5.2 检查是否有遗漏的硬编码英文(通过正则搜索)

View File

@@ -0,0 +1,13 @@
# Checklist
- [ ] `release.yml` includes PDCC publish flow and does not invoke Velopack.
- [ ] `release.yml` uploads app payload artifacts for PDCC.
- [ ] S3 output path is rooted at `lanmountain/update/` (no system version prefix).
- [ ] S3 has `repo/`, `meta/`, and `installers/` outputs after a release run.
- [ ] Host update source default is `stcn` and old `pdc` values are auto-normalized.
- [ ] Host can persist PDC payload into launcher incoming directory.
- [ ] Launcher can apply PDC FileMap payload with signature/hash verification.
- [ ] Legacy signed `files.json + update.zip` path still works as compatibility fallback.
- [ ] CI run attached proving all release matrix jobs pass.
- [ ] N-1 -> N incremental update verified on Windows x64/x86 and Linux x64.
- [ ] Rollback verification report attached.

View File

@@ -0,0 +1,44 @@
# PDC Incremental Update Migration
## Goal
Replace VeloPack-based incremental packaging with a unified PDC FileMap + object-repo pipeline, while keeping Launcher installation, rollback, and update orchestration ownership unchanged.
## Stage 1 (Completed)
- Release workflow removed VeloPack-based release packaging.
- Signed FileMap path was restored as an interim release mechanism.
- Host/Launcher fallback behavior stayed compatible with `files.json + files.json.sig + update.zip`.
## Stage 2 (Current Implementation Target)
- Move release publishing to PDCC + `phainon.yml` (ClassIsland-style).
- Promote PDC-distributed FileMap/object-repo as the primary incremental path.
- Keep GitHub Release installers and metadata as parallel distribution.
- Keep Launcher state machine ownership (`.current/.partial/.destroy` + snapshots).
- Update source defaults to `stcn` (S3/PDC), with GitHub fallback.
- S3 object root is fixed to `lanmountain/update/` with no update-system version prefix.
Expected S3 layout:
- `lanmountain/update/repo/<hash-prefix>/<hash-object>`
- `lanmountain/update/meta/channels/<channel>/<subchannel>/latest.json`
- `lanmountain/update/meta/distributions/<distributionId>/*.json`
- `lanmountain/update/installers/<platform>/<arch>/*`
## Acceptance
- `release.yml` includes PDCC publish steps and no Velopack steps.
- Release jobs keep building installers for Windows x64/x86, Linux x64, and macOS.
- PDC metadata + FileMap + object repo are published under `lanmountain/update/`.
- Host can consume PDC payload (`stcn` source) and fallback to GitHub when unavailable.
- Launcher can apply both:
- legacy signed `files.json + update.zip`
- PDC FileMap object-repo payload.
- Rollback semantics remain unchanged.
## Deprecated Notes
- The following interim outputs are compatibility-only (not the long-term primary path):
- `files-windows-x64.json` / `.sig` / `update-windows-x64.zip`
- `files-windows-x86.json` / `.sig` / `update-windows-x86.zip`
- `files-linux-x64.json` / `.sig` / `update-linux-x64.zip`

View File

@@ -0,0 +1,15 @@
# Tasks
- [x] Remove VeloPack packaging from release workflow.
- [x] Keep signed FileMap path as interim compatibility fallback.
- [x] Remove launcher/runtime Velopack branching.
- [ ] Add `phainon.yml` for PDCC publish configuration.
- [ ] Add PDCC installation + publish steps in `release.yml`.
- [ ] Upload app payload artifacts for PDCC consumption in release build jobs.
- [ ] Publish PDC metadata + object repo to S3 path root `lanmountain/update/`.
- [ ] Mirror installers to `lanmountain/update/installers/<platform>/<arch>/`.
- [ ] Replace update source canonical value with `stcn` (keep legacy `pdc` compatibility).
- [ ] Add PDC payload model into host update check result.
- [ ] Add host download path for PDC payload (`pdc-filemap.json` + signature + metadata).
- [ ] Add launcher PDC FileMap apply path with rollback-compatible semantics.
- [ ] Keep old `files.json + update.zip` path behind compatibility fallback.

View File

@@ -0,0 +1,12 @@
# Checklist
- [x] `plugin.json` 缺省时仍默认为 `in-proc`
- [x] 非法 `runtime.mode` 会给出清晰错误
- [x] SDK 中已有 Worker 入口和隔离运行模式的公共接口
- [x] IPC 契约已拆到独立工程,且不引用 Avalonia
- [x] IPC 封装层已集中环境变量、启动参数和通知路由常量
- [x] 架构文档已写明一期 `isolated-background`、二期 `isolated-window`
- [x] 架构文档已写明 `IPluginExportRegistry` / `IPluginMessageBus` 不再作为隔离插件主边界
- [x] 文档已写明 ClassIsland 的借鉴点与取舍
- [ ] Host 在 Worker 崩溃时仅降级插件且不中断主程序
- [ ] `isolated-background` 的组件、编辑器、设置页完成真实 IPC 回路

View File

@@ -0,0 +1,41 @@
# Plugin Process Isolation
## Why
现有插件体系仍是“同进程 + AssemblyLoadContext 隔离”,无法阻止插件 fatal crash 拖垮 Host也无法阻止插件直接访问 Host 进程内对象和内存。
## What Changes
- 增加插件运行模式概念:`in-proc``isolated-background``isolated-window`
- 一期落地 `isolated-background`
- 新建独立 IPC 契约包和 IPC 封装包
-`PluginSdk` 中新增 Worker 入口与 `runtime.mode`
- 明确隔离模式下不再兼容对象实例共享型 API
- 新增正式架构文档说明 UI 方案、迁移策略、残余风险和 ClassIsland 借鉴
## Impact
- `LanMountainDesktop.PluginSdk/`
- `LanMountainDesktop.PluginTemplate/`
- 新增 `LanMountainDesktop.PluginIsolation.Contracts/`
- 新增 `LanMountainDesktop.PluginIsolation.Ipc/`
- `docs/ARCHITECTURE.md`
- `docs/PLUGIN_PROCESS_ISOLATION_ARCHITECTURE.md`
## Requirements
### Requirement 1
宿主必须同时支持存量 `in-proc` 插件与未来的隔离插件,不得以本次改造打断旧插件加载。
### Requirement 2
隔离插件的 Host/Worker 通信必须基于显式 IPC 路由和 DTO而不是 Host 服务对象实例共享。
### Requirement 3
一期必须把后台逻辑隔离为独立 Worker 进程,并显式记录 Host UI 壳层的残余风险。
### Requirement 4
仓库文档必须把 ClassIsland IPC 的借鉴点和不照搬的部分写清楚,避免后续实现阶段误把插件协议做成远程对象模型。

View File

@@ -0,0 +1,12 @@
# Tasks
- [x] 梳理现有插件运行时、组件注册、设置页和共享对象边界
- [x] 形成插件进程隔离架构文档
- [x]`.trae/specs/plugin-process-isolation/` 下补齐 spec、tasks、checklist
- [x]`PluginSdk` 中增加 `runtime.mode`、Worker 入口接口和运行模式枚举
- [x] 新建 `LanMountainDesktop.PluginIsolation.Contracts`,沉淀纯 DTO、路由常量、错误码与 JSON context
- [x] 新建 `LanMountainDesktop.PluginIsolation.Ipc`,沉淀 ClassIsland 风格的 IPC 包装外壳
- [x] 更新插件模板 `plugin.json`,让新插件默认显式声明 `in-proc`
- [ ] 在 Host 侧接入真实 Worker 进程拉起与 dotnetCampus.Ipc 传输绑定
- [ ]`isolated-background` 构建 Host UI 壳层适配器
- [ ] 为故障、心跳、降级与恢复补齐端到端测试

View File

@@ -0,0 +1,5 @@
# Checklist (Deprecated)
- [x] Spec marked as deprecated.
- [x] Active implementation ownership moved to `pdc-incremental-migration`.
- [x] No release workflow dependency remains on VeloPack.

View File

@@ -0,0 +1,15 @@
# VeloPack Update Integration (Deprecated)
## Status
This spec is deprecated and superseded by `.trae/specs/pdc-incremental-migration/`.
## Deprecation Reason
- VeloPack native package generation introduced unstable release blocking (version format coupling and platform divergence).
- The project has switched back to signed FileMap incremental assets as the primary update path.
- Launcher remains the update installer/rollback authority; packaging and distribution are being migrated to PDC/S3-compatible flows.
## Migration Note
Use `.trae/specs/pdc-incremental-migration/spec.md` as the active authority for incremental update implementation and acceptance.

View File

@@ -0,0 +1,6 @@
# Tasks (Deprecated)
- [x] Mark VeloPack integration spec as deprecated.
- [x] Remove VeloPack runtime branches from launcher/host update path.
- [x] Remove VeloPack release workflow packaging steps.
- [ ] Keep archive for historical context only (no new implementation tasks here).

View File

@@ -0,0 +1,24 @@
* [x] AppSettingsSnapshot 包含 EnableSlideTransition 字段且默认为 false
* [x] DesktopPage 拥有名为 DesktopPageSlideTransform 的 TranslateTransform
* [x] DesktopPage.Transitions 包含 Opacity 和 TranslateTransform.X 两个 DoubleTransition
* [x] 点击"回到 Windows"时播放退场动画Opacity 淡出 或 Opacity+滑动),动画完成后再最小化
* [x] 从最小化恢复时 DesktopPage 先以 Opacity=0 遮住 Normal 中间态FullScreen 生效后播放入场动画
* [x] 动画期间 DesktopPage.IsHitTestVisible 为 false动画完成后恢复
* [x] 动画期间 OnWindowPropertyChanged 不执行强制全屏纠正
* [x] 快速连续操作不会导致动画冲突
* [x] GeneralSettingsPage 在 Windows 平台显示"滑入滑出过渡效果"开关
* [x] GeneralSettingsPage 在非 Windows 平台不显示该开关
* [x] EnableSlideTransition 设置持久化到 AppSettingsSnapshot 且立即生效
* [x] dotnet build 无编译错误

View File

@@ -0,0 +1,147 @@
# 窗口过渡动画 Spec
## Why
当前全屏窗口在"回到 Windows"(最小化)和"恢复应用"时存在严重的视觉问题:
1. 恢复时经历 `Minimized → Normal → FullScreen` 两步跳变,用户会短暂看到无框小窗口
2. 状态切换无任何过渡动画,体验生硬
3. `OnWindowPropertyChanged` 使用 `Dispatcher.UIThread.Post` 延迟纠正,进一步延长了 Normal 中间态的可见时间
## What Changes
-`MainWindow.axaml``DesktopPage` 上添加 `TranslateTransform``TranslateTransform.X` 过渡动画
- 修改 `MainWindow.axaml.cs``OnMinimizeClick`,实现退场动画(滑出/淡出 → 最小化)
- 修改 `App.axaml.cs``RestoreOrCreateMainWindow`,实现入场动画(全屏 → 滑入/淡入)
- 修改 `MainWindow.axaml.cs``OnWindowPropertyChanged`,在动画期间暂停强制全屏逻辑
-`AppSettingsSnapshot` 中添加 `EnableSlideTransition` 设置项(默认关闭)
-`GeneralSettingsPageViewModel` 中添加对应 ViewModel 属性
-`GeneralSettingsPage.axaml` 中添加开关 UI仅 Windows 平台显示)
- 添加平台检测逻辑Windows 且开启设置时使用滑入滑出,其他情况使用 Opacity 淡入淡出
## Impact
- Affected specs: 窗口生命周期过渡动画
- Affected code:
- `LanMountainDesktop/Views/MainWindow.axaml` - DesktopPage 添加 TranslateTransform
- `LanMountainDesktop/Views/MainWindow.axaml.cs` - OnMinimizeClick、OnWindowPropertyChanged、新增动画方法
- `LanMountainDesktop/App.axaml.cs` - RestoreOrCreateMainWindow、OnMainWindowPropertyChanged
- `LanMountainDesktop/Models/AppSettingsSnapshot.cs` - 新增 EnableSlideTransition 字段
- `LanMountainDesktop/ViewModels/SettingsViewModels.cs` - GeneralSettingsPageViewModel 新增属性
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml` - 新增开关 UI
---
## ADDED Requirements
### Requirement: 窗口退场过渡动画
系统 SHALL 在主窗口最小化/隐藏时播放退场过渡动画,消除窗口状态跳变的视觉闪烁。
#### Scenario: Opacity 淡出退场(所有平台默认)
- **WHEN** 用户点击"回到 Windows"或触发最小化
- **THEN** 系统将 `DesktopPage.Opacity` 设为 0触发淡出动画
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
- **AND THEN** 最小化完成后重置 `DesktopPage.Opacity = 1`(窗口已不可见)
#### Scenario: 滑出退场Windows + 开启设置)
- **WHEN** 用户点击"回到 Windows"且运行在 Windows 平台且已开启滑入滑出设置
- **THEN** 系统同时将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
- **AND THEN** 最小化完成后重置 `DesktopPageSlideTransform.X = 0``DesktopPage.Opacity = 1`
### Requirement: 窗口入场过渡动画
系统 SHALL 在主窗口恢复时播放入场过渡动画,消除 Normal 中间态的视觉闪烁。
#### Scenario: Opacity 淡入入场(所有平台默认)
- **WHEN** 主窗口从最小化/隐藏状态恢复
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0遮住 Normal 中间态)
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
- **AND THEN** 等 FullScreen 状态生效后将 `DesktopPage.Opacity` 设为 1触发淡入动画
#### Scenario: 滑入入场Windows + 开启设置)
- **WHEN** 主窗口从最小化/隐藏状态恢复且运行在 Windows 平台且已开启滑入滑出设置
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
- **AND THEN** 等 FullScreen 状态生效后同时将 `DesktopPage.Opacity` 设为 1 且 `DesktopPageSlideTransform.X` 设为 0触发滑入+淡入组合动画
### Requirement: 动画期间交互保护
系统 SHALL 在过渡动画播放期间防止用户交互和状态冲突。
#### Scenario: 动画期间禁止交互
- **WHEN** 退场或入场动画正在播放
- **THEN** `DesktopPage.IsHitTestVisible` 设为 `false`
- **AND THEN** 动画完成后恢复为 `true`
#### Scenario: 动画期间暂停强制全屏
- **WHEN** 入场动画正在播放且窗口临时处于 Normal 状态
- **THEN** `OnWindowPropertyChanged` 不执行强制全屏纠正
- **AND THEN** 入场动画完成后恢复正常强制全屏逻辑
#### Scenario: 防止快速连续操作
- **WHEN** 用户在动画播放期间再次触发最小化或恢复
- **THEN** 系统忽略重复操作,避免动画冲突
### Requirement: 滑入滑出设置项
系统 SHALL 在基本设置页面提供"滑入滑出过渡效果"开关,仅 Windows 平台可见。
#### Scenario: 设置项可见性
- **WHEN** 用户在 Windows 平台打开基本设置页面
- **THEN** 显示"滑入滑出过渡效果"开关
- **WHEN** 用户在非 Windows 平台打开基本设置页面
- **THEN** 不显示该开关
#### Scenario: 设置项默认值
- **WHEN** 用户首次安装应用
- **THEN** `EnableSlideTransition` 默认为 `false`
#### Scenario: 设置持久化
- **WHEN** 用户切换"滑入滑出过渡效果"开关
- **THEN** 设置值立即持久化到 `AppSettingsSnapshot.EnableSlideTransition`
- **AND THEN** 下次窗口过渡时立即生效,无需重启
### Requirement: DesktopPage TranslateTransform 声明
系统 SHALL 在 `DesktopPage` 上声明 `TranslateTransform` 和对应的过渡动画。
#### Scenario: XAML 声明
- **WHEN** MainWindow 初始化
- **THEN** `DesktopPage` 拥有名为 `DesktopPageSlideTransform``TranslateTransform`
- **AND THEN** `DesktopPage.Transitions` 包含 `Opacity``TranslateTransform.X` 两个过渡
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`320ms`FluttermotionToken.Duration.Intro`400ms
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`DecelerateBezier
### Requirement: 设置窗口不参与桌面壳过渡动画
系统 SHALL 将桌面壳进出场动画限制在主窗口范围内,不影响独立设置窗口。
#### Scenario: 设置窗口在桌面动画期间保持独立
- **WHEN** 主窗口执行滑入、滑出、最小化或恢复动画
- **THEN** 设置窗口不参与该动画
- **AND THEN** 设置窗口不会跟随主窗口一起隐藏、最小化或重定位
## MODIFIED Requirements
### Requirement: OnMinimizeClick 行为
**当前**: 直接设置 `WindowState = WindowState.Minimized`,无动画
**修改后**: 先播放退场动画,动画完成后再设置 `WindowState = WindowState.Minimized`
### Requirement: RestoreOrCreateMainWindow 行为
**当前**: `Show() → Normal → FullScreen`,无过渡动画,用户可见 Normal 中间态
**修改后**: 先将 `DesktopPage` 设为不可见Opacity=0 + 可选滑出位),再执行状态切换,最后播放入场动画
### Requirement: OnWindowPropertyChanged 强制全屏逻辑
**当前**: 任何非 Minimized/FullScreen 状态立即纠正为 FullScreen
**修改后**: 动画期间允许临时 Normal 状态存在,动画完成后恢复强制全屏逻辑
## REMOVED Requirements
无移除的需求。

View File

@@ -0,0 +1,52 @@
# Tasks
- [x] Task 1: 在 `AppSettingsSnapshot` 中添加 `EnableSlideTransition` 字段
- [x] 添加 `public bool EnableSlideTransition { get; set; } = false;`
- [x]`Clone()` 方法中无需特殊处理bool 是值类型)
- [x] Task 2: 在 `MainWindow.axaml``DesktopPage` 上添加 `TranslateTransform` 和过渡动画
- [x] 添加 `<TranslateTransform />`
- [x]`Grid.Transitions` 中添加 `TranslateTransform.X``DoubleTransition`,使用 `FluttermotionToken.Duration.Intro` 和 DecelerateBezier 缓动
- [x] Task 3: 在 `MainWindow.axaml.cs` 中实现退场动画逻辑
- [x] 添加 `_isSlideAnimationActive` 标志位
- [x] 修改 `OnMinimizeClick`,调用新的 `SlideOutAndMinimizeAsync` 方法
- [x] 实现 `SlideOutAndMinimizeAsync`:读取设置 → 播放退场动画Opacity + 可选滑动)→ 等动画完成 → 最小化 → 重置位置
- [x] 动画期间设置 `DesktopPage.IsHitTestVisible = false`
- [x] Task 4: 在 `MainWindow.axaml.cs` 中实现入场动画逻辑
- [x] 添加 `public void PrepareEnterAnimation()` 方法:禁用过渡 → 设置初始位置Opacity=0, X=屏幕宽度或0→ 重新启用过渡
- [x] 添加 `public void PlayEnterAnimation()` 方法触发入场动画Opacity=1, X=0
- [x] 添加 `private bool IsSlideTransitionEnabled()` 方法,从设置中读取
- [x] Task 5: 修改 `App.axaml.cs``RestoreOrCreateMainWindow`
- [x] 在窗口状态切换前调用 `mainWindow.PrepareEnterAnimation()`
- [x] 在 FullScreen 状态生效后调用 `mainWindow.PlayEnterAnimation()`
- [x] Task 6: 修改 `MainWindow.axaml.cs``OnWindowPropertyChanged`
- [x]`_isSlideAnimationActive` 为 true 时跳过强制全屏逻辑
- [x] Task 7: 在 `GeneralSettingsPageViewModel` 中添加 `EnableSlideTransition` 属性
- [x] 添加 `[ObservableProperty] private bool _enableSlideTransition;`
- [x] 添加 `OnEnableSlideTransitionChanged` 持久化方法
- [x] 在构造函数和 `OnSettingsChanged` 中加载/同步该设置
- [x] 添加 `IsSlideTransitionAvailable` 平台检测属性
- [x] Task 8: 在 `GeneralSettingsPage.axaml` 中添加"滑入滑出过渡效果"开关
- [x] 在"运行时设置"分组中添加 `SettingsExpander`
- [x] 仅 Windows 平台显示(使用 `IsVisible` 绑定到 `IsSlideTransitionAvailable`
- [x] 图标使用 `ArrowRight`
- [x] Task 9: 构建验证
- [x] 执行 `dotnet build` 确保无编译错误
# Task Dependencies
- [Task 2] depends on [Task 1]
- [Task 3] depends on [Task 1, Task 2]
- [Task 4] depends on [Task 1, Task 2]
- [Task 5] depends on [Task 4]
- [Task 6] depends on [Task 3]
- [Task 7] depends on [Task 1]
- [Task 8] depends on [Task 7]
- [Task 9] depends on [Task 3, Task 4, Task 5, Task 6, Task 7, Task 8]

View File

@@ -62,7 +62,10 @@ dotnet test LanMountainDesktop.slnx -c Debug
### UI
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
- **圆角规范 (AI 强制建议)**
- **桌面组件根容器**:必须且仅能使用 `{DynamicResource DesignCornerRadiusComponent}`
- **内部元素**:必须根据嵌套层级使用 `DesignCornerRadiusSm/Md/Lg` 等 Token严禁硬编码像素值。
- **禁止修改系数**:严禁在圆角资源上乘以任何 `scale` 变量,圆角现在由全局样式固定控制。
- 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/`
- UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs`
@@ -71,7 +74,7 @@ dotnet test LanMountainDesktop.slnx -c Debug
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V5_MIGRATION.md`
### 设置与主题
@@ -88,6 +91,6 @@ dotnet test LanMountainDesktop.slnx -c Debug
- 视觉规范:`docs/VISUAL_SPEC.md`
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
- SDK v5 迁移:`docs/PLUGIN_SDK_V5_MIGRATION.md`
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。

237
CHANGELOG.md Normal file
View File

@@ -0,0 +1,237 @@
# 更新日志 / Changelog
## [0.8.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.4) - 2026-04-12
### 新增 (Added)
-**全新淡入淡出动画系统**: 引入了一套全新的淡入淡出动画效果
- 提升界面切换和元素显示的视觉流畅度
- 为用户带来更加自然优雅的交互体验
### 变更 (Changed)
- ♻️ **SDK 更新**: 更新插件 SDK优化插件开发接口和兼容性
- 🎨 **网速显示组件优化**: 优化了网速显示组件的显示效果
- 改进数据展示方式,提升可读性
- 优化视觉样式,与整体设计语言更加协调
### 修复 (Fixed)
-
### 移除 (Removed)
-
***
## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12
### 新增 (Added)
-
### 变更 (Changed)
-**插件设置页面支持 View 展示**: 插件设置页面现在支持使用 View 进行展示
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
- 提供更灵活的设置页面展示方式,提升插件用户体验
- 兼容原有的设置方式,平滑过渡
- 🔧 **三指滑动与融合桌面功能开关位置调整**: 将三指滑动与融合桌面功能开关移动到了开发者设置界面
- 优化设置页面结构,将高级功能集中管理
- 普通用户界面更加简洁,开发者可在已有的开发者设置界面中访问相关设置
### 修复 (Fixed)
- 🐛 **快捷方式组件透明问题**: 修复了快捷方式组件无法正常透明的问题
- 问题原因: 组件背景透明属性设置异常或渲染层级问题
- 修复方案: 修正透明属性配置,确保快捷方式组件背景透明效果正常显示
- 🐛 **插件无法正常升级问题**: 修复了插件无法正常升级的问题
- 问题原因: 插件升级流程中存在异常,导致升级操作失败或中断
- 修复方案: 修复插件升级逻辑,确保插件可以正常检测、下载和安装更新
- 🐛 **开发者设置项持久化问题**: 修复了开发者设置项不能正确持久化的问题
- 问题原因: 开发者设置项的保存或读取逻辑存在缺陷,导致设置无法正确保存或恢复
- 修复方案: 修复设置持久化逻辑,确保开发者设置项能够正确保存并在重启后恢复
### 移除 (Removed)
- 🗑️ **不附带 .NET 10 依赖的轻量版安装包**: 移除了不附带 .NET 10 依赖的轻量版安装包
- 简化版本发布和维护流程,统一提供完整依赖的安装包
- 用户无需担心 .NET 运行时环境,安装后即可直接使用
***
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
### 新增 (Added)
-
### 变更 (Changed)
- ♻️ **插件 SDK 更新**: 更新插件 SDK优化插件开发接口和兼容性
### 修复 (Fixed)
- 🐛 **轻量版 .NET 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 环境下的依赖问题
- 问题原因: 轻量版与 .NET 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-
***
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
### 新增 (Added)
-**便签组件**: 全新便签组件上线,支持 Markdown 语法
- 支持丰富的 Markdown 格式:标题、列表、加粗、斜体、代码块等
- 便签内容自动保存,方便记录和管理日常备忘。丰富信息展示途径,让作业布置也可在阑山桌面完成。
-**白板主题自适应笔色**: 白板功能新增主题自适应笔色支持
- 根据当前主题自动调整画笔颜色,确保在不同主题下都有良好的书写体验
- 深色主题下自动切换为浅色笔迹,浅色主题下使用深色笔迹
### 变更 (Changed)
- 🎨 **融合桌面设置组件库样式更新**: 优化融合桌面设置页面的组件库样式
- 提升视觉一致性和用户体验
- 统一组件风格,与整体设计语言保持协调
### 修复 (Fixed)
- 🐛 **白板无法使用问题**: 修复了白板功能无法正常使用的问题
- 问题原因: 相关依赖或初始化逻辑异常导致白板功能失效
- 修复方案: 修复了白板的依赖加载和初始化流程,恢复正常使用
- 🐛 **央官网新闻组件显示问题**: 修复了央官网新闻组件的显示异常
- 优化组件渲染逻辑,确保新闻内容正确展示
- 🐛 **课程表组件显示问题**: 修复了课程表组件的显示异常
- 优化组件布局和渲染,确保课程信息正确显示
- 🐛 **轻量版 .NET 10 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 10 环境下的依赖问题
- 问题原因: 轻量版与 .NET 10 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升与 .NET 10 的兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-
***
## [0.8.3.2](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.2) - 2026-04-09
### 新增 (Added)
-**应用启动台图标卡片显示选项**: 新增应用启动台图标卡片显示设置
- 用户可在设置中选择是否显示应用图标的专属卡片背景
- 关闭后仅显示应用图标本身,更加简洁
- 支持动态切换,实时预览效果
### 变更 (Changed)
-
### 修复 (Fixed)
- 🐛 **应用启动台文件夹应用数量限制**: 修复了应用启动台文件夹无法查看超过12个应用的问题
- 问题原因: 文件夹弹窗未实现滚动功能,应用列表超出显示区域后被截断
- 修复方案: 为文件夹内容区域添加滚动支持,允许用户滚动查看所有应用
- 🐛 **电源菜单重启导致关机问题**: 修复了点击电源菜单"重启"选项却触发关机的问题
- 问题原因: `SlideToShutDown.exe` 仅支持关机操作,不支持重启,错误地将其用于重启功能
- 修复方案: 重启操作改为使用标准的二次确认对话框(所有平台统一),仅关机操作使用 SlideToShutDown 滑动界面
- 🐛 **课表组件字体显示问题**: 修复了日间模式下课表组件字体颜色与背景色相近导致看不清的问题
- 问题原因: 主题切换时增量更新逻辑未同步更新文字颜色
- 修复方案: 在 `IncrementalUpdateItems()` 方法中同步更新课程项的文字颜色
### 移除 (Removed)
- 🗑️ **更新页面重复标题**: 移除了更新页面中重复的更新标题,优化页面布局
***
## [0.8.3.1](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.1) - 2026-04-08
### 新增 (Added)
-**快捷方式组件**: 新增快捷方式组件,可在阑山桌面内便捷打开系统应用与文件
- 支持创建快捷方式,统一管理应用和文件
- 提供单击打开和双击打开两种交互模式
- 支持配置是否显示背景
- 📝 初始化更新日志文档,为后续版本发布建立基础
### 变更 (Changed)
-
### 修复 (Fixed)
-
### 移除 (Removed)
-
***
所有重要的更改都将记录在此文件中。
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
并且本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
***
## \[格式示例]
### 新增 (Added)
- 待发布的新功能
### 变更 (Changed)
- 待发布的变更
### 修复 (Fixed)
- 待发布的修复
### 移除 (Removed)
- 待发布的移除项
***
## 版本说明
### 版本号规则
本项目采用语义化版本号 `MAJOR.MINOR.PATCH.BUILD`:
- **MAJOR (主版本号)**: 不兼容的 API 修改
- **MINOR (次版本号)**: 向下兼容的功能性新增
- **PATCH (修订号)**: 向下兼容的问题修正
- **BUILD (构建号)**: 内部构建版本,用于区分同一 PATCH 版本的不同构建
### 分类说明
- **新增 (Added)**: 新功能、新特性
- **变更 (Changed)**: 对现有功能的变更
- **修复 (Fixed)**: Bug 修复
- **移除 (Removed)**: 移除的功能或特性
### 图例
- 🎉 **重大更新**: 重要功能或里程碑
-**新功能**: 新增功能特性
- 🐛 **Bug修复**: 问题修复
- 🔧 **配置**: 配置相关变更
- 📝 **文档**: 文档更新
- 🎨 **样式**: UI/UX 改进
- ♻️ **重构**: 代码重构
-**性能**: 性能优化
- 🔒 **安全**: 安全相关
- 🌐 **国际化**: 国际化/本地化
***
## 链接

View File

@@ -1,8 +1,9 @@
<Project>
<PropertyGroup>
<Version>1.0.0</Version>
<Version>0.0.0-dev</Version>
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
</Project>

42
Directory.Packages.props Normal file
View File

@@ -0,0 +1,42 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="12.0.2" />
<PackageVersion Include="Avalonia.Controls.WebView" Version="12.0.0" />
<PackageVersion Include="Avalonia.Desktop" Version="12.0.2" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.0.2" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.2" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.1" />
<PackageVersion Include="ClassIsland.Markdown.Avalonia" Version="12.0.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="dotnetCampus.Ipc" Version="2.0.0-alpha436" />
<PackageVersion Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageVersion Include="Downloader" Version="5.4.0" />
<PackageVersion Include="FluentAvaloniaUI" Version="3.0.0-preview2" />
<PackageVersion Include="FluentIcons.Avalonia" Version="2.1.325" />
<PackageVersion Include="Material.Avalonia" Version="3.16.1" />
<PackageVersion Include="MaterialColorUtilities" Version="0.3.0" />
<PackageVersion Include="Material.Icons.Avalonia" Version="3.0.3-nightly.0.2" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="MudTools.OfficeInterop" Version="2.0.9" />
<PackageVersion Include="MudTools.OfficeInterop.Excel" Version="2.0.9" />
<PackageVersion Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.9" />
<PackageVersion Include="MudTools.OfficeInterop.Word" Version="2.0.9" />
<PackageVersion Include="PortAudioSharp2" Version="1.0.6" />
<PackageVersion Include="PostHog" Version="2.5.0" />
<PackageVersion Include="Sentry" Version="6.4.1" />
<PackageVersion Include="System.Drawing.Common" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="System.Runtime.WindowsRuntime" Version="5.0.0-preview.5.20278.1" />
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.92.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0-pre.4" />
<PackageVersion Include="YamlDotNet" Version="17.1.0" />
<PackageVersion Include="log4net" Version="3.3.1" />
</ItemGroup>
</Project>

View File

@@ -6,23 +6,48 @@ namespace LanMountainDesktop.Appearance;
public static class AppearanceCornerRadiusTokenFactory
{
public static AppearanceCornerRadiusTokens Create(double scale)
public static AppearanceCornerRadiusTokens Create(string style)
{
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(12, normalizedScale),
Radius(14, normalizedScale),
Radius(20, normalizedScale),
Radius(28, normalizedScale),
Radius(32, normalizedScale),
Radius(36, normalizedScale),
Radius(18, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)
{
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
return new CornerRadius(scaled);
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusStyle(style);
return normalized switch
{
GlobalAppearanceSettings.CornerRadiusStyleSharp => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(4),
Xs: new CornerRadius(8),
Sm: new CornerRadius(10),
Md: new CornerRadius(14),
Lg: new CornerRadius(20),
Xl: new CornerRadius(24),
Island: new CornerRadius(28),
Component: new CornerRadius(20)),
GlobalAppearanceSettings.CornerRadiusStyleRounded => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(8),
Xs: new CornerRadius(14),
Sm: new CornerRadius(16),
Md: new CornerRadius(24),
Lg: new CornerRadius(32),
Xl: new CornerRadius(36),
Island: new CornerRadius(40),
Component: new CornerRadius(28)),
GlobalAppearanceSettings.CornerRadiusStyleOpen => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(10),
Xs: new CornerRadius(16),
Sm: new CornerRadius(20),
Md: new CornerRadius(28),
Lg: new CornerRadius(36),
Xl: new CornerRadius(40),
Island: new CornerRadius(44),
Component: new CornerRadius(32)),
// Balanced (default)
_ => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(6),
Xs: new CornerRadius(12),
Sm: new CornerRadius(14),
Md: new CornerRadius(20),
Lg: new CornerRadius(28),
Xl: new CornerRadius(32),
Island: new CornerRadius(36),
Component: new CornerRadius(24))
};
}
}

View File

@@ -46,8 +46,8 @@ public sealed class DesktopShellHost : IDesktopShellHost
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Exit += (_, _) => _performExitCleanup();
_createAndAssignMainWindow(desktop);
_startActivationListener();
_createAndAssignMainWindow(desktop);
}
_startWeatherRefresh();

View File

@@ -5,7 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />

View File

@@ -7,6 +7,5 @@ public sealed record ComponentChromeContext(
string ComponentId,
string? PlacementId,
double CellSize,
double GlobalCornerRadiusScale,
AppearanceCornerRadiusTokens CornerRadiusTokens,
SettingsScope Scope = SettingsScope.App);

View File

@@ -0,0 +1,9 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
x:Class="LanMountainDesktop.Launcher.App"
RequestedThemeVariant="Default">
<Application.Styles>
<sty:FluentAvaloniaTheme />
</Application.Styles>
</Application>

View File

@@ -0,0 +1,745 @@
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Services.Ipc;
using LanMountainDesktop.Launcher.Views;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher;
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();
Logger.Info(
$"Launcher App initialize. Command='{context.Command}'; IsGuiMode={context.IsGuiCommand}; " +
$"IsPreview={context.IsPreviewCommand}; IsDebugMode={context.IsDebugMode}; " +
$"LaunchSource='{context.LaunchSource}'; IsElevated={execution.IsElevated}; " +
$"UserSid='{execution.UserSid ?? string.Empty}'; ExplicitAppRoot='{context.ExplicitAppRoot ?? "<none>"}'.");
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode)
{
base.OnFrameworkInitializationCompleted();
return;
}
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var context = LauncherRuntimeContext.Current;
var execution = LauncherExecutionContext.Capture();
Logger.Info(
$"Framework initialization completed. Command='{context.Command}'; IsPreview={context.IsPreviewCommand}; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"IsElevated={execution.IsElevated}; UserSid='{execution.UserSid ?? string.Empty}'.");
if (HandlePreviewCommand(context, desktop))
{
base.OnFrameworkInitializationCompleted();
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();
updateWindow.Show();
_ = RunApplyUpdateWithWindowAsync(desktop, context, updateWindow);
}
else
{
var splashWindow = CreateSplashWindow();
splashWindow.Show();
_ = RunCoordinatorWithSplashAsync(desktop, context, splashWindow);
}
}
base.OnFrameworkInitializationCompleted();
}
private bool HandlePreviewCommand(CommandContext context, IClassicDesktopStyleApplicationLifetime desktop)
{
switch (context.Command.ToLowerInvariant())
{
case "preview-splash":
{
Logger.Info("Preview command: splash.");
var splashWindow = CreateSplashWindow();
splashWindow.SetDebugMode(true);
splashWindow.Show();
_ = SimulateSplashPreviewAsync(desktop, splashWindow);
return true;
}
case "preview-error":
{
Logger.Info("Preview command: error.");
var errorWindow = new ErrorWindow();
errorWindow.SetErrorMessage("[Preview] This is the launcher error window preview.");
errorWindow.Show();
_ = WaitForWindowCloseAsync(desktop, errorWindow);
return true;
}
case "preview-update":
{
Logger.Info("Preview command: update.");
var updateWindow = new UpdateWindow();
updateWindow.SetDebugMode(true);
updateWindow.Show();
_ = SimulateUpdatePreviewAsync(desktop, updateWindow);
return true;
}
case "preview-oobe":
{
Logger.Info("Preview command: oobe.");
var oobeWindow = new OobeWindow();
oobeWindow.Show();
_ = SimulateOobePreviewAsync(desktop, oobeWindow);
return true;
}
case "preview-debug":
{
Logger.Info("Preview command: debug window.");
var devDebugWindow = new DevDebugWindow();
devDebugWindow.Show();
return true;
}
default:
return false;
}
}
private static SplashWindow CreateSplashWindow()
{
var window = new SplashWindow();
TrySetSplashVersionInfo(window, LauncherRuntimeContext.Current);
return window;
}
private static void TrySetSplashVersionInfo(SplashWindow window, CommandContext context)
{
try
{
var appRoot = Commands.ResolveAppRoot(context);
var versionInfo = new DeploymentLocator(appRoot).GetVersionInfo();
window.SetVersionInfo(versionInfo.Version, versionInfo.Codename);
}
catch (Exception ex)
{
Logger.Warn($"Failed to set splash version info before coordinator start: {ex.Message}");
}
}
private async Task SimulateSplashPreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, SplashWindow window)
{
var stages = new[] { "initializing", "update", "plugins", "launch", "ready" };
var messages = new[] { "Initializing...", "Checking updates...", "Checking plugins...", "Launching host...", "Ready" };
var reporter = (ISplashStageReporter)window;
for (var i = 0; i < stages.Length; i++)
{
reporter.Report(stages[i], messages[i]);
await Task.Delay(800).ConfigureAwait(false);
}
await Task.Delay(5000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private async Task SimulateUpdatePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, UpdateWindow window)
{
var stages = new[] { "verify", "extract", "apply", "plugins", "cleanup" };
for (var i = 0; i < stages.Length; i++)
{
window.Report(stages[i], $"Processing {stages[i]}...", (i + 1) * 20);
await Task.Delay(600).ConfigureAwait(false);
}
window.ReportComplete(true, null);
await Task.Delay(3000).ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private async Task SimulateOobePreviewAsync(IClassicDesktopStyleApplicationLifetime desktop, OobeWindow window)
{
try
{
await window.WaitForEnterAsync().ConfigureAwait(false);
Logger.Info("OOBE preview completed by user.");
}
catch (Exception ex)
{
Logger.Error("OOBE preview failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private async Task WaitForWindowCloseAsync(IClassicDesktopStyleApplicationLifetime desktop, Window window)
{
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
window.Closed += (_, _) => tcs.TrySetResult();
await tcs.Task.ConfigureAwait(false);
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(0));
}
private static async Task RunCoordinatorWithSplashAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
SplashWindow splashWindow)
{
LauncherResult result;
SplashWindow? currentSplashWindow = splashWindow;
var appRoot = Commands.ResolveAppRoot(context);
var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context);
if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource,
successPolicy,
coordinatorPipeName,
out var reservedAttempt,
out var activeCoordinatorAttempt))
{
result = await AttachToExistingCoordinatorAsync(
context,
currentSplashWindow,
activeCoordinatorAttempt).ConfigureAwait(false);
Logger.Info($"Secondary launcher completed. Success={result.Success}; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
return;
}
using var coordinatorServer = new LauncherCoordinatorIpcServer(
coordinatorPipeName,
BuildCoordinatorStatusFromAttempt(reservedAttempt),
HandleCoordinatorRequestAsync,
startupAttemptRegistry.UpdateOwnedCoordinatorHeartbeat);
coordinatorServer.Start();
while (true)
{
try
{
Logger.Info(
$"Coordinator start. Command='{context.Command}'; AppRoot='{appRoot}'; " +
$"IsDebugMode={context.IsDebugMode}; LaunchSource='{context.LaunchSource}'; " +
$"ResultPath='{context.GetOption("result") ?? "<none>"}'.");
var deploymentLocator = new DeploymentLocator(appRoot);
var coordinator = new LauncherFlowCoordinator(
context,
deploymentLocator,
new OobeStateService(appRoot),
new UpdateEngineService(deploymentLocator),
new PluginInstallerService(),
startupAttemptRegistry,
coordinatorServer);
result = await coordinator.RunAsync(currentSplashWindow).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Coordinator threw an unhandled exception.", ex);
result = new LauncherResult
{
Success = false,
Stage = "launch",
Code = "exception",
Message = $"Launcher failed: {ex.Message}",
ErrorMessage = ex.ToString()
};
}
if (result.Success ||
result.Code == "host_not_found" ||
(!string.Equals(result.Stage, "launch", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(result.Stage, "launchHost", StringComparison.OrdinalIgnoreCase)))
{
break;
}
var failureAction = await ShowFailureWindowAsync(result).ConfigureAwait(false);
if (failureAction == ErrorWindowResult.Exit)
{
break;
}
if (failureAction == ErrorWindowResult.ActivateExisting &&
await TryActivateExistingInstanceAsync().ConfigureAwait(false))
{
result = new LauncherResult
{
Success = true,
Stage = "launch",
Code = "activation_requested",
Message = "Launcher activated the existing desktop instance.",
Details = result.Details
};
break;
}
currentSplashWindow = CreateSplashWindow();
currentSplashWindow.Show();
}
Logger.Info($"Coordinator completed. Success={result.Success}; Stage='{result.Stage}'; Code='{result.Code}'.");
await WriteLauncherResultAsync(context, result).ConfigureAwait(false);
Environment.ExitCode = result.Success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
private static async Task<LauncherResult> AttachToExistingCoordinatorAsync(
CommandContext context,
SplashWindow? splashWindow,
StartupAttemptRecord? activeCoordinatorAttempt)
{
var reporter = splashWindow as ISplashStageReporter;
reporter?.Report("activation", "Connecting to the active launcher...");
if (activeCoordinatorAttempt is not null &&
!string.IsNullOrWhiteSpace(activeCoordinatorAttempt.CoordinatorPipeName))
{
var command = string.Equals(context.LaunchSource, "restart", StringComparison.OrdinalIgnoreCase)
? LauncherCoordinatorCommands.Attach
: LauncherCoordinatorCommands.ActivateDesktop;
var request = new LauncherCoordinatorRequest
{
Command = command,
LaunchSource = context.LaunchSource,
SuccessPolicy = LauncherFlowCoordinator.ResolveSuccessPolicyKey(context)
};
var response = await new LauncherCoordinatorIpcClient()
.SendAsync(activeCoordinatorAttempt.CoordinatorPipeName, request, TimeSpan.FromSeconds(2))
.ConfigureAwait(false);
if (response is not null)
{
reporter?.Report("activation", response.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = response.Accepted ||
IsRecoverableActivationFailure(response.ActivationResult, response.Status);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = success && !response.Accepted ? "attached_to_launcher_coordinator" : response.Code,
Message = success && !response.Accepted
? "Attached to the active Launcher coordinator; desktop startup is still in progress."
: response.Message,
Details = BuildCoordinatorResultDetails(response.Status, response.ActivationResult)
};
}
}
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
reporter?.Report("activation", activation.Message);
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
var success = activation.Accepted || IsRecoverableActivationFailure(activation, null);
return new LauncherResult
{
Success = success,
Stage = "launch",
Code = activation.Accepted
? "existing_host_activated"
: success
? "existing_host_startup_pending"
: "existing_host_activation_failed",
Message = success && !activation.Accepted
? "Existing desktop process is still starting; Launcher attached without starting another process."
: activation.Message,
Details = BuildCoordinatorResultDetails(null, activation)
};
}
await DismissSplashIfNeededAsync(splashWindow).ConfigureAwait(false);
return new LauncherResult
{
Success = false,
Stage = "launch",
Code = "launcher_coordinator_unavailable",
Message = "Another Launcher is coordinating startup, but it did not respond in time.",
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["activeCoordinatorPid"] = activeCoordinatorAttempt?.CoordinatorPid.ToString() ?? string.Empty,
["activeCoordinatorPipeName"] = activeCoordinatorAttempt?.CoordinatorPipeName ?? string.Empty,
["activeAttemptId"] = activeCoordinatorAttempt?.AttemptId ?? string.Empty,
["activeHostPid"] = activeCoordinatorAttempt?.HostPid.ToString() ?? string.Empty
}
};
}
private static async Task<LauncherCoordinatorResponse> HandleCoordinatorRequestAsync(
LauncherCoordinatorRequest request,
LauncherCoordinatorStatus status)
{
if (string.Equals(request.Command, LauncherCoordinatorCommands.ActivateDesktop, StringComparison.OrdinalIgnoreCase))
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
if (activation is not null)
{
if (!activation.Accepted && IsRecoverableActivationFailure(activation, status))
{
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = activation.Accepted,
Code = activation.Accepted ? "existing_host_activated" : "existing_host_activation_failed",
Message = activation.Message,
Status = status,
ActivationResult = activation
};
}
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator; desktop startup is still in progress.",
Status = status
};
}
return new LauncherCoordinatorResponse
{
Accepted = true,
Code = "attached_to_launcher_coordinator",
Message = "Attached to the active Launcher coordinator.",
Status = status
};
}
private static LauncherCoordinatorStatus BuildCoordinatorStatusFromAttempt(StartupAttemptRecord attempt)
{
return new LauncherCoordinatorStatus
{
AttemptId = attempt.AttemptId,
CoordinatorPid = Environment.ProcessId,
HostPid = attempt.HostPid,
HostProcessAlive = TryGetLiveProcess(attempt.HostPid),
LaunchSource = attempt.LaunchSource,
SuccessPolicy = attempt.SuccessPolicy,
LastObservedStage = attempt.LastObservedStage,
LastObservedMessage = attempt.LastObservedMessage,
PublicIpcConnected = attempt.PublicIpcConnected || attempt.IpcConnected,
State = attempt.State.ToString(),
SoftTimeoutShown = attempt.State is StartupAttemptState.SoftTimeout or StartupAttemptState.DetachedWaiting,
Completed = attempt.State is StartupAttemptState.Succeeded or StartupAttemptState.Failed,
Succeeded = attempt.State == StartupAttemptState.Succeeded,
UpdatedAtUtc = attempt.UpdatedAtUtc
};
}
private static bool IsRecoverableActivationFailure(
PublicShellActivationResult? activation,
LauncherCoordinatorStatus? status)
{
if (activation is { Accepted: true })
{
return false;
}
if (status is { Completed: false, HostProcessAlive: true })
{
return true;
}
var shellStatus = activation?.Status;
if (shellStatus is null || !shellStatus.PublicIpcReady)
{
return false;
}
return !shellStatus.MainWindowOpened ||
!shellStatus.DesktopVisible ||
string.Equals(activation?.Code, "shell_not_ready", StringComparison.OrdinalIgnoreCase) ||
string.Equals(activation?.Code, "startup_pending", StringComparison.OrdinalIgnoreCase);
}
private static Dictionary<string, string> BuildCoordinatorResultDetails(
LauncherCoordinatorStatus? status,
PublicShellActivationResult? activation)
{
var details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["coordinatorPid"] = status?.CoordinatorPid.ToString() ?? string.Empty,
["coordinatorAttemptId"] = status?.AttemptId ?? string.Empty,
["hostPid"] = status?.HostPid.ToString() ?? activation?.Status.ProcessId.ToString() ?? string.Empty,
["hostProcessAlive"] = status?.HostProcessAlive.ToString() ?? string.Empty,
["publicIpcConnected"] = (status?.PublicIpcConnected ?? activation is not null).ToString(),
["startupStage"] = status?.LastObservedStage.ToString() ?? string.Empty,
["startupState"] = status?.State ?? string.Empty,
["activationAccepted"] = activation?.Accepted.ToString() ?? string.Empty,
["shellState"] = activation?.Status.ShellState ?? status?.ShellStatus?.ShellState ?? string.Empty,
["trayState"] = activation?.Status.Tray.State ?? status?.ShellStatus?.Tray.State ?? string.Empty,
["taskbarUsable"] = activation?.Status.Taskbar.IsUsable.ToString() ?? status?.ShellStatus?.Taskbar.IsUsable.ToString() ?? string.Empty
};
return details;
}
private static async Task DismissSplashIfNeededAsync(SplashWindow? splashWindow)
{
if (splashWindow is null)
{
return;
}
try
{
await splashWindow.DismissAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to dismiss splash after coordinator attach: {ex.Message}");
}
}
private static async Task WriteLauncherResultAsync(CommandContext context, LauncherResult result)
{
var resultPath = context.GetOption("result");
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
try
{
await Commands.WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
Logger.Info($"Launcher result written to '{Path.GetFullPath(resultPath)}'.");
}
catch (Exception ex)
{
Logger.Error($"Failed to write launcher result to '{resultPath}'.", ex);
}
}
private static async Task<ErrorWindowResult> ShowFailureWindowAsync(LauncherResult result)
{
ErrorWindow? errorWindow = null;
var hostProcessAlive = result.Details.TryGetValue("hostProcessAlive", out var hostProcessAliveText) &&
bool.TryParse(hostProcessAliveText, out var hostProcessAliveValue) &&
hostProcessAliveValue;
var hostPid = result.Details.TryGetValue("hostPid", out var hostPidText) &&
int.TryParse(hostPidText, out var parsedPid)
? parsedPid
: (int?)null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
try
{
errorWindow = new ErrorWindow();
if (hostProcessAlive)
{
errorWindow.ConfigureForRunningHostFailure(hostPid);
}
else
{
errorWindow.ConfigureForGenericFailure(allowRetry: true);
}
errorWindow.SetErrorMessage(
$"Failed to start LanMountainDesktop.\n\nStage: {result.Stage}\nCode: {result.Code}\n\n{result.Message}");
errorWindow.Show();
}
catch (Exception ex)
{
Logger.Error("Failed to show launcher failure window.", ex);
}
});
if (errorWindow is null)
{
return ErrorWindowResult.Exit;
}
try
{
return await errorWindow.WaitForChoiceAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Error("Failure window closed unexpectedly.", ex);
return ErrorWindowResult.Exit;
}
}
private static async Task<bool> TryActivateExistingInstanceAsync()
{
var activation = await TryActivateExistingInstanceWithStatusAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
return activation?.Accepted == true;
}
private static async Task<PublicShellActivationResult?> TryActivateExistingInstanceWithStatusAsync(TimeSpan timeout)
{
try
{
using var ipcClient = new LanMountainDesktopIpcClient();
var connectTask = ipcClient.ConnectAsync();
var completedTask = await Task.WhenAny(connectTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != connectTask)
{
return null;
}
await connectTask.ConfigureAwait(false);
if (!ipcClient.IsConnected)
{
return null;
}
var shellProxy = ipcClient.CreateProxy<IPublicShellControlService>();
var activationTask = shellProxy.ActivateMainWindowWithStatusAsync();
completedTask = await Task.WhenAny(activationTask, Task.Delay(timeout)).ConfigureAwait(false);
if (completedTask != activationTask)
{
return null;
}
return await activationTask.ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Failed to activate the existing desktop instance: {ex.Message}");
return null;
}
}
private static bool TryGetLiveProcess(int processId)
{
if (processId <= 0)
{
return false;
}
try
{
using var process = Process.GetProcessById(processId);
return !process.HasExited;
}
catch
{
return false;
}
}
private static async Task RunApplyUpdateWithWindowAsync(
IClassicDesktopStyleApplicationLifetime desktop,
CommandContext context,
UpdateWindow window)
{
var appRoot = Commands.ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
var success = true;
string? errorMessage = null;
try
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("verify", "Verifying update...", 10));
var updateResult = await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false);
if (!updateResult.Success)
{
success = false;
errorMessage = updateResult.Message;
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("plugins", "Applying plugin upgrades...", 60));
var pluginsDir = context.GetOption("plugins-dir") ?? Path.Combine(appRoot, "plugins");
var queueResult = pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success && queueResult.Code != "noop")
{
Logger.Error($"Plugin upgrade failed during apply-update: {queueResult.Message}");
}
}
if (success)
{
await Dispatcher.UIThread.InvokeAsync(() => window.Report("cleanup", "Cleaning up old deployments...", 90));
deploymentLocator.CleanupOldDeployments(minVersionsToKeep: 3);
}
}
catch (Exception ex)
{
success = false;
errorMessage = ex.Message;
Logger.Error("Apply-update flow failed.", ex);
}
await Dispatcher.UIThread.InvokeAsync(() => window.ReportComplete(success, errorMessage));
await Task.Delay(success ? 1500 : 5000).ConfigureAwait(false);
await Commands.WriteResultIfNeededAsync(context.GetOption("result"), new LauncherResult
{
Success = success,
Stage = "apply-update",
Code = success ? "ok" : "failed",
Message = success ? "Update applied successfully." : (errorMessage ?? "Unknown error"),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = context.Command,
["launchSource"] = context.LaunchSource
}
}).ConfigureAwait(false);
Environment.ExitCode = success ? 0 : 1;
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(Environment.ExitCode), DispatcherPriority.Background);
}
}

View File

@@ -0,0 +1,46 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher;
[JsonSourceGenerationOptions(
WriteIndented = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(SignedFileMap))]
[JsonSerializable(typeof(UpdateFileEntry))]
[JsonSerializable(typeof(PlondsUpdateMetadata))]
[JsonSerializable(typeof(PlondsFileMap))]
[JsonSerializable(typeof(PlondsComponentEntry))]
[JsonSerializable(typeof(PlondsFileEntry))]
[JsonSerializable(typeof(PlondsHashDescriptor))]
[JsonSerializable(typeof(SnapshotMetadata))]
[JsonSerializable(typeof(AppVersionInfo))]
[JsonSerializable(typeof(StartupProgressMessage))]
[JsonSerializable(typeof(LauncherCoordinatorRequest))]
[JsonSerializable(typeof(LauncherCoordinatorResponse))]
[JsonSerializable(typeof(LauncherCoordinatorStatus))]
[JsonSerializable(typeof(PublicShellStatus))]
[JsonSerializable(typeof(PublicTrayStatus))]
[JsonSerializable(typeof(PublicTaskbarStatus))]
[JsonSerializable(typeof(PublicShellActivationResult))]
[JsonSerializable(typeof(LauncherResult))]
[JsonSerializable(typeof(HostDiscoveryConfig))]
[JsonSerializable(typeof(PluginManifest))]
[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))]
[JsonSerializable(typeof(PrivacyConfig))]
[JsonSerializable(typeof(PrivacyAgreementState))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallProgressReport))]
[JsonSerializable(typeof(LanMountainDesktop.Shared.Contracts.Update.InstallCompleteReport))]
internal sealed partial class AppJsonContext : JsonSerializerContext;

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,11 @@
-----BEGIN RSA PUBLIC KEY-----
MIIBigKCAYEAt3yev3f0D1AZthEmr7ZGeDTcjIOGwQgPGRK/qV1XMlYS96AYiqlQ
ToZyA+WrDAXOUHcpaIzei+GdieTs+IE0q64dvBY5+wJShKhGMdcJ+nibt6qfsgvX
M2jSuR5ubHP9HGqBQNgLYdGFyD/IA7cDG5AsrGTXtVIldbkSzHPJiAp69G3fu9Hi
J7o7jE3pzTTPoArpjcCheoK/+9vjZOmEmkw71uWvmtld8KgOYz5Wk+GbQ2mJk6NJ
5TNqvlnzbYl946f78XNvHnnguLEU7q4SK0vgE7F92G10xB1A6DCTZQINjz/RrO5s
M/r29/jRSZbdrqbDIufxzxSeU80ADd7THSAGTVltynO0prAKW4be7ZtKbZVXgMUO
NMyCZUPCvSZP21Z7FSVyzf3wWYbyn/iBYCogticl5GBlr6ChQ/kfOQCGysCuDRK0
/RJ+ukWQCpl41Sh33B3HltOoKNuVuOkhwiDvJ4ckDoupf+4hzTzqWCuZf3NLAsYf
FQiGowgqx0l5AgMBAAE=
-----END RSA PUBLIC KEY-----

View File

@@ -0,0 +1,182 @@
using System.Globalization;
namespace LanMountainDesktop.Launcher;
internal sealed class CommandContext
{
private const string LaunchSourceOptionName = "launch-source";
private static readonly string[] GuiCommands =
[
"launch",
"apply-update",
"preview-splash",
"preview-error",
"preview-update",
"preview-oobe",
"preview-debug"
];
public string Command { get; }
public string SubCommand { get; }
public IReadOnlyDictionary<string, string> Options { get; }
/// <summary>
/// 原始命令行参数,用于转发给主程序
/// </summary>
public IReadOnlyList<string> RawArgs { get; }
public bool IsLegacyPluginInstall =>
Options.ContainsKey("source") &&
Options.ContainsKey("plugins-dir") &&
Options.ContainsKey("result");
public string LaunchSource => NormalizeLaunchSource(GetOption(LaunchSourceOptionName)) ?? InferLaunchSource();
/// <summary>
/// 是否处于调试模式(从 Rider/VS 等 IDE 启动)
/// 当满足以下任一条件时启用:
/// 1. 明确指定 --debug 参数
/// 2. 调试器附加Debugger.IsAttached
/// 3. DOTNET_ENVIRONMENT 环境变量为 DevelopmentIDE 调试启动时自动设置)
/// </summary>
public bool IsDebugMode =>
Options.ContainsKey("debug") ||
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);
public bool IsGuiCommand =>
GuiCommands.Contains(Command, StringComparer.OrdinalIgnoreCase);
public bool IsMaintenanceCommand =>
string.Equals(LaunchSource, "apply-update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(LaunchSource, "plugin-install", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "update", StringComparison.OrdinalIgnoreCase) ||
string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase);
public string? ExplicitAppRoot => GetOption("app-root");
private CommandContext(string command, string subCommand, Dictionary<string, string> options, string[] rawArgs)
{
Command = command;
SubCommand = subCommand;
Options = options;
RawArgs = rawArgs;
}
public static CommandContext FromArgs(string[] args)
{
var options = ParseOptions(args);
var command = args.Length > 0 && !args[0].StartsWith("--", StringComparison.Ordinal)
? args[0]
: "launch";
var subCommand = args.Length > 1 && !args[1].StartsWith("--", StringComparison.Ordinal)
? args[1]
: string.Empty;
return new CommandContext(command, subCommand, options, args);
}
public string? GetOption(string key)
{
return Options.TryGetValue(key, out var value) ? value : null;
}
public int GetIntOption(string key, int fallback)
{
var raw = GetOption(key);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)
? value
: fallback;
}
private string InferLaunchSource()
{
if (IsPreviewCommand)
{
return "debug-preview";
}
if (string.Equals(Command, "apply-update", StringComparison.OrdinalIgnoreCase))
{
return "apply-update";
}
if (IsLegacyPluginInstall || string.Equals(Command, "plugin", StringComparison.OrdinalIgnoreCase))
{
return "plugin-install";
}
return "normal";
}
private static string? NormalizeLaunchSource(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
return raw.Trim().ToLowerInvariant() switch
{
"normal" => "normal",
"restart" => "restart",
"postinstall" => "postinstall",
"apply-update" => "apply-update",
"plugin-install" => "plugin-install",
"debug-preview" => "debug-preview",
_ => null
};
}
private static Dictionary<string, string> ParseOptions(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
values[key[..equalsIndex]] = key[(equalsIndex + 1)..];
continue;
}
if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal))
{
values[key] = args[++i];
continue;
}
values[key] = "true";
}
return values;
}
}

View File

@@ -0,0 +1,66 @@
<!-- AOT 发布配置文件 -->
<Project>
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 启用 Native AOT -->
<PublishAot>true</PublishAot>
<!-- 启用修剪以减小体积 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<!-- 自包含(不依赖系统 .NET Runtime -->
<SelfContained>true</SelfContained>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 包含 native 库到单文件中 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 压缩单文件 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- 优化大小 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- 禁用 ReadyToRunAOT 不需要) -->
<PublishReadyToRun>false</PublishReadyToRun>
<!-- 注意RuntimeIdentifier 由 CI/CD 工作流通过 -r 参数传入,不在此处硬编码 -->
<!-- 支持的平台win-x64, win-x86, linux-x64, osx-x64, osx-arm64 -->
</PropertyGroup>
<!-- AOT 兼容性设置 -->
<PropertyGroup>
<!-- 允许不安全代码(某些 AOT 场景需要) -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 启用编译时绑定Avalonia 需要) -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<!-- AOT 修剪配置 -->
<ItemGroup Condition="'$(PublishAot)' == 'true'">
<!-- 保留 Avalonia 必要的类型 -->
<TrimmerRootAssembly Include="Avalonia" />
<TrimmerRootAssembly Include="Avalonia.Desktop" />
<!-- 保留动态序列化类型 -->
<TrimmerRootAssembly Include="System.Text.Json" />
</ItemGroup>
<!-- AOT 兼容性:某些包可能需要特殊处理 -->
<PropertyGroup Condition="'$(PublishAot)' == 'true'">
<!-- 忽略某些警告 -->
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
<!-- 允许 IL 警告 -->
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<!-- AOT 模式下禁用反射式 JSON 序列化,强制使用 Source Generator -->
<!-- 之前设置为 true 与 AOT 矛盾,导致 IL2026/IL3050 警告和运行时失败 -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<!-- 启用 ISerializable 支持(部分库需要) -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,29 @@
<!-- 单文件发布配置文件(非 AOT但接近单文件体验 -->
<Project>
<PropertyGroup Condition="'$(PublishSingleFileMode)' == 'true'">
<!-- 自包含 -->
<SelfContained>true</SelfContained>
<!-- 单文件发布 -->
<PublishSingleFile>true</PublishSingleFile>
<!-- 包含 native 库到单文件中 -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- 压缩单文件 -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<!-- ReadyToRun 预编译(提升启动速度) -->
<PublishReadyToRun>true</PublishReadyToRun>
<!-- 修剪以减小体积 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<!-- 优化大小 -->
<OptimizationPreference>Size</OptimizationPreference>
<!-- 目标运行时 -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,62 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<!-- 导入 AOT 配置 -->
<Import Project="LanMountainDesktop.Launcher.AOT.props" Condition="Exists('LanMountainDesktop.Launcher.AOT.props')" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.0.0-dev</Version>
<PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<!-- 应用程序图标 -->
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<!-- 只引用 Shared.ContractsIPC 协议) -->
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.IPC\LanMountainDesktop.Shared.IPC.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Tmds.DBus.Protocol" />
</ItemGroup>
<!-- 资源文件 -->
<ItemGroup>
<!-- 公钥文件 -->
<None Include="Assets\public-key.pem" CopyToOutputDirectory="PreserveNewest" />
<!-- Avalonia 资源文件 -->
<AvaloniaResource Include="Assets\logo.ico" />
<AvaloniaResource Include="..\LanMountainDesktop\Assets\logo_nightly.png" Link="Assets\logo_nightly.png" />
</ItemGroup>
<Target Name="CopyPublicKeyToLauncherDir" AfterTargets="Build">
<PropertyGroup>
<PublicKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublicKeySource>
<PublicKeyDestDir>$(OutDir).launcher\update</PublicKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublicKeyDestDir)" />
<Copy SourceFiles="$(PublicKeySource)" DestinationFolder="$(PublicKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPublicKeyToPublishDir" AfterTargets="Publish">
<PropertyGroup>
<PublishedKeySource>$(MSBuildProjectDirectory)\Assets\public-key.pem</PublishedKeySource>
<PublishedKeyDestDir>$(PublishDir).launcher\update</PublishedKeyDestDir>
</PropertyGroup>
<MakeDir Directories="$(PublishedKeyDestDir)" />
<Copy SourceFiles="$(PublishedKeySource)" DestinationFolder="$(PublishedKeyDestDir)" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher;
internal static class LauncherRuntimeContext
{
public static CommandContext Current { get; set; } = CommandContext.FromArgs([]);
}

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

@@ -0,0 +1,96 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
namespace LanMountainDesktop.Launcher.Models;
internal static class LauncherCoordinatorCommands
{
public const string Attach = "attach";
public const string ActivateDesktop = "activate-desktop";
public const string GetStatus = "get-status";
}
internal sealed class LauncherCoordinatorRequest
{
[JsonPropertyName("requestId")]
public string RequestId { get; init; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("command")]
public string Command { get; init; } = LauncherCoordinatorCommands.Attach;
[JsonPropertyName("launcherPid")]
public int LauncherPid { get; init; } = Environment.ProcessId;
[JsonPropertyName("launchSource")]
public string LaunchSource { get; init; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; init; } = string.Empty;
}
internal sealed class LauncherCoordinatorResponse
{
[JsonPropertyName("accepted")]
public bool Accepted { get; init; }
[JsonPropertyName("code")]
public string Code { get; init; } = string.Empty;
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("status")]
public LauncherCoordinatorStatus? Status { get; init; }
[JsonPropertyName("activationResult")]
public PublicShellActivationResult? ActivationResult { get; init; }
}
internal sealed class LauncherCoordinatorStatus
{
[JsonPropertyName("attemptId")]
public string AttemptId { get; init; } = string.Empty;
[JsonPropertyName("coordinatorPid")]
public int CoordinatorPid { get; init; } = Environment.ProcessId;
[JsonPropertyName("hostPid")]
public int HostPid { get; init; }
[JsonPropertyName("hostProcessAlive")]
public bool HostProcessAlive { get; init; }
[JsonPropertyName("launchSource")]
public string LaunchSource { get; init; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; init; } = string.Empty;
[JsonPropertyName("lastObservedStage")]
public StartupStage LastObservedStage { get; init; } = StartupStage.Initializing;
[JsonPropertyName("lastObservedMessage")]
public string LastObservedMessage { get; init; } = string.Empty;
[JsonPropertyName("publicIpcConnected")]
public bool PublicIpcConnected { get; init; }
[JsonPropertyName("state")]
public string State { get; init; } = string.Empty;
[JsonPropertyName("softTimeoutShown")]
public bool SoftTimeoutShown { get; init; }
[JsonPropertyName("completed")]
public bool Completed { get; init; }
[JsonPropertyName("succeeded")]
public bool Succeeded { get; init; }
[JsonPropertyName("shellStatus")]
public PublicShellStatus? ShellStatus { get; init; }
[JsonPropertyName("updatedAtUtc")]
public DateTimeOffset UpdatedAtUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.Launcher.Models;
internal sealed class LauncherResult
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("stage")]
public string Stage { get; init; } = string.Empty;
[JsonPropertyName("code")]
public string Code { get; init; } = "ok";
[JsonPropertyName("message")]
public string Message { get; init; } = string.Empty;
[JsonPropertyName("currentVersion")]
public string? CurrentVersion { get; init; }
[JsonPropertyName("targetVersion")]
public string? TargetVersion { get; init; }
[JsonPropertyName("rolledBackTo")]
public string? RolledBackTo { get; init; }
[JsonPropertyName("details")]
public Dictionary<string, string> Details { get; init; } = [];
[JsonPropertyName("installedPackagePath")]
public string? InstalledPackagePath { get; init; }
[JsonPropertyName("manifestId")]
public string? ManifestId { get; init; }
[JsonPropertyName("manifestName")]
public string? ManifestName { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,63 @@
namespace LanMountainDesktop.Launcher.Models;
internal enum OobeStateStatus
{
FirstRun,
Completed,
Unavailable,
Suppressed
}
internal sealed class OobeStateFile
{
public int SchemaVersion { get; init; } = 1;
public string CompletedAtUtc { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string? UserSid { get; init; }
public string LaunchSource { get; init; } = string.Empty;
}
internal sealed class OobeLaunchDecision
{
public OobeStateStatus Status { get; init; }
public bool ShouldShowOobe { get; init; }
public string StatePath { get; init; } = string.Empty;
public string LaunchSource { get; init; } = "normal";
public bool IsElevated { get; init; }
public string UserName { get; init; } = string.Empty;
public string? UserSid { get; init; }
public string ResultCode { get; init; } = "ok";
public string SuppressionReason { get; init; } = string.Empty;
public string ErrorMessage { get; init; } = string.Empty;
public bool UsedLegacyMarker { get; init; }
public bool MigratedLegacyMarker { get; init; }
}
internal sealed class OobeCompletionResult
{
public bool Success { get; init; }
public string ResultCode { get; init; } = "ok";
public string ErrorMessage { get; init; } = string.Empty;
}
internal sealed record LauncherExecutionSnapshot(
bool IsElevated,
string UserName,
string? UserSid);

View File

@@ -0,0 +1,42 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 隐私协议同意状态模型(带防篡改保护)
/// </summary>
public class PrivacyAgreementState
{
/// <summary>
/// 用户是否同意隐私协议
/// </summary>
public bool IsAgreed { get; set; } = false;
/// <summary>
/// 同意时间UTC
/// </summary>
public DateTime AgreedAtUtc { get; set; }
/// <summary>
/// 同意的协议版本
/// </summary>
public string AgreementVersion { get; set; } = "1.0";
/// <summary>
/// 用户标识(匿名)
/// </summary>
public string UserId { get; set; } = string.Empty;
/// <summary>
/// 设备标识
/// </summary>
public string DeviceId { get; set; } = string.Empty;
/// <summary>
/// 数据完整性校验哈希HMAC-SHA256
/// </summary>
public string IntegrityHash { get; set; } = string.Empty;
/// <summary>
/// 用于生成哈希的随机盐值
/// </summary>
public string Salt { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,22 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 隐私配置模型
/// </summary>
public class PrivacyConfig
{
/// <summary>
/// 是否启用崩溃报告遥测
/// </summary>
public bool CrashTelemetryEnabled { get; set; } = true;
/// <summary>
/// 是否启用使用统计遥测
/// </summary>
public bool UsageTelemetryEnabled { get; set; } = true;
/// <summary>
/// 隐私追踪 ID
/// </summary>
public string TelemetryId { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,24 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// GitHub Release 信息
/// </summary>
public sealed class ReleaseInfo
{
public required string TagName { get; init; }
public required string Name { get; init; }
public required bool Prerelease { get; init; }
public required DateTime PublishedAt { get; init; }
public required List<ReleaseAsset> Assets { get; init; }
public string? Body { get; init; }
}
/// <summary>
/// Release 资源文件
/// </summary>
public sealed class ReleaseAsset
{
public required string Name { get; init; }
public required string BrowserDownloadUrl { get; init; }
public required long Size { get; init; }
}

View File

@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Models;
internal enum StartupAttemptState
{
Pending,
SoftTimeout,
DetachedWaiting,
Succeeded,
Failed,
WaitingForShell
}
internal sealed class StartupAttemptRecord
{
[JsonPropertyName("attemptId")]
public string AttemptId { get; set; } = Guid.NewGuid().ToString("N");
[JsonPropertyName("hostPid")]
public int HostPid { get; set; }
[JsonPropertyName("coordinatorPid")]
public int CoordinatorPid { get; set; }
[JsonPropertyName("coordinatorPipeName")]
public string CoordinatorPipeName { get; set; } = string.Empty;
[JsonPropertyName("startedAtUtc")]
public DateTimeOffset StartedAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("updatedAtUtc")]
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("heartbeatAtUtc")]
public DateTimeOffset HeartbeatAtUtc { get; set; } = DateTimeOffset.UtcNow;
[JsonPropertyName("launchSource")]
public string LaunchSource { get; set; } = string.Empty;
[JsonPropertyName("successPolicy")]
public string SuccessPolicy { get; set; } = string.Empty;
[JsonPropertyName("lastObservedStage")]
public StartupStage LastObservedStage { get; set; } = StartupStage.Initializing;
[JsonPropertyName("lastObservedMessage")]
public string LastObservedMessage { get; set; } = string.Empty;
[JsonPropertyName("ipcConnected")]
public bool IpcConnected { get; set; }
[JsonPropertyName("publicIpcConnected")]
public bool PublicIpcConnected { get; set; }
[JsonPropertyName("shellStatus")]
public string ShellStatus { get; set; } = string.Empty;
[JsonPropertyName("reservedBeforeHostStart")]
public bool ReservedBeforeHostStart { get; set; }
[JsonPropertyName("state")]
public StartupAttemptState State { get; set; } = StartupAttemptState.Pending;
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新频道
/// </summary>
public enum UpdateChannel
{
/// <summary>
/// 正式版 - 只检查 prerelease=false 的版本
/// </summary>
Stable,
/// <summary>
/// 预览版 - 检查所有版本(包括 prerelease=true)
/// </summary>
Preview
}

View File

@@ -0,0 +1,13 @@
namespace LanMountainDesktop.Launcher.Models;
/// <summary>
/// 更新检查结果
/// </summary>
public sealed class UpdateCheckResult
{
public bool HasUpdate { get; init; }
public string? LatestVersion { get; init; }
public string? CurrentVersion { get; init; }
public ReleaseInfo? Release { get; init; }
public string? ErrorMessage { get; init; }
}

View File

@@ -0,0 +1,144 @@
namespace LanMountainDesktop.Launcher.Models;
internal sealed class SignedFileMap
{
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public List<UpdateFileEntry> Files { get; set; } = [];
}
internal sealed class UpdateFileEntry
{
public string Path { get; set; } = string.Empty;
public string? ArchivePath { get; set; }
public string Action { get; set; } = "replace";
public string? Sha256 { get; set; }
}
internal sealed class SnapshotMetadata
{
public string SnapshotId { get; set; } = string.Empty;
public string SourceVersion { get; set; } = string.Empty;
public string? TargetVersion { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public string SourceDirectory { get; set; } = string.Empty;
public string? TargetDirectory { get; set; }
public string Status { get; set; } = "pending";
}
internal sealed class UpdateApplyResult
{
public bool Success { get; init; }
public string Message { get; init; } = string.Empty;
public string? FromVersion { get; init; }
public string? ToVersion { get; init; }
public string? RolledBackTo { get; init; }
}
internal sealed class PlondsUpdateMetadata
{
public string? DistributionId { get; set; }
public string? Channel { get; set; }
public string? SubChannel { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? FileMapPath { get; set; }
public string? FileMapSignaturePath { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsFileMap
{
public string? DistributionId { get; set; }
public string? FromVersion { get; set; }
public string? ToVersion { get; set; }
public string? Version { get; set; }
public string? Platform { get; set; }
public string? Arch { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsComponentEntry> Components { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsComponentEntry
{
public string Name { get; set; } = string.Empty;
public string? Version { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
public List<PlondsFileEntry> Files { get; set; } = [];
}
internal sealed class PlondsFileEntry
{
public string Path { get; set; } = string.Empty;
public string? Action { get; set; } = "replace";
public string? Url { get; set; }
public string? ObjectUrl { get; set; }
public string? ObjectPath { get; set; }
public string? ObjectKey { get; set; }
public string? ArchivePath { get; set; }
public string? Sha256 { get; set; }
public string? Sha512 { get; set; }
public string? Sha512Base64 { get; set; }
public byte[]? Sha512Bytes { get; set; }
public PlondsHashDescriptor? Hash { get; set; }
public Dictionary<string, string> Metadata { get; set; } = [];
}
internal sealed class PlondsHashDescriptor
{
public string? Algorithm { get; set; }
public string? Value { get; set; }
public byte[]? Bytes { get; set; }
}

View File

@@ -0,0 +1,76 @@
using Avalonia;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher;
public static class Program
{
[STAThread]
public static async Task<int> Main(string[] args)
{
var commandContext = CommandContext.FromArgs(args);
var execution = LauncherExecutionContext.Capture();
Logger.Initialize();
Logger.Info(
$"Program entry. Command='{commandContext.Command}'; SubCommand='{commandContext.SubCommand}'; " +
$"IsGuiMode={commandContext.IsGuiCommand}; IsDebugMode={commandContext.IsDebugMode}; " +
$"LaunchSource='{commandContext.LaunchSource}'; IsElevated={execution.IsElevated}; " +
$"UserSid='{execution.UserSid ?? string.Empty}'; " +
$"HasResultPath={!string.IsNullOrWhiteSpace(commandContext.GetOption("result"))}; " +
$"ExplicitAppRoot='{commandContext.ExplicitAppRoot ?? "<none>"}'.");
try
{
if (commandContext.IsLegacyPluginInstall)
{
var installer = new PluginInstallerService();
return await Commands.RunLegacyPluginInstallAsync(commandContext, installer).ConfigureAwait(false);
}
if (!commandContext.IsGuiCommand)
{
return await Commands.RunCliCommandAsync(commandContext).ConfigureAwait(false);
}
LauncherRuntimeContext.Current = commandContext;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
return Environment.ExitCode;
}
catch (Exception ex)
{
Logger.Error("Launcher failed before GUI flow completed.", ex);
var result = new LauncherResult
{
Success = false,
Stage = "launcher",
Code = "launcher_bootstrap_failed",
Message = ex.Message,
ErrorMessage = ex.ToString(),
Details = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["command"] = commandContext.Command,
["subCommand"] = commandContext.SubCommand,
["launchSource"] = commandContext.LaunchSource,
["isGuiMode"] = commandContext.IsGuiCommand.ToString(),
["isDebugMode"] = commandContext.IsDebugMode.ToString(),
["isElevated"] = execution.IsElevated.ToString(),
["userSid"] = execution.UserSid ?? string.Empty,
["explicitAppRoot"] = commandContext.ExplicitAppRoot ?? string.Empty
}
};
await Commands.WriteResultIfNeededAsync(commandContext.GetOption("result"), result).ConfigureAwait(false);
return 1;
}
}
public static AppBuilder BuildAvaloniaApp()
{
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -0,0 +1,77 @@
{
"$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",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"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",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Launcher (Plugin Install)": {
"commandName": "Project",
"commandLineArgs": "plugin install <path-to-plugin.laapp>",
"workingDirectory": "$(SolutionDir)",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,196 @@
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
internal static class Commands
{
public static async Task<int> RunLegacyPluginInstallAsync(CommandContext context, PluginInstallerService installer)
{
var resultPath = context.GetOption("result");
LauncherResult result;
try
{
var source = context.GetOption("source") ?? string.Empty;
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
result = installer.InstallPackage(source, pluginsDir);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "plugin.install",
Code = "failed",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(resultPath, result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
public static async Task<int> RunCliCommandAsync(CommandContext context)
{
var appRoot = ResolveAppRoot(context);
var deploymentLocator = new DeploymentLocator(appRoot);
var updateEngine = new UpdateEngineService(deploymentLocator);
var pluginInstaller = new PluginInstallerService();
var pluginUpgrades = new PluginUpgradeQueueService(pluginInstaller);
LauncherResult result;
try
{
result = await ExecuteCoreAsync(context, updateEngine, pluginInstaller, pluginUpgrades).ConfigureAwait(false);
}
catch (Exception ex)
{
result = new LauncherResult
{
Success = false,
Stage = "command",
Code = "exception",
Message = ex.Message,
ErrorMessage = ex.Message
};
}
await WriteResultIfNeededAsync(context.GetOption("result"), result).ConfigureAwait(false);
return result.Success ? 0 : 1;
}
private static async Task<LauncherResult> ExecuteCoreAsync(
CommandContext context,
UpdateEngineService updateEngine,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.Command.ToLowerInvariant())
{
case "update":
return await ExecuteUpdateAsync(context, updateEngine).ConfigureAwait(false);
case "plugin":
return ExecutePluginCommand(context, pluginInstaller, pluginUpgrades);
default:
return new LauncherResult
{
Success = false,
Stage = "command",
Code = "unsupported_command",
Message = $"Unsupported command '{context.Command}'."
};
}
}
private static async Task<LauncherResult> ExecuteUpdateAsync(CommandContext context, UpdateEngineService updateEngine)
{
return context.SubCommand.ToLowerInvariant() switch
{
"check" => updateEngine.CheckPendingUpdate(),
"apply" => await updateEngine.ApplyPendingUpdateAsync().ConfigureAwait(false),
"rollback" => updateEngine.RollbackLatest(),
"download" => await DownloadUpdatePayloadAsync(context, updateEngine).ConfigureAwait(false),
_ => new LauncherResult
{
Success = false,
Stage = "update",
Code = "unsupported_subcommand",
Message = $"Unsupported update sub-command '{context.SubCommand}'."
}
};
}
private static async Task<LauncherResult> DownloadUpdatePayloadAsync(CommandContext context, UpdateEngineService updateEngine)
{
return await updateEngine.DownloadAsync(
context.GetOption("manifest-url") ?? throw new InvalidOperationException("Missing --manifest-url."),
context.GetOption("signature-url") ?? throw new InvalidOperationException("Missing --signature-url."),
context.GetOption("archive-url") ?? throw new InvalidOperationException("Missing --archive-url."),
CancellationToken.None).ConfigureAwait(false);
}
private static LauncherResult ExecutePluginCommand(
CommandContext context,
PluginInstallerService pluginInstaller,
PluginUpgradeQueueService pluginUpgrades)
{
switch (context.SubCommand.ToLowerInvariant())
{
case "install":
{
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginInstaller.InstallPackage(source, pluginsDir);
}
case "update":
{
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
}
default:
return new LauncherResult
{
Success = false,
Stage = "plugin",
Code = "unsupported_subcommand",
Message = $"Unsupported plugin sub-command '{context.SubCommand}'."
};
}
}
public static async Task WriteResultIfNeededAsync(string? resultPath, LauncherResult result)
{
if (string.IsNullOrWhiteSpace(resultPath))
{
return;
}
var fullPath = Path.GetFullPath(resultPath);
var dir = Path.GetDirectoryName(fullPath);
if (!string.IsNullOrWhiteSpace(dir))
{
Directory.CreateDirectory(dir);
}
var json = JsonSerializer.Serialize(result, AppJsonContext.Default.LauncherResult);
await File.WriteAllTextAsync(fullPath, json, Encoding.UTF8).ConfigureAwait(false);
}
public static string ResolveAppRoot(CommandContext context)
{
var configured = context.GetOption("app-root");
if (!string.IsNullOrWhiteSpace(configured))
{
return Path.GetFullPath(configured);
}
var launcherDir = Path.GetDirectoryName(Environment.ProcessPath);
var baseDir = Path.GetFullPath(!string.IsNullOrWhiteSpace(launcherDir)
? launcherDir
: AppContext.BaseDirectory);
// 发布版结构Launcher 和 app-* 目录在同一目录
// 检查当前目录是否有 app-* 子目录(发布版)
var appDirs = Directory.GetDirectories(baseDir, "app-*", SearchOption.TopDirectoryOnly);
if (appDirs.Length > 0)
{
// 找到 app-* 目录,说明是发布版结构
return baseDir;
}
// 开发环境:检查父目录是否有主程序
var parent = Path.GetFullPath(Path.Combine(baseDir, ".."));
var parentHost = OperatingSystem.IsWindows()
? Path.Combine(parent, "LanMountainDesktop.exe")
: Path.Combine(parent, "LanMountainDesktop");
if (File.Exists(parentHost))
{
return parent;
}
// 默认返回 baseDir
return baseDir;
}
}

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,295 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 解析应用数据目录位置。
/// </summary>
/// <remarks>
/// 安装后的目录结构:
/// <code>
/// {AppRoot}/ ← 应用安装根目录
/// LanMountainDesktop.Launcher.exe ← Launcher 可执行文件
/// .Launcher/ ← Launcher 数据目录(日志、状态、配置等)
/// app-{version}/ ← Host 部署目录
/// LanMountainDesktop.exe
/// ...
/// </code>
///
/// Launcher 数据目录固定位于应用安装根目录下的 <c>.Launcher</c> 文件夹中,
/// 与 app-* 部署目录同级。此目录不随数据位置模式改变。
///
/// DesktopHost数据目录则根据用户选择可位于系统目录或便携目录。
/// </remarks>
internal sealed class DataLocationResolver
{
private const string ConfigFileName = "data-location.config.json";
private const string DesktopFolderName = "Desktop";
private const string LauncherDataFolderName = ".Launcher";
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>
/// 默认便携模式数据路径(应用目录下的 Desktop 文件夹)
/// </summary>
public string DefaultPortableDataPath => Path.Combine(_appRoot, DesktopFolderName);
/// <summary>
/// Launcher 数据目录,固定位于应用安装根目录下的 .Launcher 文件夹。
/// 该目录与 app-* 部署目录同级,不随数据位置模式改变。
/// </summary>
public string ResolveLauncherDataPath()
{
return Path.Combine(_appRoot, LauncherDataFolderName);
}
/// <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");
}
/// <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;
}
}
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;
}
/// <summary>
/// 解析数据根目录(用户选择的位置)
/// </summary>
public string ResolveDataRoot()
{
var config = LoadConfig();
return ResolveDataRoot(config);
}
private string ResolveDataRoot(DataLocationConfig? config)
{
if (config is null)
{
return _defaultSystemDataPath;
}
if (string.Equals(config.DataLocationMode, "Portable", StringComparison.OrdinalIgnoreCase))
{
var portablePath = !string.IsNullOrWhiteSpace(config.PortableDataPath)
? config.PortableDataPath
: DefaultPortableDataPath;
return Path.GetFullPath(portablePath);
}
return !string.IsNullOrWhiteSpace(config.SystemDataPath)
? Path.GetFullPath(config.SystemDataPath)
: _defaultSystemDataPath;
}
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 launcherDataPath = ResolveLauncherDataPath();
Directory.CreateDirectory(launcherDataPath);
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(Path.Combine(ResolveDataRoot(config), DesktopFolderName));
}
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

@@ -0,0 +1,40 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeferredSplashStageReporter : ISplashStageReporter
{
private ISplashStageReporter? _inner;
private readonly List<(string Stage, string Message)> _pending = [];
public void SetInner(ISplashStageReporter inner)
{
_inner = inner;
foreach (var (stage, message) in _pending)
{
_inner.Report(stage, message);
}
_pending.Clear();
}
public void Report(string stage, string message)
{
if (_inner is not null)
{
_inner.Report(stage, message);
}
else
{
_pending.Add((stage, message));
}
}
public void ReportStage(string stage, int progress)
{
if (_inner is not null)
{
_inner.ReportStage(stage, progress);
}
}
}

View File

@@ -0,0 +1,633 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed class DeploymentLocator
{
private readonly string _appRoot;
public DeploymentLocator(string appRoot)
{
_appRoot = appRoot;
}
public string GetAppRoot() => _appRoot;
public string? FindCurrentDeploymentDirectory()
{
Console.WriteLine("[DeploymentLocator] Searching for deployment directories (ClassIsland style)...");
if (!Directory.Exists(_appRoot))
{
Console.WriteLine("[DeploymentLocator] App root directory does not exist");
return null;
}
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
try
{
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
Console.WriteLine($"[DeploymentLocator] Found {candidates.Length} app-* directories");
var validInstallations = candidates
.Where(path =>
{
var hasDestroy = File.Exists(Path.Combine(path, ".destroy"));
var hasPartial = File.Exists(Path.Combine(path, ".partial"));
var hasExe = File.Exists(Path.Combine(path, executable));
var hasCurrent = File.Exists(Path.Combine(path, ".current"));
var version = ParseVersionFromDirectory(path);
Console.WriteLine($"[DeploymentLocator] Candidate: {Path.GetFileName(path)} | " +
$"Version={version} | " +
$"Current={hasCurrent} | " +
$"Destroy={hasDestroy} | " +
$"Partial={hasPartial} | " +
$"HasExe={hasExe}");
return !hasDestroy && !hasPartial && hasExe;
})
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
HasCurrentMarker = File.Exists(Path.Combine(path, ".current"))
})
.OrderBy(x => x.HasCurrentMarker ? 0 : 1) // .current 鏍囪鐨勬帓鍓嶉潰
.ThenByDescending(x => x.Version) // 鐒跺悗鎸夌増鏈彿闄嶅簭
.ToList();
if (validInstallations.Count == 0)
{
Console.WriteLine("[DeploymentLocator] No valid deployment directories found");
return null;
}
var best = validInstallations[0];
Console.WriteLine($"[DeploymentLocator] Selected: {Path.GetFileName(best.Path)} (current={best.HasCurrentMarker}, version={best.Version})");
return best.Path;
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Error searching for deployments: {ex}");
return null;
}
}
public HostResolutionResult ResolveHostExecutable(CommandContext context)
{
ArgumentNullException.ThrowIfNull(context);
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var searchedPaths = new List<string>();
var explicitAppRoot = context.ExplicitAppRoot;
var devModeConfigIgnored = !context.IsDebugMode && Views.ErrorWindow.CheckDevModeEnabled();
string? resolvedPath;
string? source;
if (!string.IsNullOrWhiteSpace(explicitAppRoot))
{
var explicitRoot = Path.GetFullPath(explicitAppRoot);
resolvedPath = TryResolveExplicitAppRoot(explicitRoot, executable, searchedPaths, out source);
}
else
{
resolvedPath = TryResolvePublishedOrPortableHost(executable, searchedPaths, out source);
}
if (resolvedPath is null && context.IsDebugMode)
{
resolvedPath = TryResolveDebugHost(executable, searchedPaths, out source);
}
if (resolvedPath is null)
{
resolvedPath = ResolveHostExecutablePathLegacy();
if (!string.IsNullOrWhiteSpace(resolvedPath))
{
searchedPaths.Add(Path.GetFullPath(resolvedPath));
source = "legacy_fallback";
}
}
return new HostResolutionResult
{
Success = !string.IsNullOrWhiteSpace(resolvedPath),
ResolvedHostPath = resolvedPath,
ResolutionSource = source,
AppRoot = _appRoot,
ExplicitAppRoot = explicitAppRoot,
DevModeConfigIgnored = devModeConfigIgnored,
SearchedPaths = searchedPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
public string? ResolveHostExecutablePath()
{
return ResolveHostExecutablePathLegacy();
}
private string? TryResolveExplicitAppRoot(
string explicitRoot,
string executable,
List<string> searchedPaths,
out string? source)
{
var directPath = Path.Combine(explicitRoot, executable);
searchedPaths.Add(directPath);
if (File.Exists(directPath))
{
source = "explicit_app_root_direct";
return directPath;
}
var deployment = FindBestDeploymentHost(explicitRoot, executable, searchedPaths);
if (deployment is not null)
{
source = "explicit_app_root_deployment";
return deployment;
}
source = null;
return null;
}
private string? TryResolvePublishedOrPortableHost(
string executable,
List<string> searchedPaths,
out string? source)
{
var deployment = FindBestDeploymentHost(_appRoot, executable, searchedPaths);
if (deployment is not null)
{
source = "published_deployment";
return deployment;
}
var portableCandidates = new[]
{
Path.Combine(_appRoot, executable),
Path.Combine(AppContext.BaseDirectory, executable)
};
foreach (var candidate in portableCandidates
.Select(Path.GetFullPath)
.Distinct(StringComparer.OrdinalIgnoreCase))
{
searchedPaths.Add(candidate);
if (File.Exists(candidate))
{
source = "portable_host";
return candidate;
}
}
source = null;
return null;
}
private string? TryResolveDebugHost(
string executable,
List<string> searchedPaths,
out string? source)
{
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath))
{
searchedPaths.Add(fullSavedPath);
if (File.Exists(fullSavedPath))
{
source = "debug_saved_custom_path";
return fullSavedPath;
}
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
}
foreach (var devPath in GetDevelopmentPaths(executable))
{
var fullPath = Path.GetFullPath(devPath);
searchedPaths.Add(fullPath);
if (File.Exists(fullPath))
{
source = "debug_build_output";
return fullPath;
}
}
source = null;
return null;
}
private static bool TryNormalizeSavedDebugPath(string savedPath, out string fullSavedPath)
{
try
{
fullSavedPath = Path.GetFullPath(savedPath);
return true;
}
catch (Exception ex)
{
fullSavedPath = string.Empty;
Logger.Warn($"Saved launcher debug host path is invalid and cannot be normalized; falling back to development paths. Path='{savedPath}'; Error='{ex.Message}'.");
return false;
}
}
private static string? FindBestDeploymentHost(
string root,
string executable,
List<string> searchedPaths)
{
if (!Directory.Exists(root))
{
searchedPaths.Add(Path.Combine(root, "app-*", executable));
return null;
}
var appDirs = Directory.GetDirectories(root, "app-*", SearchOption.TopDirectoryOnly)
.Where(path => !File.Exists(Path.Combine(path, ".destroy")))
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
HostPath = Path.Combine(path, executable),
HasCurrent = File.Exists(Path.Combine(path, ".current")),
Version = ParseVersionFromDirectory(path)
})
.OrderByDescending(item => item.HasCurrent)
.ThenByDescending(item => item.Version)
.ToList();
foreach (var candidate in appDirs)
{
searchedPaths.Add(candidate.HostPath);
if (File.Exists(candidate.HostPath))
{
return candidate.HostPath;
}
}
if (appDirs.Count == 0)
{
searchedPaths.Add(Path.Combine(root, "app-*", executable));
}
return null;
}
private string? ResolveHostExecutablePathLegacy()
{
var executable = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
// 1. 棣栧厛鏌ユ壘 app-{version} 鐩綍锛堢敓浜х幆澧冿級
var currentDeployment = FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(currentDeployment))
{
var inDeployment = Path.Combine(currentDeployment, executable);
if (File.Exists(inDeployment))
{
return inDeployment;
}
}
var inRoot = Path.Combine(_appRoot, executable);
if (File.Exists(inRoot))
{
return inRoot;
}
var parent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
var inParent = Path.Combine(parent, executable);
if (File.Exists(inParent))
{
return inParent;
}
// 4. 寮€鍙戞ā寮忥細濡傛灉鍚敤浜嗗紑鍙戞ā寮忥紝浼樺厛浣跨敤淇濆瓨鐨勮嚜瀹氫箟璺緞
if (Views.ErrorWindow.CheckDevModeEnabled())
{
var savedCustomPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedCustomPath))
{
if (TryNormalizeSavedDebugPath(savedCustomPath, out var fullSavedPath) &&
File.Exists(fullSavedPath))
{
return fullSavedPath;
}
else if (!string.IsNullOrWhiteSpace(fullSavedPath))
{
Logger.Warn($"Saved launcher debug host path is invalid; falling back to development paths. Path='{fullSavedPath}'.");
}
}
var devPath = ScanDevelopmentPaths(executable);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
}
// 5. 寮€鍙戞ā寮忥細鏌ユ壘涓荤▼搴忛」鐩殑杈撳嚭鐩綍
var devPaths = GetDevelopmentPaths(executable);
foreach (var devPath in devPaths)
{
if (File.Exists(devPath))
{
return devPath;
}
}
return null;
}
/// <summary>
/// 鎵弿寮€鍙戣矾寰勶紙寮€鍙戞ā寮忥級
/// </summary>
private static string? ScanDevelopmentPaths(string executable)
{
var solutionRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
var possiblePaths = new[]
{
// 标准开发路径:解决方案根目录下的 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),
// 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;
}
/// <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[]
{
// 标准开发路径:解决方案根目录下的 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),
// dev-test 目录
Path.Combine(solutionRoot, "dev-test", "app-1.0.0-dev", executable),
};
return possiblePaths.Select(Path.GetFullPath).Distinct();
}
public string GetCurrentVersion()
{
var deployment = FindCurrentDeploymentDirectory();
if (string.IsNullOrWhiteSpace(deployment))
{
return "0.0.0";
}
return ParseVersionTextFromDirectory(deployment) ?? "0.0.0";
}
public string BuildNextDeploymentDirectory(string targetVersion)
{
var sanitized = string.IsNullOrWhiteSpace(targetVersion) ? "0.0.0" : targetVersion.Trim();
var index = 0;
while (true)
{
var candidate = Path.Combine(_appRoot, $"app-{sanitized}-{index.ToString(CultureInfo.InvariantCulture)}");
if (!Directory.Exists(candidate))
{
return candidate;
}
index++;
}
}
/// <summary>
/// 娓呯悊鏃х増鏈儴缃诧紝淇濈暀鏈€杩戠殑N涓増鏈? /// </summary>
/// <param name="minVersionsToKeep">鏈€灏戜繚鐣欑増鏈暟锛岄粯璁?涓?/param>
public void CleanupOldDeployments(int minVersionsToKeep = 3)
{
try
{
Console.WriteLine($"[DeploymentLocator] Starting cleanup with retention policy: keep at least {minVersionsToKeep} versions");
if (!Directory.Exists(_appRoot))
{
return;
}
var candidates = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly);
var validDeployments = candidates
.Where(path => !File.Exists(Path.Combine(path, ".partial")))
.Select(path => new
{
Path = path,
Version = ParseVersionFromDirectory(path),
IsDestroyed = File.Exists(Path.Combine(path, ".destroy")),
IsCurrent = File.Exists(Path.Combine(path, ".current"))
})
.OrderByDescending(item => item.Version)
.ToList();
Console.WriteLine($"[DeploymentLocator] Found {validDeployments.Count} valid deployments");
// 纭畾瑕佷繚鐣欑殑鐗堟湰
var versionsToKeep = new HashSet<string>();
// 1. 鎬绘槸淇濈暀褰撳墠鐗堟湰
var currentVersion = validDeployments.FirstOrDefault(d => d.IsCurrent);
if (currentVersion != null)
{
versionsToKeep.Add(currentVersion.Path);
Console.WriteLine($"[DeploymentLocator] Keep current version: {currentVersion.Path}");
}
// 2. 淇濈暀鏈€杩戠殑N涓湁鏁堢増鏈紙涓嶅寘鎷凡鏍囪destroy鐨勶級
var activeVersions = validDeployments
.Where(d => !d.IsDestroyed)
.Take(minVersionsToKeep)
.ToList();
foreach (var ver in activeVersions)
{
versionsToKeep.Add(ver.Path);
Console.WriteLine($"[DeploymentLocator] Keep recent version: {ver.Path}");
}
// 3. 淇濈暀鏈夊揩鐓х殑鐗堟湰锛堢敤浜庡洖婊氾級
var resolver = new DataLocationResolver(_appRoot);
var snapshotDir = Path.Combine(resolver.ResolveLauncherDataPath(), "snapshots");
if (Directory.Exists(snapshotDir))
{
try
{
var snapshotFiles = Directory.GetFiles(snapshotDir, "*.json", SearchOption.TopDirectoryOnly);
foreach (var snapshotFile in snapshotFiles)
{
try
{
var json = File.ReadAllText(snapshotFile);
var snapshot = System.Text.Json.JsonSerializer.Deserialize(json, AppJsonContext.Default.SnapshotMetadata);
if (snapshot != null && !string.IsNullOrEmpty(snapshot.SourceDirectory))
{
if (Directory.Exists(snapshot.SourceDirectory))
{
versionsToKeep.Add(snapshot.SourceDirectory);
Console.WriteLine($"[DeploymentLocator] Keep version for rollback: {snapshot.SourceDirectory}");
}
}
}
catch
{
// 蹇界暐蹇収瑙f瀽閿欒
}
}
}
catch
{
// 蹇界暐蹇収鐩綍璁块棶閿欒
}
}
// 娓呯悊涓嶉渶瑕佺殑鐗堟湰
foreach (var deployment in validDeployments)
{
if (versionsToKeep.Contains(deployment.Path))
{
if (deployment.IsDestroyed)
{
try
{
File.Delete(Path.Combine(deployment.Path, ".destroy"));
Console.WriteLine($"[DeploymentLocator] Unmarked for deletion (kept): {deployment.Path}");
}
catch
{
// 蹇界暐鍙栨秷鏍囪澶辫触
}
}
continue;
}
if (!deployment.IsDestroyed)
{
try
{
File.WriteAllText(Path.Combine(deployment.Path, ".destroy"), string.Empty);
Console.WriteLine($"[DeploymentLocator] Marked for deletion: {deployment.Path}");
}
catch
{
// 蹇界暐鏍囪澶辫触
}
}
// 灏濊瘯鍒犻櫎
try
{
Directory.Delete(deployment.Path, recursive: true);
Console.WriteLine($"[DeploymentLocator] Deleted: {deployment.Path}");
}
catch
{
// 蹇界暐鍒犻櫎澶辫触(鍙兘鏂囦欢琚崰鐢?,涓嬫鍚姩鍐嶈瘯
Console.WriteLine($"[DeploymentLocator] Failed to delete (will retry later): {deployment.Path}");
}
}
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DeploymentLocator] Cleanup failed: {ex.Message}");
// 蹇界暐娓呯悊澶辫触
}
}
/// <summary>
/// 浠呮竻鐞嗗凡鏍囪涓?destroy鐨勯儴缃诧紙鍏煎鏃ф柟娉曪級
/// </summary>
[Obsolete("Use CleanupOldDeployments instead")]
public void CleanupDestroyedDeployments()
{
CleanupOldDeployments(3);
}
public static Version ParseVersionFromDirectory(string path)
{
var text = ParseVersionTextFromDirectory(path);
return Version.TryParse(text, out var version) ? version : new Version(0, 0, 0);
}
private static string? ParseVersionTextFromDirectory(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return null;
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return null;
}
return segments[1];
}
/// <summary>
/// 浠庨儴缃茬洰褰曡鍙栫増鏈俊鎭? /// </summary>
public AppVersionInfo GetVersionInfo()
{
var executableName = OperatingSystem.IsWindows() ? "LanMountainDesktop.exe" : "LanMountainDesktop";
var resolved = AppVersionProvider.ResolveFromPackageRoot(_appRoot, executableName);
return string.IsNullOrWhiteSpace(resolved.Version)
? new AppVersionInfo
{
Version = GetCurrentVersion(),
Codename = "Administrate"
}
: resolved;
}
}

View File

@@ -0,0 +1,634 @@
using System.Diagnostics;
using System.Text.Json;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 灵活的主程序定位器
/// </summary>
internal sealed class FlexibleHostLocator
{
private readonly HostDiscoveryOptions _options;
private readonly string _appRoot;
private readonly DeploymentLocator _deploymentLocator;
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
{
_appRoot = appRoot;
_options = options ?? new HostDiscoveryOptions();
_deploymentLocator = new DeploymentLocator(appRoot);
}
/// <summary>
/// 解析主程序可执行文件路径
/// </summary>
public string? ResolveHostExecutablePath()
{
var executable = GetExecutableName();
var searchContext = new SearchContext
{
ExecutableName = executable,
AppRoot = _appRoot,
Options = _options
};
// ========== 第一阶段:标准路径查找(快速路径)==========
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
var envPath = GetPathFromEnvironment();
if (!string.IsNullOrWhiteSpace(envPath))
{
var validated = ValidateAndReturn(envPath, "environment variable");
if (validated != null) return validated;
}
// 2. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var deploymentExePath = Path.Combine(deploymentDir, executable);
if (File.Exists(deploymentExePath))
{
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
return deploymentExePath;
}
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
}
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
var deploymentPath = SearchDeploymentDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(deploymentPath))
{
return deploymentPath;
}
// 4. 检查 Launcher 同级目录(便携模式)
var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath))
{
return portablePath;
}
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 5. 检查配置文件中的路径 - 用户自定义配置
var configPath = GetPathFromConfigFile();
if (!string.IsNullOrWhiteSpace(configPath))
{
var validated = ValidateAndReturn(configPath, "config file");
if (validated != null) return validated;
}
// 5. 搜索附近目录(向上、向下各一层)
var nearbyPath = SearchNearbyDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(nearbyPath))
{
return nearbyPath;
}
// 7. 开发模式:检查保存的自定义路径
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
{
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedPath))
{
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
if (validated != null) return validated;
}
}
// 8. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
// 9. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath))
{
return additionalPath;
}
// 10. 递归搜索(如果启用)
if (_options.RecursiveSearch)
{
var recursivePath = SearchRecursively(searchContext);
if (!string.IsNullOrWhiteSpace(recursivePath))
{
return recursivePath;
}
}
return null;
}
/// <summary>
/// 从环境变量获取路径
/// </summary>
private string? GetPathFromEnvironment()
{
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
{
return null;
}
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
return path;
}
/// <summary>
/// 从配置文件获取路径
/// </summary>
private string? GetPathFromConfigFile()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
{
return null;
}
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
if (!File.Exists(configPath))
{
return null;
}
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
}
}
catch
{
// 忽略配置文件读取错误
}
return null;
}
/// <summary>
/// 搜索部署目录
/// </summary>
private string? SearchDeploymentDirectories(SearchContext context)
{
if (!Directory.Exists(_appRoot))
{
return null;
}
try
{
// 查找 app-* 目录
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
.ToList();
// 优先选择带 .current 标记的
var currentMarked = appDirs
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
.Select(dir => Path.Combine(dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
if (currentMarked != null)
{
return currentMarked;
}
// 选择版本号最高的
var latest = appDirs
.Select(dir => new
{
Dir = dir,
Version = ParseVersionFromDirectoryName(dir)
})
.OrderByDescending(x => x.Version)
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
return latest;
}
catch
{
return null;
}
}
/// <summary>
/// 搜索便携模式位置Launcher 同级目录)
/// </summary>
private string? SearchPortableLocation(SearchContext context)
{
try
{
var launcherDir = AppContext.BaseDirectory;
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
if (File.Exists(portablePath))
{
return portablePath;
}
}
catch
{
// 忽略错误
}
return null;
}
/// <summary>
/// 搜索附近目录(灵活查找,适用于各种部署场景)
/// </summary>
private string? SearchNearbyDirectories(SearchContext context)
{
try
{
var searchDirs = new List<string>();
// Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
searchDirs.Add(launcherDir);
// 上级目录
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
searchDirs.Add(parentDir);
}
// 上上级目录
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
if (Directory.Exists(grandparentDir))
{
searchDirs.Add(grandparentDir);
}
// AppRoot 及其上级
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
{
searchDirs.Add(_appRoot);
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
if (Directory.Exists(appParent))
{
searchDirs.Add(appParent);
}
}
// 去重后搜索
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
{
// 直接搜索
var directPath = Path.Combine(dir, context.ExecutableName);
if (File.Exists(directPath))
{
return directPath;
}
// 搜索子目录(一层)
if (Directory.Exists(dir))
{
foreach (var subDir in Directory.GetDirectories(dir))
{
var subPath = Path.Combine(subDir, context.ExecutableName);
if (File.Exists(subPath))
{
return subPath;
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return null;
}
/// <summary>
/// 搜索开发路径
/// </summary>
private string? SearchDevelopmentPaths(SearchContext context)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 动态构建可能的开发路径(支持不同的项目结构)
var possiblePaths = new List<string>();
// 从解决方案根目录搜索(支持不同的解决方案结构)
var solutionRoot = FindSolutionRoot(launcherDir);
if (!string.IsNullOrWhiteSpace(solutionRoot))
{
// 搜索所有可能的 bin 目录
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
}
// 添加硬编码的备用路径
possiblePaths.AddRange(new[]
{
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
});
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 搜索额外的配置路径
/// </summary>
private string? SearchAdditionalPaths(SearchContext context)
{
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
{
return null;
}
foreach (var pattern in _options.AdditionalSearchPaths)
{
try
{
// 替换变量
var expandedPattern = ExpandVariables(pattern);
// 支持通配符
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
{
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
var filePattern = Path.GetFileName(expandedPattern);
if (Directory.Exists(dir))
{
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
var validMatch = matches.FirstOrDefault(File.Exists);
if (validMatch != null)
{
return validMatch;
}
}
}
else if (File.Exists(expandedPattern))
{
return expandedPattern;
}
}
catch
{
// 忽略搜索错误
}
}
return null;
}
/// <summary>
/// 递归搜索
/// </summary>
private string? SearchRecursively(SearchContext context)
{
try
{
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
foreach (var searchDir in searchDirs.Where(Directory.Exists))
{
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略递归搜索错误
}
return null;
}
/// <summary>
/// 递归搜索目录
/// </summary>
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
{
if (depth > _options.MaxRecursionDepth)
{
return null;
}
try
{
// 检查当前目录
var directPath = Path.Combine(dir, executableName);
if (File.Exists(directPath))
{
return directPath;
}
// 检查子目录
foreach (var subDir in Directory.GetDirectories(dir))
{
// 跳过某些目录
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
{
continue;
}
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略访问错误
}
return null;
}
/// <summary>
/// 查找解决方案根目录
/// </summary>
private string? FindSolutionRoot(string startDir)
{
var current = new DirectoryInfo(startDir);
while (current != null)
{
// 查找 .sln 文件
if (current.GetFiles("*.sln").Any())
{
return current.FullName;
}
// 查找 .git 目录作为备选
if (current.GetDirectories(".git").Any())
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
/// <summary>
/// 搜索 bin 目录
/// </summary>
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
{
var results = new List<string>();
try
{
// 查找所有 bin 目录
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
foreach (var binDir in binDirs)
{
// 检查 Debug 和 Release 子目录
var configDirs = new[] { "Debug", "Release" };
foreach (var config in configDirs)
{
var configPath = Path.Combine(binDir, config);
if (Directory.Exists(configPath))
{
// 检查所有 net* 子目录
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
foreach (var fwDir in frameworkDirs)
{
var exePath = Path.Combine(fwDir, executableName);
if (File.Exists(exePath))
{
results.Add(exePath);
}
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return results;
}
/// <summary>
/// 验证路径并返回
/// </summary>
private string? ValidateAndReturn(string path, string source)
{
if (File.Exists(path))
{
Debug.WriteLine($"Found host executable from {source}: {path}");
return path;
}
// 尝试添加 .exeWindows
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
var withExe = path + ".exe";
if (File.Exists(withExe))
{
Debug.WriteLine($"Found host executable from {source}: {withExe}");
return withExe;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}
/// <summary>
/// 获取可执行文件名
/// </summary>
private string GetExecutableName()
{
var name = _options.ExecutableName;
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
name += ".exe";
}
return name;
}
/// <summary>
/// 展开路径变量
/// </summary>
private string ExpandVariables(string path)
{
return path
.Replace("${AppRoot}", _appRoot)
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
}
/// <summary>
/// 从目录名解析版本
/// </summary>
private static Version ParseVersionFromDirectoryName(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return new Version(0, 0, 0);
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return new Version(0, 0, 0);
}
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
}
/// <summary>
/// 搜索上下文
/// </summary>
private class SearchContext
{
public required string ExecutableName { get; set; }
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

View File

@@ -0,0 +1,47 @@
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 主程序发现选项
/// </summary>
public sealed class HostDiscoveryOptions
{
/// <summary>
/// 可执行文件名Windows 下自动添加 .exe
/// </summary>
public string ExecutableName { get; set; } = "LanMountainDesktop";
/// <summary>
/// 额外的搜索路径(支持通配符)
/// </summary>
public List<string> AdditionalSearchPaths { get; set; } = new();
/// <summary>
/// 是否递归搜索子目录
/// </summary>
public bool RecursiveSearch { get; set; } = false;
/// <summary>
/// 递归搜索的最大深度
/// </summary>
public int MaxRecursionDepth { get; set; } = 3;
/// <summary>
/// 环境变量名称,用于指定自定义路径
/// </summary>
public string? CustomPathEnvVar { get; set; } = "LMD_HOST_PATH";
/// <summary>
/// 配置文件路径(相对于 app root
/// </summary>
public string? ConfigFileName { get; set; } = "host-discovery.json";
/// <summary>
/// 是否优先使用开发模式配置
/// </summary>
public bool PreferDevModeConfig { get; set; } = true;
/// <summary>
/// 搜索超时(毫秒)
/// </summary>
public int SearchTimeoutMs { get; set; } = 5000;
}

View File

@@ -0,0 +1,213 @@
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services;
internal sealed record HostLaunchPlan(
string HostPath,
string PackageRoot,
string WorkingDirectory,
IReadOnlyList<string> Arguments,
IReadOnlyDictionary<string, string> EnvironmentVariables,
AppVersionInfo VersionInfo);
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", DataRootOptionName,
LauncherIpcConstants.LauncherPidEnvVar,
LauncherIpcConstants.PackageRootEnvVar,
LauncherIpcConstants.VersionEnvVar,
LauncherIpcConstants.CodenameEnvVar
];
public static HostLaunchPlan Build(
CommandContext context,
DeploymentLocator deploymentLocator,
HostResolutionResult resolution,
string? dataRoot = null)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(deploymentLocator);
ArgumentNullException.ThrowIfNull(resolution);
if (string.IsNullOrWhiteSpace(resolution.ResolvedHostPath))
{
throw new InvalidOperationException("Host path must be resolved before building a launch plan.");
}
var hostPath = Path.GetFullPath(resolution.ResolvedHostPath);
var packageRoot = ResolvePackageRoot(hostPath, resolution.AppRoot, resolution.ResolutionSource);
var versionInfo = deploymentLocator.GetVersionInfo();
var arguments = BuildForwardedArguments(context, packageRoot, versionInfo, dataRoot);
var environment = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[LauncherIpcConstants.LauncherPidEnvVar] = Environment.ProcessId.ToString(),
[LauncherIpcConstants.PackageRootEnvVar] = packageRoot,
[LauncherIpcConstants.VersionEnvVar] = versionInfo.Version,
[LauncherIpcConstants.CodenameEnvVar] = versionInfo.Codename
};
if (!string.IsNullOrWhiteSpace(dataRoot))
{
environment["LMD_DATA_ROOT"] = dataRoot;
}
return new HostLaunchPlan(
hostPath,
packageRoot,
Directory.Exists(packageRoot)
? packageRoot
: Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory,
arguments,
environment,
versionInfo);
}
public static string FormatArgumentsForLog(IReadOnlyList<string> arguments)
{
return string.Join(" ", arguments.Select(QuoteArgument));
}
private static string ResolvePackageRoot(string hostPath, string appRoot, string? resolutionSource)
{
var fullAppRoot = string.IsNullOrWhiteSpace(appRoot)
? AppContext.BaseDirectory
: Path.GetFullPath(appRoot);
var hostDirectory = Path.GetDirectoryName(hostPath);
if (hostDirectory is not null &&
Directory.Exists(fullAppRoot) &&
IsAppDeploymentDirectory(hostDirectory) &&
IsParentOf(fullAppRoot, hostDirectory))
{
return fullAppRoot;
}
if (string.Equals(resolutionSource, "published_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "explicit_app_root_deployment", StringComparison.OrdinalIgnoreCase) ||
string.Equals(resolutionSource, "legacy_fallback", StringComparison.OrdinalIgnoreCase))
{
return fullAppRoot;
}
return hostDirectory ?? fullAppRoot;
}
private static IReadOnlyList<string> BuildForwardedArguments(
CommandContext context,
string packageRoot,
AppVersionInfo versionInfo,
string? dataRoot = null)
{
var arguments = new List<string>();
for (var index = 0; index < context.RawArgs.Count; index++)
{
var arg = context.RawArgs[index];
if (index == 0 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.Command, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (index == 1 &&
!arg.StartsWith("--", StringComparison.Ordinal) &&
string.Equals(arg, context.SubCommand, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (arg.StartsWith("--", StringComparison.Ordinal))
{
var key = arg[2..];
var equalsIndex = key.IndexOf('=');
if (equalsIndex >= 0)
{
key = key[..equalsIndex];
}
if (LauncherOnlyOptions.Contains(key, StringComparer.OrdinalIgnoreCase))
{
if (equalsIndex < 0 &&
index + 1 < context.RawArgs.Count &&
!context.RawArgs[index + 1].StartsWith("--", StringComparison.Ordinal))
{
index++;
}
continue;
}
}
arguments.Add(arg);
}
arguments.Add($"--{LauncherIpcConstants.LauncherPidEnvVar}={Environment.ProcessId}");
arguments.Add($"--{LauncherIpcConstants.PackageRootEnvVar}={packageRoot}");
arguments.Add($"--{LauncherIpcConstants.VersionEnvVar}={versionInfo.Version}");
arguments.Add($"--{LauncherIpcConstants.CodenameEnvVar}={versionInfo.Codename}");
if (!string.IsNullOrWhiteSpace(dataRoot))
{
arguments.Add($"--{DataRootOptionName}={dataRoot}");
}
return arguments;
}
private static bool IsAppDeploymentDirectory(string path)
{
var fileName = Path.GetFileName(Path.TrimEndingDirectorySeparator(path));
return fileName.StartsWith("app-", StringComparison.OrdinalIgnoreCase);
}
private static bool IsParentOf(string parent, string child)
{
var parentPath = Path.GetFullPath(parent).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var childPath = Path.GetFullPath(child).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
if (string.Equals(parentPath, childPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return childPath.StartsWith(
parentPath + Path.DirectorySeparatorChar,
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new System.Text.StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
}

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.Launcher.Services;
internal sealed class HostResolutionResult
{
public bool Success { get; init; }
public string? ResolvedHostPath { get; init; }
public string? ResolutionSource { get; init; }
public string AppRoot { get; init; } = string.Empty;
public string? ExplicitAppRoot { get; init; }
public bool DevModeConfigIgnored { get; init; }
public List<string> SearchedPaths { get; init; } = [];
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface IOobeStep
{
Task RunAsync(CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.Launcher.Services;
internal interface ISplashStageReporter
{
void Report(string stage, string message);
/// <summary>
/// 报告阶段和进度0-100
/// </summary>
void ReportStage(string stage, int progress);
}

View File

@@ -0,0 +1,9 @@
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services;
public interface IUpdateProgressReporter
{
void ReportProgress(InstallProgressReport report);
void ReportComplete(InstallCompleteReport report);
}

View File

@@ -0,0 +1,111 @@
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcClient
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
public async Task<LauncherCoordinatorResponse?> SendAsync(
string pipeName,
LauncherCoordinatorRequest request,
TimeSpan timeout)
{
if (string.IsNullOrWhiteSpace(pipeName))
{
return null;
}
using var timeoutCts = new CancellationTokenSource(timeout);
try
{
await using var client = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
await WriteRequestAsync(client, request, timeoutCts.Token).ConfigureAwait(false);
return await ReadResponseAsync(client, timeoutCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (TimeoutException)
{
return null;
}
catch (Exception ex)
{
Logger.Warn($"Failed to send launcher coordinator IPC request: {ex.Message}");
return null;
}
}
private static async Task WriteRequestAsync(
Stream stream,
LauncherCoordinatorRequest request,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(request, AppJsonContext.Default.LauncherCoordinatorRequest);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<LauncherCoordinatorResponse?> ReadResponseAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorResponse);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -0,0 +1,235 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO.Pipes;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherCoordinatorIpcServer : IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
private readonly string _pipeName;
private readonly Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> _requestHandler;
private readonly Action<LauncherCoordinatorStatus> _heartbeatHandler;
private readonly CancellationTokenSource _cts = new();
private readonly object _statusGate = new();
private LauncherCoordinatorStatus _status;
private Task? _listenTask;
private Task? _heartbeatTask;
public LauncherCoordinatorIpcServer(
string pipeName,
LauncherCoordinatorStatus initialStatus,
Func<LauncherCoordinatorRequest, LauncherCoordinatorStatus, Task<LauncherCoordinatorResponse>> requestHandler,
Action<LauncherCoordinatorStatus> heartbeatHandler)
{
_pipeName = pipeName;
_status = initialStatus;
_requestHandler = requestHandler;
_heartbeatHandler = heartbeatHandler;
}
public static string CreatePipeName()
{
var seed = $"{Environment.UserName}:{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}";
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(seed.ToLowerInvariant()));
return $"LanMountainDesktop_Launcher_Coordinator_{Convert.ToHexString(bytes[..8])}";
}
public void Start()
{
_listenTask ??= Task.Run(ListenLoopAsync);
_heartbeatTask ??= Task.Run(HeartbeatLoopAsync);
}
public LauncherCoordinatorStatus GetStatus()
{
lock (_statusGate)
{
return _status;
}
}
public void UpdateStatus(LauncherCoordinatorStatus status)
{
lock (_statusGate)
{
_status = status;
}
}
public void Dispose()
{
_cts.Cancel();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(1));
_heartbeatTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
}
_cts.Dispose();
}
private async Task ListenLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
NamedPipeServerStream? server = null;
try
{
server = new NamedPipeServerStream(
_pipeName,
PipeDirection.InOut,
8,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
var connectedServer = server;
_ = Task.Run(() => HandleConnectionAsync(connectedServer, _cts.Token), _cts.Token);
server = null;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC listener failed: {ex.Message}");
try
{
await Task.Delay(250, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
server?.Dispose();
}
}
}
private async Task HeartbeatLoopAsync()
{
while (!_cts.IsCancellationRequested)
{
try
{
_heartbeatHandler(GetStatus());
await Task.Delay(TimeSpan.FromSeconds(2), _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator heartbeat failed: {ex.Message}");
}
}
}
private async Task HandleConnectionAsync(NamedPipeServerStream server, CancellationToken cancellationToken)
{
try
{
var request = await ReadRequestAsync(server, cancellationToken).ConfigureAwait(false);
var status = GetStatus();
var response = request is null
? new LauncherCoordinatorResponse
{
Accepted = false,
Code = "invalid_request",
Message = "Launcher coordinator request was invalid.",
Status = status
}
: await _requestHandler(request, status).ConfigureAwait(false);
await WriteResponseAsync(server, response, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"Launcher coordinator IPC request failed: {ex.Message}");
}
finally
{
try
{
server.Dispose();
}
catch
{
}
}
}
private static async Task<LauncherCoordinatorRequest?> ReadRequestAsync(
Stream stream,
CancellationToken cancellationToken)
{
var lengthBuffer = new byte[LengthPrefixSize];
if (!await ReadExactAsync(stream, lengthBuffer, cancellationToken).ConfigureAwait(false))
{
return null;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return null;
}
var payload = new byte[payloadLength];
if (!await ReadExactAsync(stream, payload, cancellationToken).ConfigureAwait(false))
{
return null;
}
return JsonSerializer.Deserialize(
Encoding.UTF8.GetString(payload),
AppJsonContext.Default.LauncherCoordinatorRequest);
}
private static async Task WriteResponseAsync(
Stream stream,
LauncherCoordinatorResponse response,
CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(response, AppJsonContext.Default.LauncherCoordinatorResponse);
var payload = Encoding.UTF8.GetBytes(json);
await stream.WriteAsync(BitConverter.GetBytes(payload.Length), cancellationToken).ConfigureAwait(false);
await stream.WriteAsync(payload, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> ReadExactAsync(
Stream stream,
byte[] buffer,
CancellationToken cancellationToken)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var read = await stream
.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
return false;
}
totalRead += read;
}
return true;
}
}

View File

@@ -0,0 +1,192 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Launcher;
namespace LanMountainDesktop.Launcher.Services.Ipc;
/// <summary>
/// Launcher IPC 服务端 - 接收主程序的启动进度报告
/// 采用持久连接 + 长度前缀协议,支持客户端在同一连接上多次发送消息。
/// 跨平台实现Windows 使用命名管道Linux/macOS 使用 Unix 域套接字
/// </summary>
public class LauncherIpcServer : IDisposable
{
private readonly CancellationTokenSource _cts = new();
private readonly Action<StartupProgressMessage> _onProgress;
private Task? _listenTask;
private NamedPipeServerStream? _currentPipe;
/// <summary>
/// 协议:每条消息以 4 字节小端 int32 长度前缀开头,后跟 UTF-8 JSON 正文。
/// 这在 Windows Message 模式和 Unix Byte 模式下均能可靠工作。
/// </summary>
private const int LengthPrefixSize = 4;
public LauncherIpcServer(Action<StartupProgressMessage> onProgress)
{
_onProgress = onProgress;
}
/// <summary>
/// 启动 IPC 服务端监听
/// </summary>
public void Start()
{
_listenTask = Task.Run(ListenLoopAsync, _cts.Token);
}
private async Task ListenLoopAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
NamedPipeServerStream? pipe = null;
try
{
pipe = new NamedPipeServerStream(
LauncherIpcConstants.PipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte);
_currentPipe = pipe;
await pipe.WaitForConnectionAsync(_cts.Token);
// 持久连接:在同一连接上循环读取多条消息,直到客户端断开
await ReadMessagesFromConnectionAsync(pipe, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
catch (IOException)
{
// 客户端断开连接,继续等待新连接
continue;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
Console.Error.WriteLine($"IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token);
}
catch (OperationCanceledException)
{
break;
}
}
finally
{
try
{
pipe?.Dispose();
}
catch { }
if (ReferenceEquals(_currentPipe, pipe))
{
_currentPipe = null;
}
}
}
}
/// <summary>
/// 从已连接的管道中持续读取消息,直到连接断开或取消
/// </summary>
private async Task ReadMessagesFromConnectionAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken)
{
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
try
{
while (pipe.IsConnected && !cancellationToken.IsCancellationRequested)
{
// 1. 读取 4 字节长度前缀
var totalRead = 0;
while (totalRead < LengthPrefixSize)
{
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), cancellationToken);
if (read == 0)
{
// 连接已关闭
return;
}
totalRead += read;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > 1024 * 1024) // 最大 1MB 单条消息
{
// 无效长度,跳过此连接
return;
}
// 2. 读取消息正文
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
try
{
totalRead = 0;
while (totalRead < payloadLength)
{
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), cancellationToken);
if (read == 0)
{
return;
}
totalRead += read;
}
// 3. 反序列化并回调
var json = System.Text.Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var message = JsonSerializer.Deserialize(json, AppJsonContext.Default.StartupProgressMessage);
if (message is not null)
{
_onProgress(message);
}
}
catch (JsonException)
{
// 忽略解析错误,继续读取下一条消息
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
/// <summary>
/// 停止 IPC 服务端
/// </summary>
public void Stop()
{
_cts.Cancel();
try
{
_currentPipe?.Dispose();
}
catch { }
}
public void Dispose()
{
Stop();
_cts.Dispose();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch { }
}
}

View File

@@ -0,0 +1,132 @@
using System.Buffers;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Launcher.Services.Ipc;
internal sealed class LauncherUpdateProgressIpcServer : IUpdateProgressReporter, IDisposable
{
private const int LengthPrefixSize = 4;
private readonly string _pipeName;
private readonly CancellationTokenSource _cts = new();
private NamedPipeServerStream? _pipe;
private Task? _listenTask;
private volatile bool _clientConnected;
public LauncherUpdateProgressIpcServer(int launcherPid)
{
_pipeName = $"LanMountainDesktop_Update_{launcherPid}";
}
public string PipeName => _pipeName;
public void Start()
{
_listenTask = Task.Run(AcceptConnectionAsync, _cts.Token);
}
private async Task AcceptConnectionAsync()
{
while (!_cts.Token.IsCancellationRequested)
{
try
{
_pipe = new NamedPipeServerStream(
_pipeName,
PipeDirection.Out,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await _pipe.WaitForConnectionAsync(_cts.Token).ConfigureAwait(false);
_clientConnected = true;
return;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Logger.Warn($"Update progress IPC listen error: {ex.Message}");
try
{
await Task.Delay(200, _cts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
public void ReportProgress(InstallProgressReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallProgressReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report progress via IPC: {ex.Message}");
}
}
public void ReportComplete(InstallCompleteReport report)
{
if (!_clientConnected || _pipe is null || !_pipe.IsConnected)
{
return;
}
try
{
WriteMessage(_pipe, JsonSerializer.Serialize(report, AppJsonContext.Default.InstallCompleteReport));
}
catch (Exception ex)
{
Logger.Warn($"Failed to report completion via IPC: {ex.Message}");
}
}
private static void WriteMessage(Stream stream, string json)
{
var payload = Encoding.UTF8.GetBytes(json);
var lengthPrefix = BitConverter.GetBytes(payload.Length);
stream.Write(lengthPrefix, 0, LengthPrefixSize);
stream.Write(payload, 0, payload.Length);
stream.Flush();
}
public void Dispose()
{
_cts.Cancel();
try
{
_pipe?.Dispose();
}
catch
{
}
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
_cts.Dispose();
}
}

Some files were not shown because too many files have changed in this diff Show More