Compare commits

..

29 Commits

Author SHA1 Message Date
lincube
12f0caafc7 fix.继续修复 .NET运行时问题 2026-05-25 01:24:18 +08:00
lincube
fd3a193e68 fix.正在修复 .NET运行时问题 2026-05-24 23:12:48 +08:00
lincube
edf3d82cc9 feat.圆角体系加入Fluent,设置页面全面使用Fluent Design System 2026-05-23 17:33:06 +08:00
lincube
e1adba3771 Create 20260523_ac8ee8d.md 2026-05-23 14:23:29 +08:00
lincube
ac8ee8dc54 changed.优化了天气组件 2026-05-23 02:49:01 +08:00
lincube
cc1c040203 changed.天气选项卡更新 2026-05-18 19:43:15 +08:00
lincube
68dc17f863 feat.发布与打包优化 2026-05-18 14:51:05 +08:00
lincube
b6d820a320 feat.完善了时钟轻应用,为启动器提供了多语言支持 2026-05-18 12:26:23 +08:00
lincube
93758fc083 feat.数字时钟,白板功能修复 2026-05-18 08:30:40 +08:00
lincube
9404a0b347 feat.动画优化与更新界面 2026-05-17 19:36:07 +08:00
lincube
a5abda62dc feat.airapp与融合桌面 2026-05-14 19:44:01 +08:00
lincube
ada0cd4a3a change.重做天气,为回到系统提供自定义功能。 2026-05-13 07:42:42 +08:00
lincube
b48056391a fix.消息盒子媒体播放器组件服务修复 2026-05-12 18:49:04 +08:00
lincube
33c264f6dd changed.更了好多 2026-05-12 16:46:49 +08:00
lincube
563f12caa1 Add install checkpoint/resume and DDSS workflows
Introduce install checkpoint support and resume logic for updates, plus related locking and validation. Adds InstallCheckpoint model, AppJsonContext serialization, and UpdatePaths helpers for deployment lock, apply-in-progress lock and install-checkpoint path. UpdateEngineService gains checkpoint load/save/delete, incoming-state validation, resume logic for PLONDS and legacy updates, apply lock handling, and safer cleanup; ApplyPendingPlondsUpdateAsync and ApplyPendingUpdate flow updated accordingly. Add DeploymentLock contract and extend UpdateState with pause/resume/cancel helpers. Tests updated to cover stale/valid checkpoint resume and legacy/PLONDS flows. CI: enhance ddss-publish to detect release channel, validate S3 assets, prepare and atomically publish channel pointer; add ddss-rollback workflow to publish rollbacks; adjust plonds-build concurrency and release events.
2026-05-12 08:35:48 +08:00
lincube
f0319b7deb Add preview controls and settings UI tweaks
Introduce GridPreviewControl and CornerRadiusPreviewControl for visual previews and wire them into the Components settings (add ScreenAspectRatio, CornerRadiusPreviewValue, and screen aspect init). Refactor ComponentsSettingsPage UI to show live previews. Improve DataSettingsPage layout and storage bar logic (use item percentages directly, include remaining segment, adjust visuals and visibility triggers). Simplify LauncherSettingsPage header/appearance layout. Add SECURITY_AUDIT_REPORT.md, analysis summary, mockup HTML, and a local .claude settings file.
2026-05-11 18:06:36 +08:00
lincube
d8f75e86be Add IPC backoff/retries and safer disposal
Introduce exponential backoff, jitter and retry logic across IPC components to improve robustness and avoid tight retry loops; make disposal idempotent and add connection guards. Key changes:
- LauncherCoordinatorIpcServer / LauncherIpcServer: add backoff constants, ComputeBackoff(), consecutive error tracking and delayed retries with jitter.
- LanMountainDesktopIpcClient / LauncherIpcClient: add connect retry loops, timeouts, delayed retries, improved error logging, and use ArrayPool for buffered async writes; ensure proper cleanup on failures.
- PublicIpcHostService: add disposed flag, guard OnPeerConnected and Dispose, and clear connected peers on dispose.
- Add many auto-generated commit analysis docs under docs/auto_commit_md and new scripts for analyzing/generating commit docs.
These changes aim to make IPC connection handling more resilient and resource-safe.
2026-05-07 21:39:21 +08:00
lincube
84caca02bf Add Data settings page and storage scanner
Introduce a new "Data" settings page to visualize and manage local app storage. Adds DataStorageService (scanning, disk info, clean operations), DataSettingsPageViewModel, XAML view and code-behind, and HexToColor/HexToBrush converters; registers converters in App.axaml. Also update localization strings for the new page and add icon mapping so the settings entry uses the Database icon. Enables per-category and global cleaning workflows and formatted size display.
2026-05-07 16:34:31 +08:00
lincube
aa7e15d967 Add CODE_WIKI and update localization
Add a comprehensive CODE_WIKI.md documenting project architecture, modules, startup flow, plugin system, testing and developer workflows. Update localization resources (en-US.json, zh-CN.json) with new/translated keys for wallpaper controls (custom color UI), material & color settings (semantic roles, surfaces, refresh/polling state), appearance (corner radius), status bar font size options, privacy policy text, component library labels, clock settings, and new language entry (Korean). Also modify settings-related ViewModels and Settings page views to surface these new features and texts (MaterialColorSettingsPageViewModel.cs, SettingsViewModels.cs, WallpaperSettingsPageViewModel.cs, MainWindow.SettingsHardCut.Stubs.cs, ComponentsSettingsPage.axaml, WallpaperSettingsPage.axaml).
2026-05-07 01:07:15 +08:00
lincube
6b1c738d8c Add material color services, plugin DTOs, and tests
Introduce IPC wire-format appearance DTOs (PluginIsolation.Contracts) and clarify they are distinct from the runtime PluginSdk snapshot. Update PluginSdk comments to document the runtime-facing snapshot shape. Change ComponentColorSchemeHelper to use the HostMaterialColorProvider and add an overload that accepts a MaterialColorSnapshot. Add new services and pipelines (MaterialColorService, MaterialSurfaceService, WindowMaterialService, WallpaperColorPipeline) and refactor AppearanceThemeService to depend on MaterialColorService while removing legacy internal implementations. Add multiple unit tests (ComponentColorSchemeHelper, PluginAppearanceBoundary, SettingsCatalogService, WallpaperSettingsPageViewModel) and update localization resources with new material_color and wallpaper keys.
2026-05-06 19:33:08 +08:00
lincube
f8a4bb888c Use MaterialColorSnapshot in appearance flow
Introduce unified material/color spec and tests, and refactor appearance plumbing to use MaterialColorSnapshot as the single source of truth. Add .trae material-color-service spec/checklist/tasks and integration/unit tests for plugin mapping and appearance VM behavior. AppearanceChangedEvent extended with new appearance change flags and HasChanged logic. ComponentEditorMaterialThemeAdapter rewritten to accept MaterialColorSnapshot and derive palette from snapshot data. Simplify AppearanceSettingsPageViewModel and related view code: remove legacy preview/custom-seed UI logic, preserve material/color fields when updating theme or corner radius, and update save calls to use with-expressions. Update ComponentEditorWindow to use adapter-provided OnPrimary brush and minor docs updates.
2026-05-06 16:02:55 +08:00
lincube
b71687cecd Introduce render gate and chart caching
Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout.
2026-05-06 16:00:45 +08:00
lincube
68ca532dc0 Move whiteboard persistence to file storage
Switch whiteboard note storage from legacy DB rows to per-note JSON files and add migration support. Update WhiteboardNoteSnapshot schema (version bump, viewport, canvas, expires, PathSvgData) and change IWhiteboardNotePersistenceService.SaveNote to return bool to surface write failures (e.g. read-only files). Implement file-based WhiteboardNotePersistenceService with legacy DB migration/cleanup, retention handling, and logging. Add comprehensive unit tests for persistence, stroke path builder, SVG import and viewport helper. Also add ThirdParty/DotNetCampus.InkCanvas project and reference it in the main csproj, and bump PostHog package to 2.6.0.
2026-05-06 00:45:33 +08:00
lincube
60e7f31ba7 Add OOBE startup presentation and settings merge
Introduce a new OOBE step for "Startup & Presentation" that exposes startup and UI preferences in OobeWindow (toggles for taskbar, slide/fade transitions, fused popup, and autostart). Add HostAppSettingsOobeMerger to read/write Host settings.json (PascalCase fields) and MergeStartupPresentation behavior, plus LauncherWindowsStartupService to sync the current Launcher into the Windows Run key on Windows. Wire UI handlers, persist choices on Next, and load defaults when entering the step. Include unit tests for the merger, adjust SettingsWindow navigation pane/toggle handling, and update docs/LAUNCHER.md to describe the new OOBE step and implementation files.
2026-05-04 11:22:21 +08:00
lincube
574b798092 fix.修折叠与展开按钮 2026-05-04 04:50:35 +08:00
lincube
49bbae29af Redesign settings window with fluent shell & search
Rebuild the settings window as a Fluent shell: adds a custom 48-DIP titlebar with Back, pane toggle, icon/title, search box, restart/more menu, and caption-button spacer; moves compact pane toggle into the titlebar and preserves FANavigationView as the primary navigation surface. Introduces a SettingsSearchService (with UI AutoComplete integration, search indexing, navigation-by-result, and search result highlighting) plus focused tests for search filtering and theme material normalization. Adds navigation history/back stack, updates SettingsViewModels for new bindings and localization keys, and updates General/Apearance pages to expose new strings and options. Implements an "auto" system material mode: default in AppSettingsSnapshot, new MaterialAuto constants and normalization/resolution logic in ThemeAppearanceValues, WindowMaterialService and MaterialSurfaceService adjustments to prefer Mica on Win11 and Acrylic on Win10 using TransparencyLevelHint. GlassEffectService and AppearanceThemeService updated to use effective material mode and to track live theme state changes. Adds localization entries (en-US, zh-CN), spec/tasks docs, and other UI/style tweaks to support the redesign.
2026-05-04 04:46:12 +08:00
lincube
1d7df5a105 Add localization and localize settings pages
Add many new localization keys (en-US and zh-CN) for notifications, developer tools, about page, status bar, and video wallpaper. Update Notification, Dev, About and StatusBar view models to use LocalizationService, expose localized ObservableProperties, and refresh localized text at construction. Localize selection options and test notification texts, and fix notification severity handling. Wire up XAML to the new localized properties (About/Dev/StatusBar pages) and update the settings page title for notifications. Also adjust copyright line generation and replace hardcoded placeholders with bound Watermark properties.
2026-05-04 04:09:51 +08:00
lincube
6a30bc6fce Refactor settings window UI and theming
Improve theming and layout for the Settings window and related services.

- MaterialSurfaceService: add special material parameters for SettingsWindowBackground (lower alpha, no blur) and avoid hot-switching real backdrops for non-settings windows.
- GlassEffectService: add AdaptiveSettingsWindowTintBrush + ResolveSettingsWindowTintAlpha to provide optional content tinting tied to system material mode.
- SettingsWindowService: refactor theme application into ApplyThemeVariantAndResources, ensure settings window material is applied at show/activate times, and tidy theme/resource application flow.
- SettingsWindow.axaml / .axaml.cs: restructure title bar (separate Grid.Row=0 border) and FANavigationView host, add pane-footer toggle button for :minimal layout, use dynamic corner radius resource, and update toggle/visibility/icon logic and responsive layout code.
- SettingsPages: remove some IconText usages and adjust margins; use DesignCornerRadiusLg for update card corner radius.
- Add NuGet.Config to set local globalPackagesFolder and ignore .nuget/packages in .gitignore.

These changes aim to improve visuals, avoid backdrop overdraw, and make the settings window behavior consistent across themes and layouts.
2026-05-04 03:19:25 +08:00
lincube
3a8516334a Add Windows system chrome patchers (Harmony)
Introduce support for toggling the system chrome on Windows using Harmony patchers. Adds Lib.Harmony.Thin to package props and project, new patcher infrastructure (ChromePatchState, PatcherEntrance) and two Harmony patches that disable FluentAvalonia's Windows chrome when configured. Program.cs now loads the chrome setting and installs patchers conditionally on Windows/x86-x64. Settings viewmodel and view updated: expose IsWindowsOs, require restart on appearance changes, migrate SettingsWindow to FAAppWindow and adapt titlebar/layout (include Windows caption placeholder and footer menu items). Also add a .gitkeep and a build log file.
2026-05-04 02:31:25 +08:00
37 changed files with 2075 additions and 207 deletions

View File

@@ -98,10 +98,8 @@ jobs:
matrix: matrix:
include: include:
- arch: x64 - arch: x64
self_contained: true
suffix: '' suffix: ''
- arch: x86 - arch: x86
self_contained: true
suffix: '' suffix: ''
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }} name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
@@ -167,30 +165,14 @@ jobs:
- name: Publish Main App - name: Publish Main App
run: | run: |
$selfContained = "${{ matrix.self_contained }}" -eq "true" $publishDir = "publish/windows-${{ matrix.arch }}"
$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:SkipAirAppHostBuild=true `
-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 ` dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release ` -c Release `
-o ./$publishDir ` -o ./$publishDir `
--self-contained:false ` --self-contained:false `
-r win-${{ matrix.arch }} `
-p:SelfContained=false `
-p:PublishSingleFile=false ` -p:PublishSingleFile=false `
-p:DebugType=none ` -p:DebugType=none `
-p:DebugSymbols=false ` -p:DebugSymbols=false `
@@ -201,21 +183,19 @@ jobs:
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
}
shell: pwsh shell: pwsh
- name: Publish AirAppHost - name: Publish AirAppHost
run: | run: |
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true" $publishDir = "publish/windows-$arch"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
if ($selfContained) {
dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj ` dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
-c Release ` -c Release `
-o ./$publishDir ` -o ./$publishDir `
--self-contained:false ` --self-contained:false `
-r win-$arch ` -r win-$arch `
-p:SelfContained=false `
-p:PublishSingleFile=false ` -p:PublishSingleFile=false `
-p:DebugType=none ` -p:DebugType=none `
-p:DebugSymbols=false ` -p:DebugSymbols=false `
@@ -227,31 +207,13 @@ jobs:
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} ` -p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }} -p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
} else {
dotnet publish LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj `
-c Release `
-o ./$publishDir `
--self-contained:false `
-p:PublishSingleFile=false `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false `
-p:BuildingAirAppHost=true `
-p:SkipAirAppHostBuild=true `
-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 shell: pwsh
- name: Restructure for Launcher - name: Restructure for Launcher
run: | run: |
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true" $publishDir = "publish/windows-$arch"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$launcherPublishDir = "publish/launcher-win-$arch" $launcherPublishDir = "publish/launcher-win-$arch"
$appDir = "app-$version" $appDir = "app-$version"
$newStructure = "publish-launcher/windows-$arch" $newStructure = "publish-launcher/windows-$arch"
@@ -274,8 +236,7 @@ jobs:
- name: Optimize and Guard Windows Payload - name: Optimize and Guard Windows Payload
run: | run: |
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true" $publishDir = "publish/windows-$arch"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 ` ./LanMountainDesktop/scripts/Optimize-PublishPayload.ps1 `
-PublishDir $publishDir ` -PublishDir $publishDir `
@@ -294,8 +255,7 @@ jobs:
$version = "${{ needs.prepare.outputs.version }}" $version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}" $arch = "${{ matrix.arch }}"
$suffix = "${{ matrix.suffix }}" $suffix = "${{ matrix.suffix }}"
$selfContained = "${{ matrix.self_contained }}" -eq "true" $publishDir = "publish/windows-$arch"
$publishDir = if ($selfContained) { "publish/windows-$arch" } else { "publish/windows-$arch-lite" }
$outputDir = "build-installer" $outputDir = "build-installer"
$installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss" $installerScript = "LanMountainDesktop/installer/LanMountainDesktop.iss"
@@ -329,7 +289,6 @@ jobs:
"/DMyOutputDir=$outputDir", "/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch", "/DMyAppArch=$arch",
"/DMyAppSuffix=$suffix", "/DMyAppSuffix=$suffix",
"/DIsSelfContained=$selfContained",
$installerScript $installerScript
) )

View File

@@ -0,0 +1,5 @@
# Runtime Packaging Fix Checklist
- [x] `dotnet build LanMountainDesktop.slnx -c Debug -v minimal` succeeds.
- [x] Runtime probe, AirAppHost startup, and packaging policy tests pass.
- [ ] Full `win-x64` package dry run completes without timeout.

View File

@@ -0,0 +1,12 @@
# Runtime Packaging Fix
Windows releases use the launcher as the only self-contained bootstrapper. The
desktop host and AirAppHost are framework-dependent and rely on an
architecture-matched .NET 10 Desktop Runtime installed by the Inno setup flow.
Acceptance:
- Windows installer payload does not bundle .NET shared runtime files.
- Inno Setup downloads and silently installs the matching .NET 10 Desktop Runtime.
- Launcher blocks framework-dependent host startup with `dotnet_runtime_missing` when the runtime is unavailable.
- AirAppHost startup uses packaged executables or an explicit architecture-matched dotnet host for DLL fallback.

View File

@@ -0,0 +1,7 @@
# Runtime Packaging Fix Tasks
- [x] Add launcher-side .NET runtime probe and host startup guard.
- [x] Update AirAppHost process start behavior for packaged exe and DLL fallback.
- [x] Update Windows packaging scripts and CI release workflow.
- [x] Update Inno Setup prerequisite download/install flow.
- [x] Add regression tests and runtime packaging documentation.

View File

@@ -38,6 +38,15 @@ public static class AppearanceCornerRadiusTokenFactory
Xl: new CornerRadius(40), Xl: new CornerRadius(40),
Island: new CornerRadius(44), Island: new CornerRadius(44),
Component: new CornerRadius(32)), Component: new CornerRadius(32)),
GlobalAppearanceSettings.CornerRadiusStyleFluent => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(2),
Xs: new CornerRadius(4),
Sm: new CornerRadius(4),
Md: new CornerRadius(8),
Lg: new CornerRadius(8),
Xl: new CornerRadius(12),
Island: new CornerRadius(16),
Component: new CornerRadius(8)),
// Balanced (default) // Balanced (default)
_ => new AppearanceCornerRadiusTokens( _ => new AppearanceCornerRadiusTokens(
Micro: new CornerRadius(6), Micro: new CornerRadius(6),

View File

@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using LanMountainDesktop.Launcher.Services;
namespace LanMountainDesktop.Launcher.Services.AirApp; namespace LanMountainDesktop.Launcher.Services.AirApp;
@@ -13,17 +14,20 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
private readonly Func<string?> _packageRootProvider; private readonly Func<string?> _packageRootProvider;
private readonly Func<string?> _hostPathProvider; private readonly Func<string?> _hostPathProvider;
private readonly Func<string?> _dataRootProvider; private readonly Func<string?> _dataRootProvider;
private readonly DotNetRuntimeProbeOptions? _runtimeProbeOptions;
public AirAppProcessStarter( public AirAppProcessStarter(
AirAppHostLocator locator, AirAppHostLocator locator,
Func<string?> packageRootProvider, Func<string?> packageRootProvider,
Func<string?> hostPathProvider, Func<string?> hostPathProvider,
Func<string?> dataRootProvider) Func<string?> dataRootProvider,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
{ {
_locator = locator; _locator = locator;
_packageRootProvider = packageRootProvider; _packageRootProvider = packageRootProvider;
_hostPathProvider = hostPathProvider; _hostPathProvider = hostPathProvider;
_dataRootProvider = dataRootProvider; _dataRootProvider = dataRootProvider;
_runtimeProbeOptions = runtimeProbeOptions;
} }
public Process? Start( public Process? Start(
@@ -34,22 +38,7 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
string? sourcePlacementId) string? sourcePlacementId)
{ {
var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider()); var hostPath = _locator.Resolve(_packageRootProvider(), _hostPathProvider());
var startInfo = new ProcessStartInfo var startInfo = CreateStartInfo(hostPath, _runtimeProbeOptions);
{
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows() &&
string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
{
startInfo.FileName = hostPath;
}
else
{
startInfo.FileName = "dotnet";
startInfo.ArgumentList.Add(hostPath);
}
AddArgument(startInfo, "--app-id", appId); AddArgument(startInfo, "--app-id", appId);
AddArgument(startInfo, "--session-id", sessionId); AddArgument(startInfo, "--session-id", sessionId);
@@ -94,6 +83,53 @@ internal sealed class AirAppProcessStarter : IAirAppProcessStarter
return process; return process;
} }
internal static ProcessStartInfo CreateStartInfo(
string hostPath,
DotNetRuntimeProbeOptions? runtimeProbeOptions = null)
{
var startInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(hostPath) ?? AppContext.BaseDirectory
};
if (OperatingSystem.IsWindows())
{
if (string.Equals(Path.GetExtension(hostPath), ".exe", StringComparison.OrdinalIgnoreCase))
{
if (DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(hostPath))
{
var executableRuntime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
if (!executableRuntime.IsAvailable)
{
throw new InvalidOperationException(
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
executableRuntime.Message);
}
}
startInfo.FileName = hostPath;
return startInfo;
}
var runtime = DotNetRuntimeProbe.Probe(runtimeProbeOptions);
if (!runtime.IsAvailable || string.IsNullOrWhiteSpace(runtime.DotNetHostPath))
{
throw new InvalidOperationException(
"Unable to start AirAppHost because the architecture-matched .NET 10 runtime was not found. " +
runtime.Message);
}
startInfo.FileName = runtime.DotNetHostPath;
startInfo.ArgumentList.Add(hostPath);
return startInfo;
}
startInfo.FileName = "dotnet";
startInfo.ArgumentList.Add(hostPath);
return startInfo;
}
private static void AddArgument(ProcessStartInfo startInfo, string name, string value) private static void AddArgument(ProcessStartInfo startInfo, string name, string value)
{ {
startInfo.ArgumentList.Add(name); startInfo.ArgumentList.Add(name);

View File

@@ -0,0 +1,401 @@
using System.Diagnostics;
using System.Runtime.InteropServices;
using Microsoft.Win32;
namespace LanMountainDesktop.Launcher.Services;
internal enum DotNetRuntimeArchitecture
{
X64,
X86
}
internal sealed record DotNetRuntimeInfo(
string Name,
string Version,
string Source,
string? Location);
internal sealed record DotNetRuntimeProbeOptions
{
public int RequiredMajorVersion { get; init; } = 10;
public DotNetRuntimeArchitecture Architecture { get; init; } = DotNetRuntimeProbe.GetCurrentArchitecture();
public string? ProgramFilesPath { get; init; }
public string? ProgramFilesX86Path { get; init; }
public string? LocalAppDataPath { get; init; }
public IReadOnlyList<string>? DotNetHostCandidates { get; init; }
public bool IncludeRegistry { get; init; } = true;
public bool IncludeDotNetCli { get; init; } = true;
}
internal sealed record DotNetRuntimeProbeResult(
bool IsAvailable,
int RequiredMajorVersion,
DotNetRuntimeArchitecture Architecture,
string? DotNetHostPath,
IReadOnlyList<string> SearchedPaths,
IReadOnlyList<DotNetRuntimeInfo> DetectedRuntimes,
string Message)
{
public Dictionary<string, string> ToDetails(string prefix = "dotnetRuntime")
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[$"{prefix}Available"] = IsAvailable.ToString(),
[$"{prefix}RequiredMajorVersion"] = RequiredMajorVersion.ToString(),
[$"{prefix}Architecture"] = Architecture.ToString(),
[$"{prefix}DotNetHostPath"] = DotNetHostPath ?? string.Empty,
[$"{prefix}SearchedPaths"] = string.Join(" | ", SearchedPaths),
[$"{prefix}DetectedRuntimes"] = string.Join(
" | ",
DetectedRuntimes.Select(runtime =>
$"{runtime.Name} {runtime.Version} [{runtime.Source}{(string.IsNullOrWhiteSpace(runtime.Location) ? string.Empty : $": {runtime.Location}")}]")),
[$"{prefix}Message"] = Message
};
}
}
internal static class DotNetRuntimeProbe
{
public const string RequiredSharedFrameworkName = "Microsoft.NETCore.App";
public const string WindowsDesktopSharedFrameworkName = "Microsoft.WindowsDesktop.App";
private static readonly string[] RequiredSharedFrameworkNames =
[
RequiredSharedFrameworkName,
WindowsDesktopSharedFrameworkName
];
public static DotNetRuntimeProbeResult Probe(DotNetRuntimeProbeOptions? options = null)
{
options ??= new DotNetRuntimeProbeOptions();
var searchedPaths = new List<string>();
var detected = new List<DotNetRuntimeInfo>();
var requiredMajor = options.RequiredMajorVersion;
var localAppDataRoot = GetLocalAppDataPath(options);
var perUserDotnetRoot = !string.IsNullOrWhiteSpace(localAppDataRoot)
? Path.Combine(localAppDataRoot, "dotnet")
: null;
foreach (var frameworkName in RequiredSharedFrameworkNames)
{
foreach (var basePath in EnumerateDotNetInstallRoots(options))
{
var sharedFrameworkDirectory = Path.Combine(basePath, "shared", frameworkName);
searchedPaths.Add(sharedFrameworkDirectory);
var isPerUser = perUserDotnetRoot is not null &&
string.Equals(basePath, perUserDotnetRoot, StringComparison.OrdinalIgnoreCase);
AddDirectoryRuntimes(sharedFrameworkDirectory, frameworkName,
isPerUser ? "shared-framework-directory-per-user" : "shared-framework-directory",
detected);
}
}
string? dotNetHostPath = null;
foreach (var candidate in EnumerateDotNetHostCandidates(options))
{
searchedPaths.Add(candidate);
if (dotNetHostPath is null && File.Exists(candidate))
{
dotNetHostPath = candidate;
}
}
if (OperatingSystem.IsWindows() && options.IncludeRegistry)
{
foreach (var frameworkName in RequiredSharedFrameworkNames)
{
AddRegistryRuntimes(options.Architecture, frameworkName, detected);
}
}
if (options.IncludeDotNetCli)
{
AddDotNetCliRuntimes(dotNetHostPath, detected);
}
var isAvailable = detected.Any(runtime =>
string.Equals(runtime.Name, RequiredSharedFrameworkName, StringComparison.OrdinalIgnoreCase) &&
IsRequiredMajor(runtime.Version, requiredMajor));
var message = isAvailable
? $".NET {requiredMajor} runtime found for {options.Architecture}."
: $".NET {requiredMajor} runtime was not found for {options.Architecture}.";
return new DotNetRuntimeProbeResult(
isAvailable,
requiredMajor,
options.Architecture,
dotNetHostPath,
searchedPaths
.Where(path => !string.IsNullOrWhiteSpace(path))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
detected
.DistinctBy(runtime => $"{runtime.Name}|{runtime.Version}|{runtime.Source}|{runtime.Location}", StringComparer.OrdinalIgnoreCase)
.OrderBy(runtime => runtime.Name, StringComparer.OrdinalIgnoreCase)
.ThenBy(runtime => runtime.Version, StringComparer.OrdinalIgnoreCase)
.ToList(),
message);
}
public static DotNetRuntimeArchitecture GetCurrentArchitecture()
{
return RuntimeInformation.ProcessArchitecture switch
{
Architecture.X86 => DotNetRuntimeArchitecture.X86,
_ => DotNetRuntimeArchitecture.X64
};
}
public static string? FindDotNetHostPath(DotNetRuntimeProbeOptions? options = null)
{
options ??= new DotNetRuntimeProbeOptions();
return EnumerateDotNetHostCandidates(options).FirstOrDefault(File.Exists);
}
public static bool IsFrameworkDependentWindowsApp(string executablePath)
{
if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(executablePath))
{
return false;
}
var directory = Path.GetDirectoryName(Path.GetFullPath(executablePath));
if (string.IsNullOrWhiteSpace(directory))
{
return false;
}
var appName = Path.GetFileNameWithoutExtension(executablePath);
var runtimeConfigPath = Path.Combine(directory, $"{appName}.runtimeconfig.json");
if (!File.Exists(runtimeConfigPath))
{
return false;
}
return !File.Exists(Path.Combine(directory, "coreclr.dll")) &&
!File.Exists(Path.Combine(directory, "hostfxr.dll")) &&
!File.Exists(Path.Combine(directory, "hostpolicy.dll")) &&
!File.Exists(Path.Combine(directory, "System.Private.CoreLib.dll"));
}
private static IEnumerable<string> EnumerateDotNetInstallRoots(DotNetRuntimeProbeOptions options)
{
var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86
? GetProgramFilesX86Path(options)
: GetProgramFilesPath(options);
yield return Path.Combine(programFilesRoot, "dotnet");
var localAppData = GetLocalAppDataPath(options);
if (!string.IsNullOrWhiteSpace(localAppData))
{
var perUserDotnet = Path.Combine(localAppData, "dotnet");
if (!string.Equals(perUserDotnet, Path.Combine(programFilesRoot, "dotnet"), StringComparison.OrdinalIgnoreCase))
{
yield return perUserDotnet;
}
}
}
private static IEnumerable<string> EnumerateDotNetHostCandidates(DotNetRuntimeProbeOptions options)
{
if (options.DotNetHostCandidates is not null)
{
foreach (var candidate in options.DotNetHostCandidates)
{
if (!string.IsNullOrWhiteSpace(candidate))
{
yield return Path.GetFullPath(candidate);
}
}
yield break;
}
var programFilesRoot = options.Architecture == DotNetRuntimeArchitecture.X86
? GetProgramFilesX86Path(options)
: GetProgramFilesPath(options);
yield return Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
var localAppData = GetLocalAppDataPath(options);
if (!string.IsNullOrWhiteSpace(localAppData))
{
var perUserHost = Path.Combine(localAppData, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
if (!string.Equals(perUserHost, Path.Combine(programFilesRoot, "dotnet", OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"), StringComparison.OrdinalIgnoreCase))
{
yield return perUserHost;
}
}
}
private static string GetProgramFilesPath(DotNetRuntimeProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.ProgramFilesPath))
{
return Path.GetFullPath(options.ProgramFilesPath);
}
return Environment.GetEnvironmentVariable("ProgramW6432") ??
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
}
private static string GetProgramFilesX86Path(DotNetRuntimeProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.ProgramFilesX86Path))
{
return Path.GetFullPath(options.ProgramFilesX86Path);
}
return Environment.GetEnvironmentVariable("ProgramFiles(x86)") ??
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
}
private static string GetLocalAppDataPath(DotNetRuntimeProbeOptions options)
{
if (!string.IsNullOrWhiteSpace(options.LocalAppDataPath))
{
return Path.GetFullPath(options.LocalAppDataPath);
}
return Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
}
private static void AddDirectoryRuntimes(
string sharedFrameworkDirectory,
string sharedFrameworkName,
string source,
List<DotNetRuntimeInfo> detected)
{
if (!Directory.Exists(sharedFrameworkDirectory))
{
return;
}
foreach (var directory in Directory.GetDirectories(sharedFrameworkDirectory))
{
var version = Path.GetFileName(directory);
if (!string.IsNullOrWhiteSpace(version))
{
detected.Add(new DotNetRuntimeInfo(sharedFrameworkName, version, source, directory));
}
}
}
private static void AddRegistryRuntimes(
DotNetRuntimeArchitecture architecture,
string sharedFrameworkName,
List<DotNetRuntimeInfo> detected)
{
try
{
var registryView = architecture == DotNetRuntimeArchitecture.X86
? RegistryView.Registry32
: RegistryView.Registry64;
using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, registryView);
using var key = baseKey.OpenSubKey(
$@"SOFTWARE\dotnet\Setup\InstalledVersions\{(architecture == DotNetRuntimeArchitecture.X86 ? "x86" : "x64")}\sharedfx\{sharedFrameworkName}");
if (key is null)
{
return;
}
foreach (var valueName in key.GetValueNames())
{
if (key.GetValue(valueName) is not null)
{
detected.Add(new DotNetRuntimeInfo(sharedFrameworkName, valueName, "registry", key.Name));
}
}
}
catch (Exception ex)
{
Logger.Warn($"Failed to inspect .NET runtime registry keys: {ex.Message}");
}
}
private static void AddDotNetCliRuntimes(
string? dotNetHostPath,
List<DotNetRuntimeInfo> detected)
{
if (string.IsNullOrWhiteSpace(dotNetHostPath) || !File.Exists(dotNetHostPath))
{
return;
}
try
{
using var process = new Process();
process.StartInfo = new ProcessStartInfo
{
FileName = dotNetHostPath,
Arguments = "--list-runtimes",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
process.Start();
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(3000);
foreach (var line in output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries))
{
var parsed = ParseListRuntimeLine(line);
if (parsed is not null &&
RequiredSharedFrameworkNames.Contains(parsed.Value.Name, StringComparer.OrdinalIgnoreCase))
{
detected.Add(new DotNetRuntimeInfo(
parsed.Value.Name,
parsed.Value.Version,
"dotnet-cli",
parsed.Value.Location));
}
}
}
catch (Exception ex)
{
Logger.Warn($"Failed to inspect .NET runtimes via dotnet CLI: {ex.Message}");
}
}
private static (string Name, string Version, string? Location)? ParseListRuntimeLine(string line)
{
var firstSpace = line.IndexOf(' ');
if (firstSpace <= 0 || firstSpace + 1 >= line.Length)
{
return null;
}
var secondSpace = line.IndexOf(' ', firstSpace + 1);
if (secondSpace <= firstSpace)
{
return null;
}
var name = line[..firstSpace].Trim();
var version = line[(firstSpace + 1)..secondSpace].Trim();
var location = line[(secondSpace + 1)..].Trim().Trim('[', ']');
return (name, version, string.IsNullOrWhiteSpace(location) ? null : location);
}
private static bool IsRequiredMajor(string version, int requiredMajor)
{
var dotIndex = version.IndexOf('.');
var majorText = dotIndex < 0 ? version : version[..dotIndex];
return int.TryParse(majorText, out var major) && major == requiredMajor;
}
}

View File

@@ -930,6 +930,44 @@ internal sealed class LauncherFlowCoordinator
return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag); return LaunchHostWithResolvedPathAsync(resolution, forceDirectMode, retryTag);
} }
internal static LauncherResult? ValidateDotNetRuntimePrerequisite(
HostLaunchPlan plan,
HostResolutionResult resolution,
DotNetRuntimeProbeOptions? probeOptions = null)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(resolution);
if (!DotNetRuntimeProbe.IsFrameworkDependentWindowsApp(plan.HostPath))
{
return null;
}
var runtime = DotNetRuntimeProbe.Probe(probeOptions);
Logger.Info(
$"Runtime prerequisite check completed. Available={runtime.IsAvailable}; " +
$"Architecture={runtime.Architecture}; Message='{runtime.Message}'.");
if (runtime.IsAvailable)
{
return null;
}
var details = BuildResolutionDetails(resolution, null, null, "runtime");
foreach (var pair in runtime.ToDetails())
{
details[pair.Key] = pair.Value;
}
return BuildResult(
success: false,
stage: "launchHost",
code: "dotnet_runtime_missing",
message: ".NET 10 Desktop Runtime is required before LanMountainDesktop can start.",
details: details,
errorMessage: runtime.Message);
}
private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync( private async Task<HostLaunchOutcome> LaunchHostWithResolvedPathAsync(
HostResolutionResult resolution, HostResolutionResult resolution,
bool forceDirectMode, bool forceDirectMode,
@@ -937,6 +975,12 @@ internal sealed class LauncherFlowCoordinator
{ {
var dataRoot = _dataLocationResolver.ResolveDataRoot(); var dataRoot = _dataLocationResolver.ResolveDataRoot();
var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot); var plan = HostLaunchPlanBuilder.Build(_context, _deploymentLocator, resolution, dataRoot);
var prerequisiteFailure = ValidateDotNetRuntimePrerequisite(plan, resolution);
if (prerequisiteFailure is not null)
{
return HostLaunchOutcome.FromResult(prerequisiteFailure);
}
var hostPath = plan.HostPath; var hostPath = plan.HostPath;
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{ {

View File

@@ -6,6 +6,7 @@ public static class GlobalAppearanceSettings
public const string CornerRadiusStyleBalanced = "Balanced"; public const string CornerRadiusStyleBalanced = "Balanced";
public const string CornerRadiusStyleRounded = "Rounded"; public const string CornerRadiusStyleRounded = "Rounded";
public const string CornerRadiusStyleOpen = "Open"; public const string CornerRadiusStyleOpen = "Open";
public const string CornerRadiusStyleFluent = "Fluent";
public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced; public const string DefaultCornerRadiusStyle = CornerRadiusStyleBalanced;
/// <summary> /// <summary>
@@ -43,6 +44,11 @@ public static class GlobalAppearanceSettings
return CornerRadiusStyleOpen; return CornerRadiusStyleOpen;
} }
if (string.Equals(trimmed, CornerRadiusStyleFluent, StringComparison.OrdinalIgnoreCase))
{
return CornerRadiusStyleFluent;
}
return DefaultCornerRadiusStyle; return DefaultCornerRadiusStyle;
} }
@@ -51,7 +57,8 @@ public static class GlobalAppearanceSettings
CornerRadiusStyleSharp, CornerRadiusStyleSharp,
CornerRadiusStyleBalanced, CornerRadiusStyleBalanced,
CornerRadiusStyleRounded, CornerRadiusStyleRounded,
CornerRadiusStyleOpen CornerRadiusStyleOpen,
CornerRadiusStyleFluent
]; ];
/// <summary> /// <summary>

View File

@@ -0,0 +1,74 @@
using LanMountainDesktop.Launcher.Services;
using LanMountainDesktop.Launcher.Services.AirApp;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AirAppProcessStarterRuntimeTests : IDisposable
{
private readonly string _root;
public AirAppProcessStarterRuntimeTests()
{
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.AirAppProcessStarterRuntimeTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
[Fact]
public void CreateStartInfo_UsesPackagedExecutable_WhenExeExists()
{
var hostPath = Path.Combine(_root, OperatingSystem.IsWindows()
? "LanMountainDesktop.AirAppHost.exe"
: "LanMountainDesktop.AirAppHost");
File.WriteAllText(hostPath, string.Empty);
var startInfo = AirAppProcessStarter.CreateStartInfo(hostPath);
Assert.Equal(hostPath, startInfo.FileName);
Assert.Empty(startInfo.ArgumentList);
}
[Fact]
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var programFiles = Path.Combine(_root, "ProgramFiles");
var dotnetRoot = Path.Combine(programFiles, "dotnet");
Directory.CreateDirectory(dotnetRoot);
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
File.WriteAllText(dotnetHost, string.Empty);
Directory.CreateDirectory(Path.Combine(
dotnetRoot,
"shared",
DotNetRuntimeProbe.RequiredSharedFrameworkName,
"10.0.5"));
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
File.WriteAllText(hostDll, string.Empty);
var options = new DotNetRuntimeProbeOptions
{
Architecture = DotNetRuntimeArchitecture.X64,
ProgramFilesPath = programFiles,
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
IncludeRegistry = false,
IncludeDotNetCli = false
};
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
Assert.Equal(dotnetHost, startInfo.FileName);
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -13,6 +13,7 @@ public sealed class CornerRadiusStyleTests
[InlineData("Balanced", "Balanced")] [InlineData("Balanced", "Balanced")]
[InlineData("Rounded", "Rounded")] [InlineData("Rounded", "Rounded")]
[InlineData("Open", "Open")] [InlineData("Open", "Open")]
[InlineData("Fluent", "Fluent")]
[InlineData("Unknown", "Balanced")] [InlineData("Unknown", "Balanced")]
[InlineData(null, "Balanced")] [InlineData(null, "Balanced")]
public void NormalizeCornerRadiusStyle_ReturnsValidStyleOrDefault(string? input, string expected) public void NormalizeCornerRadiusStyle_ReturnsValidStyleOrDefault(string? input, string expected)
@@ -20,6 +21,23 @@ public sealed class CornerRadiusStyleTests
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusStyle(input)); Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusStyle(input));
} }
[Fact]
public void FluentStyle_ReturnsFluentDesignSystemValues()
{
var tokens = LanMountainDesktop.Appearance.AppearanceCornerRadiusTokenFactory.Create(
GlobalAppearanceSettings.CornerRadiusStyleFluent);
// Microsoft Fluent Design System: ControlCornerRadius = 4px, OverlayCornerRadius = 8px
Assert.Equal(new CornerRadius(2), tokens.Micro);
Assert.Equal(new CornerRadius(4), tokens.Xs);
Assert.Equal(new CornerRadius(4), tokens.Sm);
Assert.Equal(new CornerRadius(8), tokens.Md);
Assert.Equal(new CornerRadius(8), tokens.Lg);
Assert.Equal(new CornerRadius(12), tokens.Xl);
Assert.Equal(new CornerRadius(16), tokens.Island);
Assert.Equal(new CornerRadius(8), tokens.Component);
}
[Fact] [Fact]
public void PluginAppearanceContext_ResolveCornerRadius_ReturnsFixedTokenValues() public void PluginAppearanceContext_ResolveCornerRadius_ReturnsFixedTokenValues()
{ {

View File

@@ -0,0 +1,238 @@
using LanMountainDesktop.Launcher.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class DotNetRuntimeProbeTests : IDisposable
{
private readonly string _root;
private readonly string _programFiles;
private readonly string _programFilesX86;
private readonly string _localAppData;
public DotNetRuntimeProbeTests()
{
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.DotNetRuntimeProbeTests", Guid.NewGuid().ToString("N"));
_programFiles = Path.Combine(_root, "ProgramFiles");
_programFilesX86 = Path.Combine(_root, "ProgramFilesX86");
_localAppData = Path.Combine(_root, "LocalAppData");
Directory.CreateDirectory(_programFiles);
Directory.CreateDirectory(_programFilesX86);
Directory.CreateDirectory(_localAppData);
}
[Fact]
public void Probe_AcceptsTargetArchitectureRuntime_WhenDotnetHostIsMissing()
{
CreateRuntime(_programFiles, "10.0.5");
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.True(result.IsAvailable);
Assert.Null(result.DotNetHostPath);
Assert.Contains(result.DetectedRuntimes, runtime => runtime.Version == "10.0.5");
}
[Fact]
public void Probe_X64DoesNotAcceptX86OnlyRuntime()
{
CreateRuntime(_programFilesX86, "10.0.5");
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.False(result.IsAvailable);
}
[Fact]
public void Probe_X86DoesNotAcceptX64OnlyRuntime()
{
CreateRuntime(_programFiles, "10.0.5");
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X86));
Assert.False(result.IsAvailable);
}
[Fact]
public void Probe_RejectsOlderMajorVersions()
{
CreateRuntime(_programFiles, "8.0.25");
CreateRuntime(_programFiles, "9.0.14");
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.False(result.IsAvailable);
}
[Fact]
public void Probe_DetectsPerUserRuntime()
{
CreateRuntime(_localAppData, "10.0.5", DotNetRuntimeProbe.RequiredSharedFrameworkName);
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.True(result.IsAvailable);
Assert.Contains(result.DetectedRuntimes, runtime =>
runtime.Version == "10.0.5" &&
runtime.Source == "shared-framework-directory-per-user");
}
[Fact]
public void Probe_DetectsWindowsDesktopRuntime()
{
CreateRuntime(_programFiles, "10.0.5", DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName);
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.False(result.IsAvailable);
Assert.Contains(result.DetectedRuntimes, runtime =>
runtime.Name == DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName &&
runtime.Version == "10.0.5");
}
[Fact]
public void Probe_DetectsPerUserWindowsDesktopRuntime()
{
CreateRuntime(_localAppData, "10.0.5", DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName);
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.Contains(result.DetectedRuntimes, runtime =>
runtime.Name == DotNetRuntimeProbe.WindowsDesktopSharedFrameworkName &&
runtime.Version == "10.0.5" &&
runtime.Source == "shared-framework-directory-per-user");
}
[Fact]
public void Probe_FindsDotNetHost_InPerUserPath()
{
var dotnetDir = Path.Combine(_localAppData, "dotnet");
Directory.CreateDirectory(dotnetDir);
File.WriteAllText(Path.Combine(dotnetDir, "dotnet.exe"), string.Empty);
var result = DotNetRuntimeProbe.Probe(new DotNetRuntimeProbeOptions
{
Architecture = DotNetRuntimeArchitecture.X64,
ProgramFilesPath = _programFiles,
ProgramFilesX86Path = _programFilesX86,
LocalAppDataPath = _localAppData,
IncludeRegistry = false,
IncludeDotNetCli = false
});
Assert.NotNull(result.DotNetHostPath);
Assert.Contains("LocalAppData", result.DotNetHostPath);
}
[Fact]
public void Probe_PrefersProgramFilesHost_OverPerUserHost()
{
var systemDotnetDir = Path.Combine(_programFiles, "dotnet");
Directory.CreateDirectory(systemDotnetDir);
File.WriteAllText(Path.Combine(systemDotnetDir, "dotnet.exe"), string.Empty);
var perUserDotnetDir = Path.Combine(_localAppData, "dotnet");
Directory.CreateDirectory(perUserDotnetDir);
File.WriteAllText(Path.Combine(perUserDotnetDir, "dotnet.exe"), string.Empty);
var result = DotNetRuntimeProbe.Probe(new DotNetRuntimeProbeOptions
{
Architecture = DotNetRuntimeArchitecture.X64,
ProgramFilesPath = _programFiles,
ProgramFilesX86Path = _programFilesX86,
LocalAppDataPath = _localAppData,
IncludeRegistry = false,
IncludeDotNetCli = false
});
Assert.NotNull(result.DotNetHostPath);
Assert.Contains("ProgramFiles", result.DotNetHostPath);
}
[Fact]
public void Probe_CombinesSystemAndPerUserRuntimes()
{
CreateRuntime(_programFiles, "10.0.5");
CreateRuntime(_localAppData, "10.0.3");
var result = DotNetRuntimeProbe.Probe(CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.True(result.IsAvailable);
Assert.Contains(result.DetectedRuntimes, runtime => runtime.Version == "10.0.5");
Assert.Contains(result.DetectedRuntimes, runtime => runtime.Version == "10.0.3");
}
[Fact]
public void ValidateDotNetRuntimePrerequisite_ReturnsStructuredFailure_WhenRuntimeIsMissing()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var appDir = Path.Combine(_root, "app-1.0.0");
Directory.CreateDirectory(appDir);
var hostPath = Path.Combine(appDir, "LanMountainDesktop.exe");
File.WriteAllText(hostPath, string.Empty);
File.WriteAllText(Path.Combine(appDir, "LanMountainDesktop.runtimeconfig.json"), "{}");
var plan = new HostLaunchPlan(
hostPath,
_root,
appDir,
[],
new Dictionary<string, string>(),
new() { Version = "1.0.0", Codename = "Test" });
var resolution = new HostResolutionResult
{
Success = true,
ResolvedHostPath = hostPath,
AppRoot = _root,
ResolutionSource = "test",
SearchedPaths = [hostPath]
};
var result = LauncherFlowCoordinator.ValidateDotNetRuntimePrerequisite(
plan,
resolution,
CreateOptions(DotNetRuntimeArchitecture.X64));
Assert.NotNull(result);
Assert.False(result.Success);
Assert.Equal("dotnet_runtime_missing", result.Code);
Assert.Equal("False", result.Details["dotnetRuntimeAvailable"]);
}
private DotNetRuntimeProbeOptions CreateOptions(DotNetRuntimeArchitecture architecture)
{
return new DotNetRuntimeProbeOptions
{
Architecture = architecture,
ProgramFilesPath = _programFiles,
ProgramFilesX86Path = _programFilesX86,
LocalAppDataPath = _localAppData,
DotNetHostCandidates = [],
IncludeRegistry = false,
IncludeDotNetCli = false
};
}
private static void CreateRuntime(string root, string version, string? frameworkName = null)
{
frameworkName ??= DotNetRuntimeProbe.RequiredSharedFrameworkName;
Directory.CreateDirectory(Path.Combine(
root,
"dotnet",
"shared",
frameworkName,
version));
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -0,0 +1,57 @@
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class PackagingRuntimePolicyTests
{
[Fact]
public void WindowsPackageScript_PublishesLauncherRootAndFrameworkDependentAppDirectory()
{
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
Assert.Contains("Publish-LauncherPayload", script);
Assert.Contains("\"app-$Version\"", script);
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
Assert.Contains("\"--self-contained\", \"false\"", script);
Assert.Contains("\"-p:SelfContained=false\"", script);
}
[Fact]
public void WindowsPayloadGuard_BlocksBundledDotNetRuntimeFiles()
{
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
Assert.Contains("coreclr.dll", script);
Assert.Contains("hostfxr.dll", script);
Assert.Contains("hostpolicy.dll", script);
Assert.Contains("System.Private.CoreLib.dll", script);
}
[Fact]
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
{
var installer = ReadRepositoryFile("LanMountainDesktop", "installer", "LanMountainDesktop.iss");
Assert.Contains("https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe", installer);
Assert.Contains("https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x86.exe", installer);
Assert.Contains("/install /quiet /norestart", installer);
Assert.Contains("ExitCode <> 3010", installer);
Assert.DoesNotContain("IsSelfContainedBuild", installer);
}
private static string ReadRepositoryFile(params string[] pathParts)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
{
directory = directory.Parent;
}
if (directory is null)
{
throw new DirectoryNotFoundException("Unable to locate repository root.");
}
return File.ReadAllText(Path.Combine([directory.FullName, .. pathParts]));
}
}

View File

@@ -44,6 +44,13 @@ pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
This guide covers local packaging and CI packaging for LanMountainDesktop. This guide covers local packaging and CI packaging for LanMountainDesktop.
### Current Windows runtime policy
- Windows installers do not bundle the .NET shared runtime.
- `LanMountainDesktop.Launcher.exe` remains a Native AOT/self-contained bootstrapper at the package root.
- `LanMountainDesktop.exe` and `LanMountainDesktop.AirAppHost.exe` are published as framework-dependent, RID-specific apps under `app-<version>/`.
- The Inno installer downloads and silently installs the matching .NET 10 Desktop Runtime (`win-x64` or `win-x86`) before copying/launching the app.
### Key points ### Key points
- use `scripts/package.ps1` with the target runtime identifier - use `scripts/package.ps1` with the target runtime identifier

View File

@@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
@@ -82,7 +82,9 @@ public sealed class AppSettingsService
} }
var json = JsonSerializer.Serialize(snapshotToPersist, SerializerOptions); var json = JsonSerializer.Serialize(snapshotToPersist, SerializerOptions);
File.WriteAllText(_settingsPath, json); var tempPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _settingsPath, overwrite: true);
var writeTimeUtc = File.Exists(_settingsPath) var writeTimeUtc = File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath) ? File.GetLastWriteTimeUtc(_settingsPath)

View File

@@ -57,7 +57,9 @@ public sealed class ClockAirAppSettingsStore
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
File.WriteAllText(_settingsPath, JsonSerializer.Serialize(normalized, SerializerOptions)); var tempPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(normalized, SerializerOptions));
File.Move(tempPath, _settingsPath, overwrite: true);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -100,8 +100,9 @@ internal sealed class FusedDesktopLayoutService : IFusedDesktopLayoutService
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
var json = JsonSerializer.Serialize(snapshot, JsonOptions); var tempPath = $"{ConfigFilePath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(ConfigFilePath, json); File.WriteAllText(tempPath, JsonSerializer.Serialize(snapshot, JsonOptions));
File.Move(tempPath, ConfigFilePath, overwrite: true);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -197,7 +197,9 @@ public sealed class LauncherSettingsService
} }
var json = JsonSerializer.Serialize(snapshot, SerializerOptions); var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json); var tempPath = $"{_settingsPath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _settingsPath, overwrite: true);
return File.Exists(_settingsPath) return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath) ? File.GetLastWriteTimeUtc(_settingsPath)

View File

@@ -358,7 +358,9 @@ internal sealed class SettingsService : ISettingsService
Directory.CreateDirectory(directory); Directory.CreateDirectory(directory);
} }
File.WriteAllText(_pluginSettingsPath, JsonSerializer.Serialize(document, SerializerOptions)); var tempPath = $"{_pluginSettingsPath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(document, SerializerOptions));
File.Move(tempPath, _pluginSettingsPath, overwrite: true);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -440,7 +440,9 @@ public sealed class ZhiJiaoHubCacheService : IDisposable
} }
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!); Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions)); var tempPath = $"{_manifestPath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(manifest, JsonOptions));
File.Move(tempPath, _manifestPath, overwrite: true);
} }
} }
@@ -469,7 +471,9 @@ public sealed class ZhiJiaoHubCacheService : IDisposable
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow); manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!); Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions)); var tempPath = $"{_manifestPath}.{Guid.NewGuid():N}.tmp";
File.WriteAllText(tempPath, JsonSerializer.Serialize(manifest, JsonOptions));
File.Move(tempPath, _manifestPath, overwrite: true);
} }
} }

View File

@@ -15,7 +15,7 @@
<TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="16" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="16" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
<components:WeatherIconView x:Name="MainIcon" Grid.Column="1" Width="56" Height="56" Margin="0,0,10,0" /> <components:WeatherIconView x:Name="MainIcon" Grid.Column="1" Width="56" Height="56" Margin="0,0,10,0" />
<TextBlock x:Name="TemperatureTextBlock" Grid.Column="2" Text="--°" FontSize="56" FontWeight="Bold" VerticalAlignment="Center" /> <TextBlock x:Name="TemperatureTextBlock" Grid.Column="2" Text="--°" FontSize="56" FontWeight="Bold" VerticalAlignment="Center" ClipToBounds="False" Padding="0,2,0,0" />
</Grid> </Grid>
<UniformGrid x:Name="MetricGrid" Grid.Row="1" Rows="1" Columns="3" /> <UniformGrid x:Name="MetricGrid" Grid.Row="1" Rows="1" Columns="3" />
<Border Grid.Row="2" Background="{DynamicResource SurfaceColor}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="10,8"> <Border Grid.Row="2" Background="{DynamicResource SurfaceColor}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="10,8">

View File

@@ -89,7 +89,7 @@ public partial class ExtendedWeatherWidget : WeatherWidgetBase
var inner = (StackPanel)panel.Child!; var inner = (StackPanel)panel.Child!;
inner.Children.Add(new TextBlock { Text = FormatTime(item.Time), Foreground = Brush(CurrentPalette.TextSecondary), FontSize = 10, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }); inner.Children.Add(new TextBlock { Text = FormatTime(item.Time), Foreground = Brush(CurrentPalette.TextSecondary), FontSize = 10, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center });
inner.Children.Add(new WeatherIconView { Width = 26, Height = 26, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.WeatherCode, item.WeatherText) }); inner.Children.Add(new WeatherIconView { Width = 26, Height = 26, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.WeatherCode, item.WeatherText) });
inner.Children.Add(new TextBlock { Text = FormatTemperature(item.TemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 12 }); inner.Children.Add(new TextBlock { Text = FormatTemperature(item.TemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 12, ClipToBounds = false });
HourlyGrid.Children.Add(panel); HourlyGrid.Children.Add(panel);
} }
} }
@@ -111,7 +111,7 @@ public partial class ExtendedWeatherWidget : WeatherWidgetBase
var inner = (StackPanel)panel.Child!; var inner = (StackPanel)panel.Child!;
inner.Children.Add(new TextBlock { Text = ResolveDayLabel(item.Date), Foreground = Brush(CurrentPalette.TextSecondary), FontSize = 10, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }); inner.Children.Add(new TextBlock { Text = ResolveDayLabel(item.Date), Foreground = Brush(CurrentPalette.TextSecondary), FontSize = 10, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center });
inner.Children.Add(new WeatherIconView { Width = 26, Height = 26, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.DayWeatherCode, item.DayWeatherText) }); inner.Children.Add(new WeatherIconView { Width = 26, Height = 26, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.DayWeatherCode, item.DayWeatherText) });
inner.Children.Add(new TextBlock { Text = $"{FormatTemperature(item.HighTemperatureC)} / {FormatTemperature(item.LowTemperatureC)}", Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11 }); inner.Children.Add(new TextBlock { Text = $"{FormatTemperature(item.HighTemperatureC)} / {FormatTemperature(item.LowTemperatureC)}", Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextAlignment = Avalonia.Media.TextAlignment.Center, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11, ClipToBounds = false });
DailyGrid.Children.Add(panel); DailyGrid.Children.Add(panel);
} }
} }

View File

@@ -10,7 +10,7 @@
<Border x:Name="OverlayBorder" /> <Border x:Name="OverlayBorder" />
<Grid x:Name="ContentGrid" RowDefinitions="Auto,*" Margin="18,14" RowSpacing="12"> <Grid x:Name="ContentGrid" RowDefinitions="Auto,*" Margin="18,14" RowSpacing="12">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center"> <Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="42" FontWeight="Bold" VerticalAlignment="Center" /> <TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="42" FontWeight="Bold" VerticalAlignment="Center" ClipToBounds="False" Padding="0,1,0,0" />
<StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Margin="12,0,0,0" VerticalAlignment="Center">
<TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="15" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="15" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="LocationTextBlock" Text="Weather" FontSize="12" FontWeight="Medium" Opacity="0.72" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="LocationTextBlock" Text="Weather" FontSize="12" FontWeight="Medium" Opacity="0.72" TextTrimming="CharacterEllipsis" />

View File

@@ -73,7 +73,7 @@ public partial class HourlyWeatherWidget : WeatherWidgetBase
var inner = (StackPanel)panel.Child!; var inner = (StackPanel)panel.Child!;
inner.Children.Add(new TextBlock { Text = item.Label, FontSize = 10, Foreground = Brush(CurrentPalette.TextSecondary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, TextAlignment = Avalonia.Media.TextAlignment.Center }); inner.Children.Add(new TextBlock { Text = item.Label, FontSize = 10, Foreground = Brush(CurrentPalette.TextSecondary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, TextAlignment = Avalonia.Media.TextAlignment.Center });
inner.Children.Add(new WeatherIconView { Width = 24, Height = 24, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.WeatherCode, item.WeatherText) }); inner.Children.Add(new WeatherIconView { Width = 24, Height = 24, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.WeatherCode, item.WeatherText) });
inner.Children.Add(new TextBlock { Text = item.Value, FontWeight = Avalonia.Media.FontWeight.SemiBold, Foreground = Brush(CurrentPalette.TextPrimary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11, TextAlignment = Avalonia.Media.TextAlignment.Center }); inner.Children.Add(new TextBlock { Text = item.Value, FontWeight = Avalonia.Media.FontWeight.SemiBold, Foreground = Brush(CurrentPalette.TextPrimary), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, FontSize = 11, TextAlignment = Avalonia.Media.TextAlignment.Center, ClipToBounds = false });
return panel; return panel;
} }

View File

@@ -11,7 +11,7 @@
<Grid x:Name="ContentGrid" ColumnDefinitions="1.2*,1.6*" Margin="18,14" ColumnSpacing="14"> <Grid x:Name="ContentGrid" ColumnDefinitions="1.2*,1.6*" Margin="18,14" ColumnSpacing="14">
<StackPanel VerticalAlignment="Center" Spacing="6"> <StackPanel VerticalAlignment="Center" Spacing="6">
<components:WeatherIconView x:Name="MainIcon" Width="64" Height="64" HorizontalAlignment="Left" /> <components:WeatherIconView x:Name="MainIcon" Width="64" Height="64" HorizontalAlignment="Left" />
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="42" FontWeight="Bold" /> <TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="42" FontWeight="Bold" ClipToBounds="False" Padding="0,1,0,0" />
<TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="15" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="15" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock x:Name="LocationTextBlock" Text="Weather" FontSize="12" FontWeight="Medium" Opacity="0.72" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="LocationTextBlock" Text="Weather" FontSize="12" FontWeight="Medium" Opacity="0.72" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>

View File

@@ -66,9 +66,9 @@ public partial class MultiDayWeatherWidget : WeatherWidgetBase
row.Children.Add(new WeatherIconView { Width = 24, Height = 24, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.DayWeatherCode, item.DayWeatherText), VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center }); row.Children.Add(new WeatherIconView { Width = 24, Height = 24, Source = WeatherIconAssetResolver.LoadIcon(CurrentVisualStyleId, item.DayWeatherCode, item.DayWeatherText), VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center });
row.Children.Add(new TextBlock { Text = ResolveDayLabel(item.Date), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }); row.Children.Add(new TextBlock { Text = ResolveDayLabel(item.Date), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 });
Grid.SetColumn(row.Children[^1], 1); Grid.SetColumn(row.Children[^1], 1);
row.Children.Add(new TextBlock { Text = FormatTemperature(item.HighTemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }); row.Children.Add(new TextBlock { Text = FormatTemperature(item.HighTemperatureC), Foreground = Brush(CurrentPalette.TextPrimary), FontWeight = Avalonia.Media.FontWeight.SemiBold, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12, ClipToBounds = false });
Grid.SetColumn(row.Children[^1], 2); Grid.SetColumn(row.Children[^1], 2);
row.Children.Add(new TextBlock { Text = FormatTemperature(item.LowTemperatureC), Foreground = Brush(CurrentPalette.TextSecondary), FontWeight = Avalonia.Media.FontWeight.Medium, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12 }); row.Children.Add(new TextBlock { Text = FormatTemperature(item.LowTemperatureC), Foreground = Brush(CurrentPalette.TextSecondary), FontWeight = Avalonia.Media.FontWeight.Medium, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, FontSize = 12, ClipToBounds = false });
Grid.SetColumn(row.Children[^1], 3); Grid.SetColumn(row.Children[^1], 3);
rowPanel.Children.Add(row); rowPanel.Children.Add(row);

View File

@@ -15,7 +15,7 @@
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Right" Spacing="1"> <StackPanel Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Right" Spacing="1">
<components:WeatherIconView x:Name="MainIcon" Width="44" Height="44" HorizontalAlignment="Right" /> <components:WeatherIconView x:Name="MainIcon" Width="44" Height="44" HorizontalAlignment="Right" />
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Right" /> <TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Right" ClipToBounds="False" />
<TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="11" FontWeight="Medium" HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" MaxWidth="100" Opacity="0.82" /> <TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="11" FontWeight="Medium" HorizontalAlignment="Right" TextTrimming="CharacterEllipsis" MaxWidth="100" Opacity="0.82" />
</StackPanel> </StackPanel>
</Grid> </Grid>

View File

@@ -11,7 +11,7 @@
<Grid x:Name="ContentGrid" RowDefinitions="*,Auto" Margin="20,16,20,14"> <Grid x:Name="ContentGrid" RowDefinitions="*,Auto" Margin="20,16,20,14">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="4"> <StackPanel VerticalAlignment="Center" Spacing="4">
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="72" FontWeight="Bold" /> <TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="72" FontWeight="Bold" ClipToBounds="False" Padding="0,2,0,0" />
<TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="18" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" /> <TextBlock x:Name="ConditionTextBlock" Text="Loading" FontSize="18" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
</StackPanel> </StackPanel>
<components:WeatherIconView x:Name="MainIcon" Grid.Column="1" Width="72" Height="72" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,0,4" /> <components:WeatherIconView x:Name="MainIcon" Grid.Column="1" Width="72" Height="72" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,0,4" />

View File

@@ -10,9 +10,11 @@ using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing; using FluentAvalonia.UI.Windowing;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.ViewModels; using LanMountainDesktop.ViewModels;
using Symbol = FluentIcons.Common.Symbol; using Symbol = FluentIcons.Common.Symbol;
@@ -69,6 +71,7 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
InitializeComponent(); InitializeComponent();
SetValue(Window.IconProperty, _appLogoService.CreateWindowIcon()); SetValue(Window.IconProperty, _appLogoService.CreateWindowIcon());
ApplyChromeMode(useSystemChrome); ApplyChromeMode(useSystemChrome);
ApplyFluentCornerRadius();
if (RootNavigationView is not null) if (RootNavigationView is not null)
{ {
@@ -798,6 +801,30 @@ public partial class SettingsWindow : FAAppWindow, ISettingsPageHostContext
TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId); TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId);
} }
/// <summary>
/// Override global corner radius tokens on the settings window root grid
/// so all child controls use Microsoft Fluent Design System values,
/// independent of the user's global corner radius preference.
/// </summary>
private void ApplyFluentCornerRadius()
{
if (RootGrid is null)
{
return;
}
var tokens = AppearanceCornerRadiusTokenFactory.Create(
GlobalAppearanceSettings.CornerRadiusStyleFluent);
RootGrid.Resources["DesignCornerRadiusMicro"] = tokens.Micro;
RootGrid.Resources["DesignCornerRadiusXs"] = tokens.Xs;
RootGrid.Resources["DesignCornerRadiusSm"] = tokens.Sm;
RootGrid.Resources["DesignCornerRadiusMd"] = tokens.Md;
RootGrid.Resources["DesignCornerRadiusLg"] = tokens.Lg;
RootGrid.Resources["DesignCornerRadiusXl"] = tokens.Xl;
RootGrid.Resources["DesignCornerRadiusIsland"] = tokens.Island;
RootGrid.Resources["DesignCornerRadiusComponent"] = tokens.Component;
}
private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e) private void OnTitleBarDragZonePointerPressed(object? sender, PointerPressedEventArgs e)
{ {
_ = sender; _ = sender;

View File

@@ -24,10 +24,6 @@
#define MyAppSuffix "" #define MyAppSuffix ""
#endif #endif
#ifndef IsSelfContained
#define IsSelfContained "true"
#endif
[Setup] [Setup]
AppId={#MyAppId} AppId={#MyAppId}
AppName={#MyAppName} AppName={#MyAppName}
@@ -112,6 +108,14 @@ english.DotNetRuntimeMissingMessage=This application requires .NET 10.0 Desktop
chinesesimplified.DotNetRuntimeMissingMessage=此应用程序需要 .NET 10.0 Desktop Runtime 才能运行。 chinesesimplified.DotNetRuntimeMissingMessage=此应用程序需要 .NET 10.0 Desktop Runtime 才能运行。
english.DotNetRuntimeMissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again. english.DotNetRuntimeMissingAction=Click "Yes" to open the official download page. Install it first, then run this installer again.
chinesesimplified.DotNetRuntimeMissingAction=单击"是"打开官方下载页面。请先完成安装,然后重新运行此安装程序。 chinesesimplified.DotNetRuntimeMissingAction=单击"是"打开官方下载页面。请先完成安装,然后重新运行此安装程序。
english.DotNetRuntimeDownloadCaption=Installing .NET 10 Desktop Runtime
chinesesimplified.DotNetRuntimeDownloadCaption=Installing .NET 10 Desktop Runtime
english.DotNetRuntimeDownloadDescription=Setup is downloading the required Microsoft .NET runtime.
chinesesimplified.DotNetRuntimeDownloadDescription=Setup is downloading the required Microsoft .NET runtime.
english.DotNetRuntimeInstallFailed=Setup could not install the required .NET 10 Desktop Runtime.
chinesesimplified.DotNetRuntimeInstallFailed=Setup could not install the required .NET 10 Desktop Runtime.
english.DotNetRuntimeStillMissing=The .NET 10 Desktop Runtime is still not detected after installation.
chinesesimplified.DotNetRuntimeStillMissing=The .NET 10 Desktop Runtime is still not detected after installation.
english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automatically. english.DotNetRuntimeOpenFailedMessage=Unable to open the download page automatically.
chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。 chinesesimplified.DotNetRuntimeOpenFailedMessage=无法自动打开下载页面。
english.DotNetRuntimeOpenFailedAction=Please open this URL manually: english.DotNetRuntimeOpenFailedAction=Please open this URL manually:
@@ -157,7 +161,8 @@ const
UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1'; UninstallRegSubkey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\{#MyAppRegistryId}_is1';
WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'; WebView2RuntimeKeyPath = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'; WebView2RuntimeDownloadUrl = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703';
DotNetRuntimeDownloadUrl = 'https://dotnet.microsoft.com/download/dotnet/10.0'; DotNetRuntimeDownloadUrlX64 = 'https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe';
DotNetRuntimeDownloadUrlX86 = 'https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x86.exe';
UpgradeChoiceInPlace = 0; UpgradeChoiceInPlace = 0;
UpgradeChoiceRelocate = 1; UpgradeChoiceRelocate = 1;
@@ -547,78 +552,118 @@ begin
end; end;
end; end;
// Returns True when the .NET 10 Desktop Runtime (or the .NET 10 Core Runtime function GetTargetDotNetDesktopRuntimePath(): String;
// which is sufficient for Avalonia apps) is found on the system.
// We check both Microsoft.WindowsDesktop.App and Microsoft.NETCore.App because
// the runtimeconfig.json may reference either framework depending on the
// publish mode and the app only needs the one it actually references.
function IsDotNetDesktopRuntimeInstalled(): Boolean;
var
BasePath: String;
begin begin
Result := False; if '{#MyAppArch}' = 'x64' then
// Check 64-bit Program Files
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
if IsDotNet10RuntimePresent(BasePath) then
begin begin
Result := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.WindowsDesktop.App');
end;
else
begin
Result := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
end;
end;
function GetPerUserDotNetDesktopRuntimePath(): String;
begin
Result := ExpandConstant('{localappdata}\dotnet\shared\Microsoft.WindowsDesktop.App');
end;
function GetDotNetRuntimeDownloadUrl(): String;
begin
if '{#MyAppArch}' = 'x64' then
begin
Result := DotNetRuntimeDownloadUrlX64;
end;
else
begin
Result := DotNetRuntimeDownloadUrlX86;
end;
end;
function GetDotNetRuntimeInstallerFileName(): String;
begin
if '{#MyAppArch}' = 'x64' then
begin
Result := 'windowsdesktop-runtime-win-x64.exe';
end
else
begin
Result := 'windowsdesktop-runtime-win-x86.exe';
end;
end;
function IsDotNetDesktopRuntimeInstalled(): Boolean;
begin
Result := IsDotNet10RuntimePresent(GetTargetDotNetDesktopRuntimePath()) or
IsDotNet10RuntimePresent(GetPerUserDotNetDesktopRuntimePath());
end;
function DotNetDownloadProgress(
const Url, FileName: String;
const Progress, ProgressMax: Int64): Boolean;
begin
Result := True; Result := True;
end;
function EnsureDotNetDesktopRuntimeInstalled(var NeedsRestart: Boolean): String;
var
DownloadPage: TDownloadWizardPage;
InstallerPath: String;
ExitCode: Integer;
begin
Result := '';
if IsDotNetDesktopRuntimeInstalled() then
begin
exit; exit;
end; end;
BasePath := ExpandConstant('{commonpf64}\dotnet\shared\Microsoft.NETCore.App'); DownloadPage := CreateDownloadPage(
if IsDotNet10RuntimePresent(BasePath) then CustomMessage('DotNetRuntimeDownloadCaption'),
CustomMessage('DotNetRuntimeDownloadDescription'),
@DotNetDownloadProgress);
try
DownloadPage.Add(GetDotNetRuntimeDownloadUrl(), GetDotNetRuntimeInstallerFileName(), '');
DownloadPage.Show;
try
DownloadPage.Download;
except
Result := CustomMessage('DotNetRuntimeInstallFailed') + #13#10 + GetExceptionMessage;
exit;
end;
finally
DownloadPage.Hide;
end;
InstallerPath := ExpandConstant('{tmp}\' + GetDotNetRuntimeInstallerFileName());
if not Exec(InstallerPath, '/install /quiet /norestart', '', SW_HIDE, ewWaitUntilTerminated, ExitCode) then
begin begin
Result := True; Result := CustomMessage('DotNetRuntimeInstallFailed');
exit; exit;
end; end;
// Check 32-bit Program Files if (ExitCode <> 0) and (ExitCode <> 3010) then
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.WindowsDesktop.App');
if IsDotNet10RuntimePresent(BasePath) then
begin begin
Result := True; Result := CustomMessage('DotNetRuntimeInstallFailed') + ' Exit code: ' + IntToStr(ExitCode);
exit; exit;
end; end;
BasePath := ExpandConstant('{commonpf}\dotnet\shared\Microsoft.NETCore.App'); if ExitCode = 3010 then
if IsDotNet10RuntimePresent(BasePath) then
begin begin
Result := True; NeedsRestart := True;
exit; end;
if not IsDotNetDesktopRuntimeInstalled() then
begin
Result := CustomMessage('DotNetRuntimeStillMissing') + #13#10 + GetTargetDotNetDesktopRuntimePath();
end; end;
end; end;
function InitializeSetup(): Boolean; function InitializeSetup(): Boolean;
var var
ErrorCode: Integer; ErrorCode: Integer;
IsSelfContainedBuild: Boolean;
begin begin
IsSelfContainedBuild := ('{#IsSelfContained}' = 'true');
if not IsSelfContainedBuild then
begin
if not IsDotNetDesktopRuntimeInstalled() then
begin
if MsgBox(
CustomMessage('DotNetRuntimeMissingMessage') + #13#10#13#10 +
CustomMessage('DotNetRuntimeMissingAction'),
mbConfirmation,
MB_YESNO) = IDYES then
begin
if not ShellExec('open', DotNetRuntimeDownloadUrl, '', '', SW_SHOWNORMAL, ewNoWait, ErrorCode) then
begin
MsgBox(
CustomMessage('DotNetRuntimeOpenFailedMessage') + #13#10 +
CustomMessage('DotNetRuntimeOpenFailedAction') + #13#10 + DotNetRuntimeDownloadUrl,
mbError,
MB_OK);
end;
end;
Result := False;
exit;
end;
end;
if IsWebView2RuntimeInstalled() then if IsWebView2RuntimeInstalled() then
begin begin
@@ -645,6 +690,11 @@ begin
Result := False; Result := False;
end; end;
function PrepareToInstall(var NeedsRestart: Boolean): String;
begin
Result := EnsureDotNetDesktopRuntimeInstalled(NeedsRestart);
end;
procedure InitializeWizard; procedure InitializeWizard;
var var
DetailsText: String; DetailsText: String;

View File

@@ -164,6 +164,12 @@ function Assert-WindowsPayloadClean {
$violations = [System.Collections.Generic.List[string]]::new() $violations = [System.Collections.Generic.List[string]]::new()
$forbiddenExtensions = @(".pdb", ".so", ".dylib", ".a") $forbiddenExtensions = @(".pdb", ".so", ".dylib", ".a")
$forbiddenBundledRuntimeFiles = @(
"coreclr.dll",
"hostfxr.dll",
"hostpolicy.dll",
"System.Private.CoreLib.dll"
)
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue | Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $forbiddenExtensions -contains $_.Extension.ToLowerInvariant() } | Where-Object { $forbiddenExtensions -contains $_.Extension.ToLowerInvariant() } |
@@ -171,6 +177,12 @@ function Assert-WindowsPayloadClean {
$violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName)) $violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName))
} }
Get-ChildItem -LiteralPath $Root -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $forbiddenBundledRuntimeFiles -contains $_.Name } |
ForEach-Object {
$violations.Add((Get-RelativePathCompat -Root $Root -Path $_.FullName))
}
Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue | Get-ChildItem -LiteralPath $Root -Recurse -Directory -Filter "runtimes" -ErrorAction SilentlyContinue |
ForEach-Object { ForEach-Object {
Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue | Get-ChildItem -LiteralPath $_.FullName -Directory -ErrorAction SilentlyContinue |

View File

@@ -236,6 +236,7 @@ function Publish-AirAppHostPayload {
"-c", $Configuration, "-c", $Configuration,
"-r", $Rid, "-r", $Rid,
"--self-contained", "false", "--self-contained", "false",
"-p:SelfContained=false",
"-p:PublishSingleFile=false", "-p:PublishSingleFile=false",
"-p:PublishTrimmed=false", "-p:PublishTrimmed=false",
"-p:PublishReadyToRun=false", "-p:PublishReadyToRun=false",
@@ -253,6 +254,70 @@ function Publish-AirAppHostPayload {
} }
} }
function Publish-LauncherPayload {
param(
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
[Parameter(Mandatory = $true)][string]$Rid,
[Parameter(Mandatory = $true)][string]$VersionValue
)
$launcherProject = Join-Path $repoRoot "..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj"
$launcherProject = Resolve-ExistingPath -PathValue $launcherProject
Write-Host "Publishing Launcher AOT payload..."
$launcherPublishArgs = @(
"publish",
$launcherProject,
"-c", $Configuration,
"-r", $Rid,
"--self-contained",
"-p:PublishAot=true",
"-p:PublishSingleFile=true",
"-p:IncludeNativeLibrariesForSelfExtract=true",
"-p:EnableCompressionInSingleFile=true",
"-p:DebugType=None",
"-p:DebugSymbols=false",
"-p:Version=$VersionValue",
"-o", $PublishedDirectory
)
& dotnet @launcherPublishArgs
if ($LASTEXITCODE -ne 0) {
throw "Launcher publish failed with exit code $LASTEXITCODE."
}
}
function Publish-MainAppFrameworkDependentPayload {
param(
[Parameter(Mandatory = $true)][string]$ProjectFile,
[Parameter(Mandatory = $true)][string]$PublishedDirectory,
[Parameter(Mandatory = $true)][string]$Rid,
[Parameter(Mandatory = $true)][string]$VersionValue
)
Write-Host "Publishing framework-dependent main app payload..."
$publishArgs = @(
"publish",
$ProjectFile,
"-c", $Configuration,
"-r", $Rid,
"--self-contained", "false",
"-p:SelfContained=false",
"-p:PublishSingleFile=false",
"-p:PublishTrimmed=false",
"-p:PublishReadyToRun=false",
"-p:DebugType=None",
"-p:DebugSymbols=false",
"-p:SkipAirAppHostBuild=true",
"-p:Version=$VersionValue",
"-o", $PublishedDirectory
)
& dotnet @publishArgs
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
}
$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..") $repoRoot = Resolve-ExistingPath -PathValue (Join-Path $scriptRoot "..")
@@ -274,8 +339,20 @@ if (-not [System.IO.Path]::IsPathRooted($PublishDir)) {
} }
Clear-DirectoryContents -TargetDirectory $PublishDir Clear-DirectoryContents -TargetDirectory $PublishDir
Write-Host "Publishing project..." if (Is-WindowsRuntimeIdentifier -Rid $RuntimeIdentifier) {
$publishArgs = @( $appPublishDir = Join-Path $PublishDir "app-$Version"
[System.IO.Directory]::CreateDirectory($appPublishDir) | Out-Null
Publish-LauncherPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
Publish-MainAppFrameworkDependentPayload -ProjectFile $projectPath -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier -VersionValue $Version
Publish-AirAppHostPayload -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier -VersionValue $Version
New-Item -ItemType File -Path (Join-Path $appPublishDir ".current") -Force | Out-Null
Remove-LibVlcForOtherArch -PublishedDirectory $appPublishDir -Rid $RuntimeIdentifier
Remove-LegacyOutputArtifacts -TargetDirectory $appPublishDir
} else {
Write-Host "Publishing project..."
$publishArgs = @(
"publish", "publish",
$projectPath, $projectPath,
"-c", $Configuration, "-c", $Configuration,
@@ -288,19 +365,20 @@ $publishArgs = @(
"-p:SkipAirAppHostBuild=true", "-p:SkipAirAppHostBuild=true",
"-p:Version=$Version", "-p:Version=$Version",
"-o", $PublishDir "-o", $PublishDir
) )
& dotnet @publishArgs & dotnet @publishArgs
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE." throw "dotnet publish failed with exit code $LASTEXITCODE."
} }
Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version Publish-AirAppHostPayload -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier -VersionValue $Version
Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier Remove-LibVlcForOtherArch -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier
Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir Remove-LegacyOutputArtifacts -TargetDirectory $PublishDir
if ($RuntimeIdentifier -like "linux-*") { if ($RuntimeIdentifier -like "linux-*") {
Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot Add-LinuxDesktopAssets -PublishedDirectory $PublishDir -RepoRoot $repoRoot
}
} }
Invoke-PublishPayloadOptimization -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier Invoke-PublishPayloadOptimization -PublishedDirectory $PublishDir -Rid $RuntimeIdentifier

View File

@@ -0,0 +1,253 @@
# LanMountainDesktop 安全审计报告
**项目**: LanMountainDesktop
**审计日期**: 2026-05-24
**审计范围**: 代码库安全性系统性评估
**审计方法**: 静态代码分析 + 架构审查 + 攻击面映射
---
## 执行摘要
本次审计对 LanMountainDesktop 代码库进行了全面的安全评估,系统性地检查了认证与访问控制、注入向量、外部交互以及敏感数据处理等高风险攻击面。
**审计结论**: 发现 **5 个已确认的中等及以上严重度漏洞**,均具有可论证的利用路径。
---
## 已确认漏洞
### 漏洞 #1 - PostHog API Key 硬编码(高严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 高 |
| **CWE** | CWE-798 - 使用硬编码凭证 |
| **位置** | [PostHogUsageTelemetryService.cs:14](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/PostHogUsageTelemetryService.cs#L14) |
| **攻击者画像** | 源代码仓库的任何访问者通过代码泄露、供应链攻击或Git历史 |
| **可控输入** | 无(静态硬编码密钥) |
**代码路径**:
```csharp
// PostHogUsageTelemetryService.cs:14
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
```
**影响**:
- 攻击者可滥用此 API Key 向 PostHog 项目发送伪造遥测数据
- 可能导致遥测数据污染,干扰产品分析决策
- API Key 暴露在公开仓库中,任何人都能获取并滥用
**修复建议**:
```csharp
private static string GetPostHogApiKey()
{
var key = Environment.GetEnvironmentVariable("POSTHOG_API_KEY");
if (string.IsNullOrEmpty(key))
throw new InvalidOperationException("PostHog API key not configured.");
return key;
}
```
---
### 漏洞 #2 - Sentry DSN 硬编码(高严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 高 |
| **CWE** | CWE-798 - 使用硬编码凭证 |
| **位置** | [SentryCrashTelemetryService.cs:15](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/SentryCrashTelemetryService.cs#L15) |
| **攻击者画像** | 源代码仓库的任何访问者 |
| **可控输入** | 无(静态硬编码密钥) |
**代码路径**:
```csharp
// SentryCrashTelemetryService.cs:15
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
```
**影响**:
- Sentry DSN 等同于项目的访问凭证
- 攻击者可利用此 DSN 向项目发送伪造崩溃报告
- 可能导致崩溃数据污染,干扰错误追踪
- 如 DSN 配置不当,可导致敏感崩溃信息被发送至攻击者控制的端点
**修复建议**:
```csharp
private static string GetSentryDsn()
{
var dsn = Environment.GetEnvironmentVariable("SENTRY_DSN");
if (string.IsNullOrEmpty(dsn))
throw new InvalidOperationException("Sentry DSN not configured.");
return dsn;
}
```
---
### 漏洞 #3 - 小米天气 API 签名密钥硬编码(高严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 高 |
| **CWE** | CWE-798 - 使用硬编码凭证 |
| **位置** | [XiaomiWeatherService.cs:25](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/XiaomiWeatherService.cs#L25) |
| **攻击者画像** | 源代码仓库的任何访问者 |
| **可控输入** | 无(静态硬编码密钥) |
**代码路径**:
```csharp
// XiaomiWeatherService.cs:25
public string Sign { get; init; } = "zUFJoAR2ZVrDy1vF3D07";
```
**影响**:
- API 签名凭证暴露在公开仓库
- 攻击者可能利用此凭证访问天气服务 API
- 可能导致 API 配额滥用或服务成本增加
- 如密钥具有更高权限,可能导致数据泄露
**修复建议**:
```csharp
public string Sign { get; init; } = Environment.GetEnvironmentVariable("XIAOMI_WEATHER_SIGN") ?? "";
```
---
### 漏洞 #4 - Sentry PII 收集配置(中等严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 中等 |
| **CWE** | CWE-359 - 个人身份信息PII意外暴露 |
| **位置** | [SentryCrashTelemetryService.cs:212](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/SentryCrashTelemetryService.cs#L212) |
| **攻击者画像** | Sentry 后端管理员、内部威胁或数据泄露事件 |
| **可控输入** | 用户环境的机器名、用户名、IP地址等系统信息 |
**代码路径**:
```csharp
// SentryCrashTelemetryService.cs:212
options.SendDefaultPii = true;
```
**影响**:
- `SendDefaultPii = true` 配置会收集和上报用户 IP 地址
- 可能违反隐私法规(如 GDPR、中国个人信息保护法要求
- 在崩溃报告中可能暴露用户敏感信息
- 用户未明确同意即被收集 PII
**修复建议**:
```csharp
// 根据用户同意状态动态设置
options.SendDefaultPii = TelemetryEnvironmentInfo.IsTelemetryPiiAllowed();
```
---
### 漏洞 #5 - SSL 证书验证被禁用(中等严重度)
| 属性 | 详情 |
|------|------|
| **严重度** | 中等 |
| **CWE** | CWE-295 - 证书验证不正确 |
| **位置** | [RecommendationDataService.cs:105](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/RecommendationDataService.cs#L105) |
| **攻击者画像** | 网络中间人攻击者(在同一网络环境的攻击者) |
| **可控输入** | 用户网络流量 |
| **利用路径** | 用户发起API请求 → 攻击者拦截流量 → 伪造响应 |
**代码路径**:
```csharp
// RecommendationDataService.cs:100-106
var handler = new HttpClientHandler
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
System.Security.Authentication.SslProtocols.Tls13,
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
};
```
**影响**:
- 禁用了服务器证书验证使应用程序容易受到中间人MITM攻击
- 攻击者可以拦截和篡改 API 响应数据
- 可能导致注入恶意内容或数据操纵
- 即使使用 TLS 1.2/1.3,证书验证被禁用仍然不安全
**修复建议**:
```csharp
var handler = new HttpClientHandler
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
System.Security.Authentication.SslProtocols.Tls13,
// 删除 ServerCertificateCustomValidationCallback 或实现正确的验证
};
```
---
## 未发现漏洞的区域
经过系统性审计,以下区域未发现中等及以上严重度的已确认漏洞:
### 认证与访问控制
- 单实例服务实现正确(使用互斥体)
- IPC 通信使用命名管道,无明显认证绕过风险
- 插件隔离使用独立进程边界
- 插件加载使用 AppDomain/AssemblyLoadContext 隔离
### 注入向量
- SQLite 使用参数化查询,无 SQL 注入风险 ([ComponentDomainStorage.cs](file:///d:/github/LanMountainDesktop/LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs))
- JSON 反序列化使用强类型上下文 (`JsonSerializerContext`),无反序列化漏洞
- 文件路径操作使用 `Path.Combine``Path.GetInvalidFileNameChars()` 过滤
- 未发现命令执行注入Process.Start 使用固定参数)
### 外部交互
- HTTP 请求使用 `HttpClient` 和超时配置
- Webhook/回调 URL 使用 `Uri.EscapeDataString` 编码
- 下载服务验证目标路径,无路径遍历风险
- URL 参数正确使用编码函数
### 敏感数据处理
- 数据库本地存储,使用 WAL 模式
- 设置数据通过 JSON 序列化存储在用户目录
- 日志文件路径正确隔离在应用数据目录
---
## 架构安全评估
| 组件 | 安全评级 | 说明 |
|------|----------|------|
| 插件系统 | 良好 | 使用独立进程隔离 |
| IPC 通信 | 良好 | 命名管道通信,进程边界隔离 |
| 更新系统 | 良好 | 支持签名验证 |
| 遥测系统 | **需改进** | 存在硬编码凭证和 PII 配置问题 |
| 数据存储 | 良好 | 使用标准加密实践 |
| 网络通信 | **需改进** | 存在证书验证绕过问题 |
---
## 修复优先级
| 优先级 | 漏洞 | 严重度 | 预计工作量 |
|--------|------|--------|------------|
| P0 - 紧急 | #1 PostHog API Key | 高 | 低 |
| P0 - 紧急 | #2 Sentry DSN | 高 | 低 |
| P0 - 紧急 | #3 Xiaomi Weather Sign | 高 | 低 |
| P1 - 高 | #4 SendDefaultPii | 中 | 低 |
| P1 - 高 | #5 SSL 证书验证禁用 | 中 | 中 |
---
## 建议的安全改进
1. **实施密钥管理**: 使用环境变量或密钥管理服务存储所有 API 凭证
2. **添加密钥扫描**: 在 CI/CD 流程中集成 secrets scanning如 GitGuardian、trufflehog
3. **隐私合规审查**: 确认遥测数据收集符合当地隐私法规要求
4. **证书验证修复**: 移除禁用的证书验证,确保 HTTPS 通信安全
5. **代码审计**: 建议进行定期安全审计
---
*报告生成工具: 自动安全审计系统*
*审计方法: 静态代码分析 + 架构审查 + 攻击面映射*

View File

@@ -4,11 +4,13 @@
为了确保桌面组件在不同尺寸、缩放比例下都能保持视觉一致性和美感,阑山桌面采用了 **固定圆角风格预设 (Fixed Corner Radius Styles)**全面参考小米澎湃OS (Xiaomi HyperOS) 的设计语言。 为了确保桌面组件在不同尺寸、缩放比例下都能保持视觉一致性和美感,阑山桌面采用了 **固定圆角风格预设 (Fixed Corner Radius Styles)**全面参考小米澎湃OS (Xiaomi HyperOS) 的设计语言。
此外,阑山桌面引入了 **Fluent** 预设,遵循 Microsoft Fluent Design System 规范。设置窗口始终使用 Fluent 圆角,独立于用户选择的全局圆角风格。
所有的组件和容器必须使用统一的资源键,禁止在 XAML 或代码中使用硬编码的像素值。 所有的组件和容器必须使用统一的资源键,禁止在 XAML 或代码中使用硬编码的像素值。
## 预设风格 (Preset Styles) ## 预设风格 (Preset Styles)
用户可以在设置中选择以下种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。 用户可以在设置中选择以下种风格之一。系统会自动根据选中的风格动态映射全局圆角 Token。
| 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 | | 风格 (ID) | 名称 (Local) | 组件圆角 (Component) | 设计语义 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
@@ -16,21 +18,33 @@
| **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 | | **Balanced** | 平衡 | 24px | **默认值**。和谐、自然、普适 |
| **Rounded** | 圆润 | 28px | 保守、柔和、亲切 | | **Rounded** | 圆润 | 28px | 保守、柔和、亲切 |
| **Open** | 开放 | 32px | 现代、沉浸、夸张 | | **Open** | 开放 | 32px | 现代、沉浸、夸张 |
| **Fluent** | Fluent | 8px | Microsoft Fluent Design System。标准、规范、一致 |
## Token 阶梯映射 (Token Step Mapping) ## Token 阶梯映射 (Token Step Mapping)
每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)** 每个风格都定义了一套完整的圆角阶梯,以确保在大容器包裹小元素时满足 **圆角嵌套一致性 (Nesting Consistency)**
| Token | Sharp | Balanced | Rounded | Open | 典型场景 | | Token | Sharp | Balanced | Rounded | Open | Fluent | 典型场景 |
| :--- | :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| **Micro** | 4px | 6px | 8px | 10px | 小图标容器、角标 (Badge) | | **Micro** | 4px | 6px | 8px | 10px | 2px | 小图标容器、角标 (Badge) |
| **Xs** | 8px | 12px | 14px | 16px | 小标签 (Tag)、输入框 | | **Xs** | 8px | 12px | 14px | 16px | 4px | 小标签 (Tag)、输入框 |
| **Sm** | 10px | 14px | 16px | 20px | 普通按钮、搜索栏、复选框 | | **Sm** | 10px | 14px | 16px | 20px | 4px | 普通按钮、搜索栏、复选框 |
| **Md** | 14px | 20px | 24px | 28px | 悬浮菜单、小提示框、子卡片 | | **Md** | 14px | 20px | 24px | 28px | 8px | 悬浮菜单、小提示框、子卡片 |
| **Lg** | 20px | 28px | 32px | 36px | 普通面板、对话框内容区 | | **Lg** | 20px | 28px | 32px | 36px | 8px | 普通面板、对话框内容区 |
| **Xl** | 24px | 32px | 36px | 40px | 大尺寸容器、设置中心页面 | | **Xl** | 24px | 32px | 36px | 40px | 12px | 大尺寸容器、设置中心页面 |
| **Island** | 28px | 36px | 40px | 44px | 任务栏、全局大悬浮容器 | | **Island** | 28px | 36px | 40px | 44px | 16px | 任务栏、全局大悬浮容器 |
| **Component** | **20px** | **24px** | **28px** | **32px** | **所有桌面组件 (Widget) 的主边框** | | **Component** | **20px** | **24px** | **28px** | **32px** | **8px** | **所有桌面组件 (Widget) 的主边框** |
## Fluent Design System 参考 (Fluent Reference)
Fluent 预设的核心值来源于 Microsoft 官方规范:
- **ControlCornerRadius = 4px**:用于标准持久 UI 元素(按钮、复选框、输入框等)
- **OverlayCornerRadius = 8px**:用于临时覆盖 UI 元素(对话框、浮出菜单等)
> [!IMPORTANT]
> **设置窗口强制约束**
> 设置窗口 (`SettingsWindow`) 始终使用 Fluent 圆角 Token不受用户全局圆角设置影响。这确保设置 UI 作为标准 Windows 应用窗口与 Fluent Design 一致。
## 开发准则 (Implementation Rules) ## 开发准则 (Implementation Rules)

19
docs/RUNTIME_PACKAGING.md Normal file
View File

@@ -0,0 +1,19 @@
# Runtime Packaging
## Windows
- Windows installers do not bundle the .NET shared runtime.
- `LanMountainDesktop.Launcher.exe` is the package-root bootstrapper and remains Native AOT/self-contained.
- `LanMountainDesktop.exe` and `LanMountainDesktop.AirAppHost.exe` are framework-dependent, RID-specific apps under `app-<version>/`.
- Inno Setup downloads and silently installs the matching .NET 10 Desktop Runtime before continuing:
- x64 installer: `https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x64.exe`
- x86 installer: `https://aka.ms/dotnet/10.0/windowsdesktop-runtime-win-x86.exe`
- Launcher runtime probing validates the architecture-matched `Microsoft.NETCore.App 10.*` shared framework before starting framework-dependent processes.
If the launcher returns `dotnet_runtime_missing`, verify the runtime architecture:
```powershell
dotnet --list-runtimes
Test-Path "C:\Program Files\dotnet\shared\Microsoft.NETCore.App"
Test-Path "C:\Program Files (x86)\dotnet\shared\Microsoft.NETCore.App"
```

View File

@@ -0,0 +1,269 @@
# Git 提交分析报告
## 📋 提交基本信息
| 属性 | 值 |
|------|-----|
| **完整哈希** | `7a70476ce8093ea6000f25fba7ba404d4f3e8f3c` |
| **短哈希** | `7a70476` |
| **作者** | lincube <lincube3@hotmail.com> |
| **提交日期** | 2026-05-19 |
| **提交时间** | 07:55:21 |
| **时区** | +0800 |
| **提交类型** | 🟡 合并提交 (Merge Commit) |
| **关联 PR** | #11 |
## 📝 提交信息摘要
```
合并对设置系统的更新 (#11)
```
**详细提交说明**
本次合并包含了对设置系统的全面更新和改进,主要涉及以下子提交:
1. **Add Windows system chrome patchers (Harmony)** - 使用 Harmony 修补器添加 Windows 系统 chrome 切换支持
2. **Refactor settings window UI and theming** - 重构设置窗口 UI 和主题
3. **Add localization and localize settings pages** - 添加本地化和多语言支持
4. **Redesign settings window with fluent shell & search** - 使用 Fluent Shell 重新设计设置窗口并添加搜索功能
5. **Add OOBE startup presentation and settings merge** - 添加 OOBE 启动演示和设置合并功能
6. **Move whiteboard persistence to file storage** - 将白板持久化迁移到文件存储
7. **Introduce render gate and chart caching** - 引入渲染门控和图表缓存机制
8. **Use MaterialColorSnapshot in appearance flow** - 在外观流程中使用 MaterialColorSnapshot
9. **Add material color services, plugin DTOs, and tests** - 添加材质颜色服务、插件 DTO 和测试
10. **Add CODE_WIKI and update localization** - 添加 CODE_WIKI 文档和更新本地化
11. **Add Data settings page and storage scanner** - 添加数据设置页面和存储扫描器
12. **Add IPC backoff/retries and safer disposal** - 添加 IPC 退避/重试和更安全的资源释放
13. **Add preview controls and settings UI tweaks** - 添加预览控件和设置 UI 调整
14. **Add install checkpoint/resume and DDSS workflows** - 添加安装检查点/恢复和 DDSS 工作流
## 📊 变更统计
| 统计项 | 数值 |
|--------|------|
| **变更文件总数** | 904 个文件 |
| **新增代码行数** | +78,048 行 |
| **删除代码行数** | -18,362 行 |
| **净增代码行数** | +59,686 行 |
## 📂 详细变更分析(按文件类型分组)
### 1. 核心代码文件 (.cs)
#### LanMountainDesktop 核心项目
| 文件路径 | 类型 | 变更说明 |
|----------|------|----------|
| `LanMountainDesktop/Program.cs` | 修改 | 添加 Windows chrome 修补器加载逻辑 |
| `LanMountainDesktop/ViewModels/SettingsViewModels.cs` | 修改 | 重构设置视图模型,添加新属性和本地化支持 |
| `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs` | 修改 | 添加材质颜色和壁纸设置 |
| `LanMountainDesktop/ViewModels/UpdateSettingsPageViewModel.cs` | 修改 | 更新设置页面视图模型 |
| `LanMountainDesktop/Views/SettingsWindow.axaml` | 修改 | Fluent Shell 设置窗口重构,添加自定义标题栏 |
| `LanMountainDesktop/Views/SettingsWindow.axaml.cs` | 修改 | 设置窗口代码重构,添加搜索功能 |
| `LanMountainDesktop/Views/TransparentOverlayWindow.axaml` | 修改 | 透明覆盖窗口大幅重构 |
| `LanMountainDesktop/Services/LocalizationService.cs` | 修改 | 本地化服务更新 |
| `LanMountainDesktop/Services/SettingsSearchService.cs` | 新增 | 设置搜索服务(搜索索引、导航、结果高亮) |
| `LanMountainDesktop/Services/MaterialSurfaceService.cs` | 修改 | 添加特殊材质参数和窗口材质处理 |
| `LanMountainDesktop/Services/GlassEffectService.cs` | 修改 | 添加自适应设置窗口调色笔刷 |
| `LanMountainDesktop/Services/SettingsWindowService.cs` | 修改 | 重构主题应用逻辑 |
| `LanMountainDesktop/Services/AppearanceThemeService.cs` | 修改 | 依赖 MaterialColorService更新外观主题处理 |
| `LanMountainDesktop/Services/WindowMaterialService.cs` | 修改 | 窗口材质服务和自动材质模式支持 |
| `LanMountainDesktop/Services/DataStorageService.cs` | 新增 | 数据存储服务(扫描、磁盘信息、清理操作) |
| `LanMountainDesktop/Services/WallpaperColorPipeline.cs` | 新增 | 壁纸颜色管道服务 |
| `LanMountainDesktop/Services/Launch/LauncherWindowsStartupService.cs` | 新增 | 启动器 Windows 启动服务 |
| `LanMountainDesktop/Services/HostAppSettingsOobeMerger.cs` | 新增 | Host 应用设置 OOBE 合并服务 |
| `LanMountainDesktop/Services/UpdateEngineService.cs` | 修改 | 添加检查点加载/保存/恢复逻辑 |
| `LanMountainDesktop/Services/IPC/...` | 多文件 | 添加重试逻辑、退避策略、更安全的资源释放 |
#### 视图模型 (ViewModels)
| 文件路径 | 变更说明 |
|----------|----------|
| `NotificationSettingsPageViewModel.cs` | 添加本地化支持 |
| `DevSettingsPageViewModel.cs` | 添加本地化支持 |
| `AboutSettingsPageViewModel.cs` | 添加本地化支持 |
| `StatusBarSettingsPageViewModel.cs` | 添加本地化支持 |
| `MaterialColorSettingsPageViewModel.cs` | 新增材质颜色设置视图模型 |
| `DataSettingsPageViewModel.cs` | 新增数据设置视图模型 |
| `GeneralSettingsPageViewModel.cs` | 更新通用设置视图模型 |
| `AppearanceSettingsPageViewModel.cs` | 更新外观设置视图模型 |
| `ComponentsSettingsPageViewModel.cs` | 添加预览控件和实时预览支持 |
#### 设置页面视图 (Views)
| 文件路径 | 变更说明 |
|----------|----------|
| `NotificationSettingsPage.axaml` | 更新通知设置页面 |
| `UpdateSettingsPage.axaml` | 大幅重构更新设置页面530 行变更) |
| `WeatherSettingsPage.axaml` | 更新天气设置页面 |
| `GeneralSettingsPage.axaml` | 更新通用设置页面 |
| `LauncherSettingsPage.axaml` | 更新启动器设置页面 |
| `MaterialColorSettingsPage.axaml` | 新增材质颜色设置页面 |
| `MaterialColorSettingsPage.axaml.cs` | 新增材质颜色设置代码 |
| `DataSettingsPage.axaml` | 新增数据设置页面 |
| `DataSettingsPage.axaml.cs` | 新增数据设置代码 |
| `StatusBarSettingsPage.axaml` | 更新状态栏设置页面 |
| `WallpaperSettingsPage.axaml` | 更新壁纸设置页面 |
### 2. 第三方库集成
#### DotNetCampus.InkCanvas 墨迹画布库(重大新增)
本次合并添加了完整的 **DotNetCampus.InkCanvas** 库,这是一个功能完整的墨迹/手写画布解决方案:
| 子项目 | 文件数 | 主要功能 |
|--------|--------|----------|
| **DotNetCampus.AvaloniaInkCanvas** | 多个 | Avalonia 平台的墨迹画布实现 |
| **DotNetCampus.InkCanvas.InkCore** | 30+ | 核心墨迹处理算法和接口 |
| **DotNetCampus.InkCanvas.SkiaInk** | 多个 | Skia 渲染引擎的墨迹实现 |
**主要功能**
- 墨迹绘制和渲染Stroke rendering
- 橡皮擦功能Eraser modes
- 点抽稀算法Drop point algorithm
- 墨迹序列化格式Ink Serialized Format
- 多平台支持Skia, WPF, Avalonia
**涉及的核心文件**
```
ThirdParty/DotNetCampus.InkCanvas/src/
├── DotNetCampus.AvaloniaInkCanvas/
│ ├── API/InkCanvas.cs
│ ├── Caching/InkBitmapCache.cs
│ ├── Core/AvaloniaSkiaInkCanvas.cs
│ └── Erasing/PointPathEraserManager.cs
├── DotNetCampus.InkCanvas.InkCore/
│ ├── Inking/Interactives/InkingModeInputDispatcher.cs
│ ├── InkSerializedFormat/InkSerializer.cs
│ └── System/Windows/Ink/Stroke.cs
└── DotNetCampus.InkCanvas.SkiaInk/
├── Settings/SkInkCanvasSettings.cs
└── Utils/SkiaExtension.cs
```
### 3. 脚本和工具文件
#### 分析脚本(新增)
| 文件 | 用途 |
|------|------|
| `parse_git_log.py` | 解析 Git HEAD 日志文件 |
| `scripts/Analyze-GitCommits.ps1` | PowerShell 提交分析脚本 |
| `scripts/GitCommitAnalyzer.cs` | C# 提交分析器 |
| `scripts/analyze_commits.ps1` | 提交分析 PowerShell 脚本 |
| `scripts/analyze_commits.py` | Python 提交分析脚本 |
| `scripts/analyze_git_commits.py` | Git 提交分析 Python 脚本 |
| `scripts/generate_commit_docs.py` | 生成提交 Markdown 文档 |
| `scripts/generate_commit_reports.py` | 生成提交报告 |
#### 构建和发布脚本
| 文件 | 变更说明 |
|------|----------|
| `LanMountainDesktop/scripts/package.ps1` | 包脚本更新 |
| `LanMountainDesktop/scripts/Optimize-PublishPayload.ps1` | 新增优化发布脚本203 行) |
### 4. 文档文件
| 文件 | 变更 | 说明 |
|------|------|------|
| `docs/ARCHITECTURE.md` | +34 行 | 架构文档更新 |
| `docs/LAUNCHER.md` | +2 行 | 启动器文档更新 |
| `docs/LAUNCHER_COORDINATOR.md` | +11 行 | 启动器协调器文档 |
| `docs/PLUGIN_SDK_V5_MIGRATION.md` | +14 行 | 插件 SDK v5 迁移文档 |
| `docs/VISUAL_SPEC.md` | +8 行 | 视觉规范文档 |
| `docs/ai/CODEBASE_MAP.md` | +1 行 | 代码库地图更新 |
| `docs/ai/SETTINGS_WINDOW_DESIGN.md` | +48 行 | 设置窗口设计文档(新增) |
| `docs/auto_commit_md/20260518_93758fc0.md` | +321 行 | 自动提交分析文档 |
| `SECURITY_AUDIT_REPORT.md` | +196 行 | 安全审计报告(新增) |
| `CODE_WIKI.md` | 新增 | 综合代码维基文档 |
| `design.md` | +2 行 | 设计文档更新 |
### 5. 配置文件
| 文件 | 变更说明 |
|------|----------|
| `NuGet.Config` | +7 行,新增本地 NuGet 包文件夹配置 |
| `LanMountainDesktop/HostApp.csproj` | 添加 Lib.Harmony.Thin 包引用 |
| `LanMountainDesktop/LanMountainDesktop.csproj` | 添加 PostHog 包更新到 2.6.0 |
| `LanMountainDesktop/plugins/PluginLoader.cs` | +61 行,插件加载器更新 |
| `LanMountainDesktop/plugins/PluginRuntimeService.cs` | +38 行,新增插件运行时服务 |
### 6. Mockup 和原型文件
| 文件 | 说明 |
|------|------|
| `mocks/class-schedule-mock.html` | +459 行,课程表 Mockup |
| `mocks/weather-widget-mock.html` | +209 行,天气组件 Mockup |
| `mockup-noise-level.html` | +898 行,噪音级别 Mockup |
## 🔍 代码审查要点
### ✅ 优点
1. **功能完整性**:此次合并涵盖了设置系统的多个重要方面,包括 UI 改进、本地化、搜索功能、数据管理等,是一个全面的更新。
2. **代码质量**
- 添加了大量的单元测试
- 引入了渲染门控机制,避免不必要的重绘,提升性能
- 使用 MaterialColorSnapshot 作为统一的数据源,简化了主题管理
3. **第三方库集成**
- 引入 DotNetCampus.InkCanvas 库,提供了完整的墨迹画布功能
- 使用 Harmony 进行系统级修补,提供了更灵活的系统集成方式
4. **安全性**
- 添加了安全审计报告
- 改进了 IPC 通信的健壮性(退避、重试、资源释放)
5. **用户体验**
- Fluent Shell 设计语言的应用,使设置窗口更加现代化
- 添加了设置搜索功能,提升了可访问性
- 数据存储管理功能让用户可以更好地管理应用空间
### ⚠️ 需要注意的点
1. **合并提交风险**
- 这是一个大型合并提交904 个文件),增加了代码审查的难度
- 建议:未来考虑拆分为更小的、功能明确的合并请求
2. **二进制文件**
- `diff.txt` 是二进制文件303KB可能是补丁或差异文件
- 建议:检查 .gitattributes 确保二进制文件处理正确
3. **大量文件变更**
- 78,048 行新增代码是一次性引入的,虽然功能完整,但风险集中
- 建议:确保有足够的测试覆盖,特别是对新集成的 DotNetCampus.InkCanvas 库
4. **本地化工作量**
- 添加了多个语言的本地化字符串
- 建议:验证所有新增字符串的翻译准确性和一致性
5. **性能考虑**
- 透明覆盖窗口有 1,258 行代码变更,需要特别关注渲染性能
- 建议:进行性能测试,特别是在不同硬件配置下
6. **依赖管理**
- 添加了新的第三方库依赖
- 建议:评估库的维护状态和长期支持情况
### 📌 建议后续行动
1. **测试覆盖**:确保对新功能有充分的单元测试和集成测试
2. **文档更新**:更新用户文档以反映新的设置选项和功能
3. **性能监控**:部署后监控应用性能,特别是启动时间和内存使用
4. **用户体验反馈**:收集用户对新设置界面和搜索功能的反馈
5. **版本发布说明**:准备详细的发布说明,记录所有新增功能和重大变更
## 📈 影响范围评估
| 影响领域 | 评级 | 说明 |
|----------|------|------|
| **用户体验** | 🟢 高正面 | Fluent Shell 设计、搜索功能、本地化 |
| **系统性能** | 🟢 正面 | 渲染门控、图表缓存、IPC 优化 |
| **代码架构** | 🟢 正面 | MaterialColorSnapshot 统一数据源 |
| **功能完整性** | 🟢 正面 | InkCanvas 集成、数据管理 |
| **安全性** | 🟢 正面 | IPC 健壮性改进、安全审计 |
| **维护成本** | 🟡 中性 | 新增第三方库依赖需要维护 |
---
*此报告由自动提交分析工具生成*
*生成时间: 2026-05-19 10:30:00*
*工具版本: Git Commit Analyzer v1.0*

View File

@@ -0,0 +1,259 @@
# Git 提交分析报告
## 📋 提交基本信息
| 属性 | 值 |
|------|-----|
| **完整哈希** | `ac8ee8dc5467d51cc09ad614aac2c783a6c5dad5` |
| **短哈希** | `ac8ee8d` |
| **作者** | lincube <lincube3@hotmail.com> |
| **提交日期** | 2026-05-23 |
| **提交时间** | 02:49:01 |
| **时区** | +0800 |
| **提交类型** | 🟢 常规提交 |
## 📝 提交信息摘要
```
changed.优化了天气组件
```
**详细分析**
本次提交主要针对天气组件进行了 UI 优化,重点解决了文本显示被截断的问题。通过调整 `ClipToBounds` 属性和添加适当的 `Padding`,改善了天气组件在各种分辨率下的文本可见性。
## 📊 变更统计
| 统计项 | 数值 |
|--------|------|
| **变更文件总数** | 15 个文件 |
| **新增代码行数** | +301 行 |
| **删除代码行数** | -19 行 |
| **净增代码行数** | +282 行 |
### 变更文件类型分布
| 文件类型 | 文件数量 | 说明 |
|----------|----------|------|
| **C# 服务文件** | 6 个 | 后端服务逻辑 |
| **XAML 视图文件** | 6 个 | UI 组件定义 |
| **XAML.CS 代码文件** | 4 个 | 视图代码逻辑 |
| **文档文件** | 1 个 | 自动生成的分析文档 |
## 📂 详细变更分析
### 1. 服务层文件变更 (Services)
#### LanMountainDesktop/Services/AppSettingsService.cs
- **变更类型**: 修改
- **变更行数**: ±6 行
- **变更说明**: 更新应用设置服务,可能涉及天气相关配置的调整
#### LanMountainDesktop/Services/FusedDesktopLayoutService.cs
- **变更类型**: 修改
- **变更行数**: ±5 行
- **变更说明**: 融合桌面布局服务更新
#### LanMountainDesktop/Services/LauncherSettingsService.cs
- **变更类型**: 修改
- **变更行数**: ±4 行
- **变更说明**: 启动器设置服务更新
#### LanMountainDesktop/Services/Settings/SettingsService.cs
- **变更类型**: 修改
- **变更行数**: ±4 行
- **变更说明**: 通用设置服务更新
#### LanMountainDesktop/Services/ZhiJiaoHubCacheService.cs
- **变更类型**: 修改
- **变更行数**: ±8 行
- **变更说明**: 智慧教育缓存服务更新(变更较大)
### 2. ClockAirApp 相关文件
#### LanMountainDesktop/ClockAirApp/ClockAirAppSettingsStore.cs
- **变更类型**: 修改
- **变更行数**: ±4 行
- **变更说明**: 时钟应用设置存储更新
### 3. 天气组件核心变更 (Weather Widgets) 🔥
这是本次提交的核心变更区域,涉及多个天气组件的 UI 优化:
#### ExtendedWeatherWidget 扩展天气组件
| 文件 | 变更类型 | 变更说明 |
|------|----------|----------|
| `Views/Components/ExtendedWeatherWidget.axaml` | 修改 | 添加 `ClipToBounds="False"` |
| `Views/Components/ExtendedWeatherWidget.axaml.cs` | 修改 | 动态生成 TextBlock 时添加 `ClipToBounds = false` |
**关键代码变更**
```csharp
// ExtendedWeatherWidget.axaml.cs
// 变更前
inner.Children.Add(new TextBlock { ... });
// 变更后
inner.Children.Add(new TextBlock {
Text = item.Value,
ClipToBounds = false // 允许文本溢出显示
});
```
#### HourlyWeatherWidget 小时天气组件
| 文件 | 变更类型 | 变更说明 |
|------|----------|----------|
| `Views/Components/HourlyWeatherWidget.axaml` | 修改 | 添加 `ClipToBounds="False"` |
| `Views/Components/HourlyWeatherWidget.axaml.cs` | 修改 | 添加 `ClipToBounds = false` |
#### MultiDayWeatherWidget 多日天气组件
| 文件 | 变更类型 | 变更说明 |
|------|----------|----------|
| `Views/Components/MultiDayWeatherWidget.axaml` | 修改 | 添加 `ClipToBounds="False" Padding="0,1,0,0"` |
| `Views/Components/MultiDayWeatherWidget.axaml.cs` | 修改 | 多处 TextBlock 添加 `ClipToBounds = false` |
**关键 UI 调整**
```xml
<!-- MultiDayWeatherWidget.axaml -->
<!-- 变更前 -->
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="42" FontWeight="Bold" />
<!-- 变更后 -->
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="42" FontWeight="Bold"
ClipToBounds="False" Padding="0,1,0,0" />
```
**代码层变更**
```csharp
// 多处高低温 TextBlock 添加了 ClipToBounds = false
new TextBlock {
Text = FormatTemperature(item.HighTemperatureC),
ClipToBounds = false // 允许温度值完整显示
}
```
#### WeatherClockWidget 天气时钟组件
| 文件 | 变更类型 | 变更说明 |
|------|----------|----------|
| `Views/Components/WeatherClockWidget.axaml` | 修改 | 温度文本块添加 `ClipToBounds="False"` |
#### WeatherWidget 主天气组件
| 文件 | 变更类型 | 变更说明 |
|------|----------|----------|
| `Views/Components/WeatherWidget.axaml` | 修改 | 温度文本块添加 `ClipToBounds="False" Padding="0,2,0,0"` |
**WeatherWidget 详细变更**
```xml
<!-- 变更前 -->
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="72" FontWeight="Bold" />
<!-- 变更后 -->
<TextBlock x:Name="TemperatureTextBlock" Text="--°" FontSize="72" FontWeight="Bold"
ClipToBounds="False" Padding="0,2,0,0" />
```
- 添加了 `ClipToBounds="False"` 以允许文本溢出显示
- 添加了 `Padding="0,2,0,0"` 调整文本垂直位置,避免上下被截断
### 4. 文档变更
#### docs/auto_commit_md/20260519_7a70476.md
- **变更类型**: 新增
- **变更行数**: +269 行
- **变更说明**: 自动生成的历史提交分析文档
## 🔍 代码审查要点
### ✅ 优点
1. **UI 修复明确**
- 清楚地识别了文本截断问题
- 使用 `ClipToBounds="False"` 是解决文本溢出显示的标准做法
- 添加适当的 `Padding` 调整了文本位置,避免被边框裁切
2. **一致性处理**
- 在所有相关天气组件中都应用了相同的修复策略
- 保持了 UI 调整的一致性,包括 XAML 和 C# 代码
3. **性能考虑**
- `ClipToBounds="False"` 的使用是局部的、针对性的
- 不会对整体渲染性能产生显著影响
4. **向后兼容**
- 修改仅影响文本显示方式,不影响数据逻辑
- 用户不会感知到底层数据的变化
### ⚠️ 需要注意的点
1. **字体渲染差异**
- `Padding`0,1,0,0 或 0,2,0,0可能需要根据不同字体进行微调
- 建议在不同字体、不同 DPI 设置下测试显示效果
2. **文本溢出风险**
- 虽然允许文本溢出,但需要确保有足够的容器空间
- 极端情况下文本可能仍然会被父容器裁切
3. **多语言支持**
- 不同的语言文本长度不同,需要确保各种语言的文本都能正确显示
- 建议测试中文、英文、日文等多种语言的天气描述文本
4. **动态内容**
- 温度值在不同单位°C/°F下长度可能不同
- 需要测试各种温度值的显示效果
### 📌 建议后续行动
1. **UI 测试**:在多种分辨率和 DPI 设置下测试天气组件
2. **多语言测试**:确保各种语言环境下文本显示正常
3. **边界测试**:测试温度值在极端情况下的显示(如 -40°C 或 50°C
4. **性能监控**:监控修改后的渲染性能,确保没有性能退化
## 📈 技术分析
### 变更的技术背景
在 Avalonia UI 框架中,`ClipToBounds` 属性默认值为 `true`,这会导致子元素在超出容器边界时被裁切。对于天气组件中的温度文本、天气描述等动态内容,这种裁切会导致文本显示不完整。
### 解决方案的有效性
| 解决方案 | 效果 | 风险 |
|----------|------|------|
| `ClipToBounds="False"` | ✅ 允许文本完整显示 | ⚠️ 可能溢出到其他元素 |
| `Padding="0,2,0,0"` | ✅ 调整文本位置 | ⚠️ 需要精确调整数值 |
### 相关设计模式
本次修改涉及以下 UI 设计考虑:
- **溢出处理**:在固定尺寸容器中显示动态内容
- **对齐策略**:通过 Padding 微调元素位置
- **层级管理**:避免文本溢出影响其他 UI 元素
## 📊 影响范围评估
| 影响领域 | 评级 | 说明 |
|----------|------|------|
| **用户体验** | 🟢 正面 | 修复了文本截断问题,提升可读性 |
| **系统性能** | 🟢 无影响 | UI 属性调整,无性能影响 |
| **代码维护性** | 🟢 正面 | 统一了天气组件的文本显示处理方式 |
| **兼容性** | 🟢 正面 | 向后兼容,无破坏性变更 |
| **测试覆盖率** | 🟡 需补充 | 建议增加 UI 显示测试用例 |
---
## 📋 总结
本次提交 `ac8ee8d` 主要解决了天气组件的文本显示问题,通过在所有相关组件中添加 `ClipToBounds="False"` 和适当的 `Padding`,确保了温度、天气描述等文本能够完整显示。
**关键成果**
- ✅ 修复了 5 个天气组件的文本截断问题
- ✅ 保持了一致的 UI 处理方式
- ✅ 代码变更精确、风险低
**建议关注**
- 多语言文本显示效果
- 不同 DPI 下的字体渲染
- 极端温度值的显示
---
*此报告由自动提交分析工具生成*
*生成时间: 2026-05-23*
*工具版本: Git Commit Analyzer v1.0*