Compare commits

..

3 Commits

Author SHA1 Message Date
lincube
8c88e305ee fix.在线安装器,启动器 2026-06-05 11:08:11 +08:00
lincube
bb4e90ea8d fix.依旧在调整我们的在线安装器 2026-06-03 12:32:56 +08:00
lincube
75c7aece4f fix.在线安装器 2026-06-03 07:30:54 +08:00
51 changed files with 2395 additions and 598 deletions

View File

@@ -164,3 +164,25 @@
* ~~搜索功能~~根据Windows 11小组件面板设计暂不提供搜索功能 * ~~搜索功能~~根据Windows 11小组件面板设计暂不提供搜索功能
## 2026-06 Fusion Desktop Editing Update
### Requirement: Library window controls edit mode
The fused desktop component library is the edit-mode boundary. Opening the independent Fluent-style library window enters fused desktop edit mode. Closing that window exits edit mode. While edit mode is active, component windows can be moved but their inner component UI is not hit-test interactive. After the library closes, component windows cannot be moved and their normal component UI interaction resumes.
### Requirement: Add button keeps the library open
The selected preview component can only be added through the library add button. Adding a component places it at the center of the library window's current screen and keeps the library open so the user can continue adding and placing components. Components must not be dragged out of the library.
### Requirement: Preview swipe changes the selected component
The right-side preview area maintains a selected component index for the current category. Selecting a category chooses the first component in that category. Vertical touch-style swipes in the preview area switch to the previous or next component in the same category with a 48 DIP threshold and wrap at the ends. Mouse wheel and Up/Down keys may provide equivalent desktop input.
### Requirement: Reuse existing desktop grid settings
Fusion desktop placement must reuse the existing Lan Mountain desktop grid settings exposed by the components settings page: short-side cell count, spacing preset, and desktop edge inset. No independent fused-desktop grid configuration source should be introduced. Adding a component and releasing a dragged component both resolve the current grid through the existing grid settings service.
### Requirement: Snap individual windows to the grid
Fusion desktop no longer displays or depends on a full-screen grid window. Each component window uses the grid only as an individual placement constraint. Dragging remains free while the pointer is moving; on release, the window snaps to the nearest cell that can contain its saved cell span, clamps inside the current screen grid, and persists `X`, `Y`, `GridRow`, `GridColumn`, `GridWidthCells`, and `GridHeightCells`.

View File

@@ -16,10 +16,13 @@ Make the Settings > Update page the single user-facing control surface for the h
- The page displays whether the current payload is an incremental update or reinstall/full installer. - The page displays whether the current payload is an incremental update or reinstall/full installer.
- The page exposes pause, resume, and cancel actions for resumable downloads and install recovery. - The page exposes pause, resume, and cancel actions for resumable downloads and install recovery.
- Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version. - Existing PloNDS/FileMap incremental update behavior remains, but update apply and rollback ownership belongs to the Host. Launcher only selects and starts the current app version.
- The page follows ClassIsland's durable-status vs working-status split: a transient check/download error must not be treated as an available update, and available/downloaded actions must stay visible while the worker is idle.
## Acceptance ## Acceptance
- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel. - `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel.
- `UpdateSettingsState` persists forced reinstall alongside other update preferences. - `UpdateSettingsState` persists forced reinstall alongside other update preferences.
- Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply. - Automatic startup checks skip manual mode, download in silent download/silent install modes, and leave installation to explicit user action or exit-time apply.
- After a successful check with an available update, the download action is visible even though no transfer is running.
- After a failed check, no download action is shown unless a valid update is still pending.
- Build succeeds for `LanMountainDesktop.slnx`. - Build succeeds for `LanMountainDesktop.slnx`.

View File

@@ -5,19 +5,67 @@
x:Class="LanDesktopPLONDS.Installer.App" x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default"> RequestedThemeVariant="Default">
<Application.Resources> <Application.Resources>
<ResourceDictionary>
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily> <FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">6</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusLg">10</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusLg">8</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusComponent">12</CornerRadius> <CornerRadius x:Key="DesignCornerRadiusComponent">8</CornerRadius>
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F7F9FC" />
<SolidColorBrush x:Key="InstallerTintBrush" Color="#DDF8FAFF" /> <ResourceDictionary.ThemeDictionaries>
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#F9FFFFFF" /> <ResourceDictionary x:Key="Default">
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#22000000" /> <SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="InstallerSecondaryTextBrush" Color="#A0000000" /> <SolidColorBrush x:Key="InstallerPaneBackgroundBrush" Color="#F9F9F9" />
<SolidColorBrush x:Key="InstallerContentBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSurfaceAltBrush" Color="#F7F7F7" />
<SolidColorBrush x:Key="InstallerSubtleFillBrush" Color="#F5F5F5" />
<SolidColorBrush x:Key="InstallerSubtleFillHoverBrush" Color="#EFEFEF" />
<SolidColorBrush x:Key="InstallerSubtleFillPressedBrush" Color="#E5E5E5" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#14000000" />
<SolidColorBrush x:Key="InstallerStrongBorderBrush" Color="#29000000" />
<SolidColorBrush x:Key="InstallerTextPrimaryBrush" Color="#1A1A1A" />
<SolidColorBrush x:Key="InstallerTextSecondaryBrush" Color="#5D5D5D" />
<SolidColorBrush x:Key="InstallerTextTertiaryBrush" Color="#6B6B6B" />
<SolidColorBrush x:Key="InstallerDisabledTextBrush" Color="#8A8A8A" />
<SolidColorBrush x:Key="InstallerAccentBrush" Color="#0067C0" />
<SolidColorBrush x:Key="InstallerAccentHoverBrush" Color="#005A9E" />
<SolidColorBrush x:Key="InstallerAccentPressedBrush" Color="#004578" />
<SolidColorBrush x:Key="InstallerOnAccentBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="InstallerSuccessBrush" Color="#0F7B0F" />
<SolidColorBrush x:Key="InstallerErrorBrush" Color="#B3261E" />
<SolidColorBrush x:Key="InstallerErrorBackgroundBrush" Color="#FFF4F3" />
<SolidColorBrush x:Key="InstallerErrorBorderBrush" Color="#F3B8B3" />
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#202020" />
<SolidColorBrush x:Key="InstallerPaneBackgroundBrush" Color="#272727" />
<SolidColorBrush x:Key="InstallerContentBackgroundBrush" Color="#1B1B1B" />
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#2B2B2B" />
<SolidColorBrush x:Key="InstallerSurfaceAltBrush" Color="#252525" />
<SolidColorBrush x:Key="InstallerSubtleFillBrush" Color="#333333" />
<SolidColorBrush x:Key="InstallerSubtleFillHoverBrush" Color="#3A3A3A" />
<SolidColorBrush x:Key="InstallerSubtleFillPressedBrush" Color="#444444" />
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#24FFFFFF" />
<SolidColorBrush x:Key="InstallerStrongBorderBrush" Color="#3DFFFFFF" />
<SolidColorBrush x:Key="InstallerTextPrimaryBrush" Color="#F3F3F3" />
<SolidColorBrush x:Key="InstallerTextSecondaryBrush" Color="#C7C7C7" />
<SolidColorBrush x:Key="InstallerTextTertiaryBrush" Color="#A0A0A0" />
<SolidColorBrush x:Key="InstallerDisabledTextBrush" Color="#7A7A7A" />
<SolidColorBrush x:Key="InstallerAccentBrush" Color="#60CDFF" />
<SolidColorBrush x:Key="InstallerAccentHoverBrush" Color="#8AD7FF" />
<SolidColorBrush x:Key="InstallerAccentPressedBrush" Color="#4CC2FF" />
<SolidColorBrush x:Key="InstallerOnAccentBrush" Color="#000000" />
<SolidColorBrush x:Key="InstallerSuccessBrush" Color="#6CCB5F" />
<SolidColorBrush x:Key="InstallerErrorBrush" Color="#FFB4AB" />
<SolidColorBrush x:Key="InstallerErrorBackgroundBrush" Color="#442726" />
<SolidColorBrush x:Key="InstallerErrorBorderBrush" Color="#8C4A45" />
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
@@ -29,9 +77,14 @@
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" /> <Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
</Style> </Style>
<Style Selector="fi|FluentIcon"> <Style Selector="fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="VerticalAlignment" Value="Center" /> <Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" /> <Setter Property="HorizontalAlignment" Value="Center" />
</Style> </Style>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="FontWeight" Value="Normal" />
</Style>
<Style Selector="Button.titlebar-icon-button"> <Style Selector="Button.titlebar-icon-button">
<Setter Property="Width" Value="40" /> <Setter Property="Width" Value="40" />
<Setter Property="Height" Value="40" /> <Setter Property="Height" Value="40" />
@@ -41,19 +94,109 @@
<Setter Property="BorderThickness" Value="0" /> <Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" /> <Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style> </Style>
<Style Selector="Button.titlebar-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.titlebar-icon-button:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerStrongBorderBrush}" />
</Style>
<Style Selector="StackPanel.installer-page-container"> <Style Selector="StackPanel.installer-page-container">
<Setter Property="Spacing" Value="18" /> <Setter Property="Spacing" Value="20" />
<Setter Property="Margin" Value="0,20,0,24" /> <Setter Property="Margin" Value="0" />
<Setter Property="MaxWidth" Value="860" /> <Setter Property="MaxWidth" Value="780" />
</Style> </Style>
<Style Selector="TextBlock.page-title-text"> <Style Selector="TextBlock.page-title-text">
<Setter Property="FontSize" Value="28" /> <Setter Property="FontSize" Value="30" />
<Setter Property="FontWeight" Value="SemiBold" /> <Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LineHeight" Value="38" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style> </Style>
<Style Selector="TextBlock.page-description-text"> <Style Selector="TextBlock.page-description-text">
<Setter Property="FontSize" Value="14" /> <Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" /> <Setter Property="LineHeight" Value="21" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
</Style> </Style>
<Style Selector="TextBlock.caption-text">
<Setter Property="FontSize" Value="12" />
<Setter Property="LineHeight" Value="17" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextTertiaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Button.primary-command">
<Setter Property="Background" Value="{DynamicResource InstallerAccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="18,9" />
<Setter Property="MinHeight" Value="38" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.primary-command:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerAccentHoverBrush}" />
</Style>
<Style Selector="Button.primary-command:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerAccentPressedBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.primary-command fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style>
<Style Selector="Button.primary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerOnAccentBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.primary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.secondary-command">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="16,9" />
<Setter Property="MinHeight" Value="38" />
</Style>
<Style Selector="Button.secondary-command:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
</Style>
<Style Selector="Button.secondary-command TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="Button.secondary-command fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled TextBlock">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.secondary-command:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="TextBox">
<Setter Property="MinHeight" Value="38" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerStrongBorderBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
</Style>
<Style Selector="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
</Style>
<Style Selector="ProgressBar">
<Setter Property="Foreground" Value="{DynamicResource InstallerAccentBrush}" />
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="MinHeight" Value="6" />
</Style>
</Application.Styles> </Application.Styles>
</Application> </Application>

View File

@@ -22,10 +22,12 @@ public partial class App : Application
var privacyIdentity = new PrivacyDeviceIdentityProvider(); var privacyIdentity = new PrivacyDeviceIdentityProvider();
var installService = OnlineInstallService.CreateDefault(privacyIdentity); var installService = OnlineInstallService.CreateDefault(privacyIdentity);
var consentStore = new InstallerPrivacyConsentStore(); var consentStore = new InstallerPrivacyConsentStore();
desktop.MainWindow = new MainWindow var mainWindow = new MainWindow
{ {
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore) DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
}; };
desktop.MainWindow = mainWindow;
mainWindow.Show();
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();

View File

@@ -10,7 +10,8 @@
<PackageVersion>$(Version)</PackageVersion> <PackageVersion>$(Version)</PackageVersion>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault> <AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<ApplicationIcon>Assets\logo.ico</ApplicationIcon> <ApplicationIcon>Assets\logo.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest Condition="'$(Configuration)' == 'Debug'">app.Debug.manifest</ApplicationManifest>
<ApplicationManifest Condition="'$(Configuration)' != 'Debug'">app.manifest</ApplicationManifest>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View File

@@ -56,7 +56,7 @@ internal sealed class FilesPackageInstaller
null)); null));
ActivateInitialDeployment(launcherRoot, targetDeployment); ActivateInitialDeployment(launcherRoot, targetDeployment);
CreateWindowsShortcutsIfAvailable(launcherRoot, options.CreateDesktopShortcut); CreateWindowsShortcutsIfAvailable(launcherRoot, options);
progress?.Report(new InstallerDeployProgress( progress?.Report(new InstallerDeployProgress(
"Completed", "Completed",
@@ -273,7 +273,7 @@ internal sealed class FilesPackageInstaller
return name is ".current" or ".partial" or ".destroy"; return name is ".current" or ".partial" or ".destroy";
} }
private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, bool createDesktopShortcut) private static void CreateWindowsShortcutsIfAvailable(string launcherRoot, OnlineInstallOptions options)
{ {
try try
{ {
@@ -315,20 +315,26 @@ internal sealed class FilesPackageInstaller
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url"); var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
WriteUrlShortcut(shortcutPath, launcherPath); WriteUrlShortcut(shortcutPath, launcherPath);
if (!createDesktopShortcut) if (options.CreateDesktopShortcut)
{ {
return;
}
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory); var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
if (string.IsNullOrWhiteSpace(desktop)) if (!string.IsNullOrWhiteSpace(desktop))
{ {
return;
}
Directory.CreateDirectory(desktop); Directory.CreateDirectory(desktop);
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath); WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
} }
}
if (options.CreateStartupShortcut)
{
var startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
if (!string.IsNullOrWhiteSpace(startup))
{
Directory.CreateDirectory(startup);
WriteUrlShortcut(Path.Combine(startup, "LanMountainDesktop.url"), launcherPath);
}
}
}
catch catch
{ {
// Shortcut creation is best-effort; deployment itself must remain usable without shell integration. // Shortcut creation is best-effort; deployment itself must remain usable without shell integration.

View File

@@ -2,5 +2,10 @@ using System.Text.Json.Serialization;
namespace LanDesktopPLONDS.Installer.Services; namespace LanDesktopPLONDS.Installer.Services;
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip,
AllowTrailingCommas = true)]
[JsonSerializable(typeof(InstallerPlondsManifest))] [JsonSerializable(typeof(InstallerPlondsManifest))]
internal sealed partial class InstallerJsonContext : JsonSerializerContext; internal sealed partial class InstallerJsonContext : JsonSerializerContext;

View File

@@ -2,6 +2,8 @@ namespace LanDesktopPLONDS.Installer.Services;
public static class InstallerPathGuard public static class InstallerPathGuard
{ {
public const string ApplicationDirectoryName = "LanMountainDesktop";
public static string GetDefaultInstallPath() public static string GetDefaultInstallPath()
{ {
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
@@ -12,7 +14,29 @@ public static class InstallerPathGuard
"Programs"); "Programs");
} }
return Path.Combine(programFiles, "LanMountainDesktop"); return Path.Combine(programFiles, ApplicationDirectoryName);
}
public static string GetInstallPathForSelectedFolder(string selectedFolder)
{
if (string.IsNullOrWhiteSpace(selectedFolder))
{
throw new ArgumentException("Selected folder is required.", nameof(selectedFolder));
}
var fullPath = Path.GetFullPath(selectedFolder.Trim());
var root = Path.GetPathRoot(fullPath);
var trimmedPath = fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var trimmedRoot = root?.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var basePath = string.Equals(trimmedPath, trimmedRoot, StringComparison.OrdinalIgnoreCase)
? fullPath
: trimmedPath;
var selectedName = Path.GetFileName(trimmedPath);
var installPath = string.Equals(selectedName, ApplicationDirectoryName, StringComparison.OrdinalIgnoreCase)
? trimmedPath
: Path.Combine(basePath, ApplicationDirectoryName);
return NormalizeInstallPath(installPath);
} }
public static string NormalizeInstallPath(string path) public static string NormalizeInstallPath(string path)

View File

@@ -10,7 +10,7 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
{ {
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL"; private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL"; private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json"; private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json"; private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
@@ -78,6 +78,15 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
{ {
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString(); var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full"); var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
var urls = new[] { candidate.FilesZipUrl }
.Concat(InstallerPlondsUrlResolver.ResolveFilesZipUrls(candidate.Manifest, candidate.Source))
.DistinctBy(uri => uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase)
.ToArray();
Exception? lastError = null;
foreach (var filesZipUrl in urls)
{
cancellationToken.ThrowIfCancellationRequested();
if (Directory.Exists(packageRoot)) if (Directory.Exists(packageRoot))
{ {
Directory.Delete(packageRoot, recursive: true); Directory.Delete(packageRoot, recursive: true);
@@ -87,9 +96,12 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
var zipPath = Path.Combine(packageRoot, "Files.zip"); var zipPath = Path.Combine(packageRoot, "Files.zip");
var extractDirectory = Path.Combine(packageRoot, "Files"); var extractDirectory = Path.Combine(packageRoot, "Files");
Directory.CreateDirectory(extractDirectory); Directory.CreateDirectory(extractDirectory);
var attempt = candidate with { FilesZipUrl = filesZipUrl };
await DownloadToFileAsync(candidate, zipPath, progress, cancellationToken).ConfigureAwait(false); try
await VerifyPackageAsync(zipPath, candidate.Manifest, candidate.FilesZipUrl, cancellationToken).ConfigureAwait(false); {
await DownloadToFileAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false);
await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false);
ExtractZip(zipPath, extractDirectory); ExtractZip(zipPath, extractDirectory);
progress?.Report(new InstallerDeployProgress( progress?.Report(new InstallerDeployProgress(
@@ -103,6 +115,18 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest); return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
} }
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
lastError = ex;
}
}
throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError);
}
public static long EstimateInstallBytes(InstallerPlondsManifest manifest) public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
{ {
@@ -140,6 +164,8 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
var totalBytes = response.Content.Headers.ContentLength; var totalBytes = response.Content.Headers.ContentLength;
var partialPath = $"{destinationPath}.partial"; var partialPath = $"{destinationPath}.partial";
long downloaded = 0; long downloaded = 0;
try
{
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
await using (var target = File.Create(partialPath)) await using (var target = File.Create(partialPath))
{ {
@@ -168,6 +194,14 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
File.Move(partialPath, destinationPath, overwrite: true); File.Move(partialPath, destinationPath, overwrite: true);
} }
finally
{
if (File.Exists(partialPath))
{
File.Delete(partialPath);
}
}
}
private static async Task VerifyPackageAsync( private static async Task VerifyPackageAsync(
string zipPath, string zipPath,

View File

@@ -63,9 +63,11 @@ public sealed record OnlineInstallPackageInfo(
Uri FilesZipUrl, Uri FilesZipUrl,
long EstimatedBytes); long EstimatedBytes);
public sealed record OnlineInstallOptions(bool CreateDesktopShortcut) public sealed record OnlineInstallOptions(bool CreateDesktopShortcut, bool CreateStartupShortcut)
{ {
public static OnlineInstallOptions Default { get; } = new(CreateDesktopShortcut: false); public static OnlineInstallOptions Default { get; } = new(
CreateDesktopShortcut: false,
CreateStartupShortcut: false);
} }
internal sealed record InstallerPlondsCandidate( internal sealed record InstallerPlondsCandidate(

View File

@@ -1,4 +1,5 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models; using LanDesktopPLONDS.Installer.Models;
namespace LanDesktopPLONDS.Installer.ViewModels; namespace LanDesktopPLONDS.Installer.ViewModels;
@@ -6,7 +7,7 @@ namespace LanDesktopPLONDS.Installer.ViewModels;
public sealed partial class InstallerStepViewModel( public sealed partial class InstallerStepViewModel(
InstallerStepId stepId, InstallerStepId stepId,
string title, string title,
string iconKey) : ObservableObject Icon icon) : ObservableObject
{ {
[ObservableProperty] [ObservableProperty]
private bool _isUnlocked; private bool _isUnlocked;
@@ -18,5 +19,5 @@ public sealed partial class InstallerStepViewModel(
public string Title { get; } = title; public string Title { get; } = title;
public string IconKey { get; } = iconKey; public Icon Icon { get; } = icon;
} }

View File

@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.Diagnostics; using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using FluentIcons.Common;
using LanDesktopPLONDS.Installer.Models; using LanDesktopPLONDS.Installer.Models;
using LanDesktopPLONDS.Installer.Services; using LanDesktopPLONDS.Installer.Services;
using LanMountainDesktop.Shared.Contracts.Privacy; using LanMountainDesktop.Shared.Contracts.Privacy;
@@ -14,7 +15,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
private readonly IPrivacyDeviceIdentityProvider _privacyIdentity; private readonly IPrivacyDeviceIdentityProvider _privacyIdentity;
private readonly InstallerPrivacyConsentStore _privacyConsentStore; private readonly InstallerPrivacyConsentStore _privacyConsentStore;
private CancellationTokenSource? _installCts; private CancellationTokenSource? _installCts;
private bool _isNavigatingInternally;
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(NextCommand))] [NotifyCanExecuteChangedFor(nameof(NextCommand))]
@@ -61,13 +61,15 @@ public sealed partial class MainWindowViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))] [NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
private bool _isInstalling; private bool _isInstalling;
[ObservableProperty] [ObservableProperty]
private bool _createDesktopShortcut; private bool _createDesktopShortcut;
[ObservableProperty] [ObservableProperty]
private InstallerStepViewModel? _selectedStep; private bool _createStartupShortcut;
public MainWindowViewModel( public MainWindowViewModel(
IOnlineInstallService installService, IOnlineInstallService installService,
@@ -79,14 +81,13 @@ public sealed partial class MainWindowViewModel : ObservableObject
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore(); _privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
Steps = Steps =
[ [
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", "Play"), new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", Icon.Play),
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "Folder"), new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", Icon.Folder),
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "Info"), new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", Icon.Info),
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "Apps"), new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", Icon.ArrowDownload),
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "Circle") new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle)
]; ];
SyncSteps(); SyncSteps();
SelectedStep = Steps[0];
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId(); DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview); PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview);
} }
@@ -109,6 +110,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
public bool IsCompleteStep => CurrentStep == InstallerStepId.Complete; public bool IsCompleteStep => CurrentStep == InstallerStepId.Complete;
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling; public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling;
public bool CanGoNext => CurrentStep switch public bool CanGoNext => CurrentStep switch
@@ -145,26 +148,24 @@ public sealed partial class MainWindowViewModel : ObservableObject
SyncSteps(); SyncSteps();
} }
partial void OnErrorMessageChanged(string? value)
{
_ = value;
OnPropertyChanged(nameof(HasError));
}
partial void OnMaxUnlockedStepChanged(InstallerStepId value) partial void OnMaxUnlockedStepChanged(InstallerStepId value)
{ {
_ = value; _ = value;
SyncSteps(); SyncSteps();
} }
partial void OnSelectedStepChanged(InstallerStepViewModel? value) partial void OnIsInstallingChanged(bool value)
{ {
if (_isNavigatingInternally || value is null) _ = value;
{ OnPropertyChanged(nameof(CanGoBack));
return; OnPropertyChanged(nameof(CanGoNext));
} OnPropertyChanged(nameof(CanStartInstall));
if (value.StepId <= MaxUnlockedStep)
{
CurrentStep = value.StepId;
return;
}
SyncSteps();
} }
[RelayCommand(CanExecute = nameof(CanGoNext))] [RelayCommand(CanExecute = nameof(CanGoNext))]
@@ -198,24 +199,48 @@ public sealed partial class MainWindowViewModel : ObservableObject
[RelayCommand(CanExecute = nameof(CanGoBack))] [RelayCommand(CanExecute = nameof(CanGoBack))]
private void Back() private void Back()
{ {
if (IsInstalling)
{
return;
}
if (CurrentStep > InstallerStepId.Welcome) if (CurrentStep > InstallerStepId.Welcome)
{ {
CurrentStep -= 1; CurrentStep -= 1;
} }
} }
[RelayCommand]
private void SelectStep(InstallerStepViewModel? step)
{
if (step is null || IsInstalling || step.StepId > MaxUnlockedStep)
{
return;
}
CurrentStep = step.StepId;
}
[RelayCommand] [RelayCommand]
private async Task BrowseAsync() private async Task BrowseAsync()
{ {
ErrorMessage = null;
if (BrowseRequested is null) if (BrowseRequested is null)
{ {
return; return;
} }
try
{
var selected = await BrowseRequested(InstallPath); var selected = await BrowseRequested(InstallPath);
if (!string.IsNullOrWhiteSpace(selected)) if (!string.IsNullOrWhiteSpace(selected))
{ {
InstallPath = selected; InstallPath = InstallerPathGuard.GetInstallPathForSelectedFolder(selected);
}
}
catch (Exception ex)
{
ErrorMessage = $"选择安装位置失败:{ex.Message}";
} }
} }
@@ -230,7 +255,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
try try
{ {
var progress = new Progress<InstallerDeployProgress>(ApplyProgress); var progress = new Progress<InstallerDeployProgress>(ApplyProgress);
var options = new OnlineInstallOptions(CreateDesktopShortcut); var options = new OnlineInstallOptions(CreateDesktopShortcut, CreateStartupShortcut);
await _installService.InstallFreshAsync(InstallPath, options, progress, _installCts.Token); await _installService.InstallFreshAsync(InstallPath, options, progress, _installCts.Token);
UnlockAndNavigate(InstallerStepId.Complete); UnlockAndNavigate(InstallerStepId.Complete);
StatusText = "安装完成"; StatusText = "安装完成";
@@ -312,23 +337,11 @@ public sealed partial class MainWindowViewModel : ObservableObject
} }
private void SyncSteps() private void SyncSteps()
{
_isNavigatingInternally = true;
try
{ {
foreach (var step in Steps) foreach (var step in Steps)
{ {
step.IsUnlocked = step.StepId <= MaxUnlockedStep; step.IsUnlocked = step.StepId <= MaxUnlockedStep;
step.IsSelected = step.StepId == CurrentStep; step.IsSelected = step.StepId == CurrentStep;
if (step.StepId == CurrentStep && !ReferenceEquals(SelectedStep, step))
{
SelectedStep = step;
}
}
}
finally
{
_isNavigatingInternally = false;
} }
} }

View File

@@ -1,17 +1,18 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia" xmlns:fi="using:FluentIcons.Avalonia"
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels" xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow" x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
x:DataType="vm:MainWindowViewModel" x:DataType="vm:MainWindowViewModel"
Width="1080" Width="1040"
Height="720" Height="680"
MinWidth="860" MinWidth="900"
MinHeight="620" MinHeight="620"
CanResize="True" CanResize="True"
x:Name="Root"
Title="{Binding WindowTitle}" Title="{Binding WindowTitle}"
Background="Transparent" Background="Transparent"
TransparencyLevelHint="Mica, AcrylicBlur, None"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="48" ExtendClientAreaTitleBarHeightHint="48"
WindowDecorations="None"> WindowDecorations="None">
@@ -19,49 +20,129 @@
<Style Selector="Grid.step-page"> <Style Selector="Grid.step-page">
<Setter Property="IsVisible" Value="False" /> <Setter Property="IsVisible" Value="False" />
</Style> </Style>
<Style Selector="Grid.step-page.visible">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="TextBlock.muted"> <Style Selector="TextBlock.muted">
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" /> <Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" /> <Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="20" />
</Style> </Style>
<Style Selector="Border.inline-panel"> <Style Selector="Button.step-nav-item">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" /> <Setter Property="Template">
<ControlTemplate>
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}" />
</Border>
</ControlTemplate>
</Setter>
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Margin" Value="0,0,0,3" />
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="40" />
</Style>
<Style Selector="Button.step-nav-item:pointerover">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillHoverBrush}" />
</Style>
<Style Selector="Button.step-nav-item:pressed">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillPressedBrush}" />
</Style>
<Style Selector="Button.step-nav-item:disabled">
<Setter Property="Background" Value="Transparent" />
<Setter Property="Opacity" Value="1" />
</Style>
<Style Selector="Button.step-nav-item:disabled TextBlock.step-title">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Button.step-nav-item:disabled fi|FluentIcon">
<Setter Property="Foreground" Value="{DynamicResource InstallerDisabledTextBrush}" />
</Style>
<Style Selector="Border.step-nav-selected-fill">
<Setter Property="Background" Value="{DynamicResource InstallerSubtleFillBrush}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
</Style>
<Style Selector="TextBlock.step-title">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
</Style>
<Style Selector="Border.info-panel">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceAltBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" /> <Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" /> <Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" /> <Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="18" /> <Setter Property="Padding" Value="12" />
</Style>
<Style Selector="Border.content-card">
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Padding" Value="20" />
</Style>
<Style Selector="Border.error-bar">
<Setter Property="Background" Value="{DynamicResource InstallerErrorBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InstallerErrorBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="12" />
</Style>
<Style Selector="TextBlock.meta-label">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextTertiaryBrush}" />
</Style>
<Style Selector="TextBlock.meta-value">
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="Border.separator">
<Setter Property="Height" Value="1" />
<Setter Property="Background" Value="{DynamicResource InstallerBorderBrush}" />
</Style> </Style>
</Window.Styles> </Window.Styles>
<Border Background="{DynamicResource InstallerWindowBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
ClipToBounds="True">
<Grid x:Name="RootGrid" <Grid x:Name="RootGrid"
Background="{DynamicResource InstallerWindowBackgroundBrush}" RowDefinitions="48,*"
RowDefinitions="48,*"> Background="Transparent">
<Border Grid.RowSpan="2"
Background="{DynamicResource InstallerTintBrush}"
IsHitTestVisible="False" />
<Border Grid.Row="0" <Border Grid.Row="0"
Background="Transparent" Background="{DynamicResource InstallerWindowBackgroundBrush}"
PointerPressed="OnTitleBarPointerPressed"> PointerPressed="OnTitleBarPointerPressed">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Margin="12,0,0,0" Margin="16,0,0,0"
Spacing="8" Spacing="10"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border Width="28"
Height="28"
Background="{DynamicResource InstallerAccentBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}">
<fi:FluentIcon Icon="ArrowDownload" <fi:FluentIcon Icon="ArrowDownload"
IconVariant="Regular" IconVariant="Regular"
FontSize="18" /> Foreground="{DynamicResource InstallerOnAccentBrush}"
FontSize="16" />
</Border>
<TextBlock Text="{Binding WindowTitle}" <TextBlock Text="{Binding WindowTitle}"
FontSize="12" FontSize="13"
FontWeight="SemiBold" FontWeight="SemiBold"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" <StackPanel Grid.Column="2"
Orientation="Horizontal" Orientation="Horizontal"
Spacing="4" Spacing="2"
Margin="0,0,8,0" Margin="0,0,8,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Button Classes="titlebar-icon-button" <Button Classes="titlebar-icon-button"
@@ -82,73 +163,142 @@
</Grid> </Grid>
</Border> </Border>
<ui:FANavigationView x:Name="StepNavigation" <Grid Grid.Row="1"
Grid.Row="1" ColumnDefinitions="260,10,*"
PaneDisplayMode="Left" Margin="10,0,10,10">
OpenPaneLength="272" <Border Grid.Column="0"
IsPaneOpen="True" Background="{DynamicResource InstallerPaneBackgroundBrush}"
IsSettingsVisible="False" CornerRadius="{DynamicResource DesignCornerRadiusLg}"
IsBackButtonVisible="False" Padding="22,24">
IsPaneToggleButtonVisible="False" <Grid RowDefinitions="Auto,*,Auto">
IsPaneVisible="True" <StackPanel Spacing="8">
MenuItemsSource="{Binding Steps}" <TextBlock Text="阑山桌面"
SelectedItem="{Binding SelectedStep, Mode=TwoWay}" FontSize="22"
Background="Transparent" FontWeight="SemiBold" />
Margin="0,0,0,0"> <TextBlock Text="在线安装程序"
<ui:FANavigationView.Resources> Classes="caption-text" />
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" /> </StackPanel>
<SolidColorBrush x:Key="NavigationViewDefaultPaneBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewExpandedPaneBackground" Color="Transparent" />
<SolidColorBrush x:Key="NavigationViewPaneBackground" Color="Transparent" />
</ui:FANavigationView.Resources>
<ui:FANavigationView.MenuItemTemplate>
<DataTemplate x:DataType="vm:InstallerStepViewModel">
<ui:FANavigationViewItem Content="{Binding Title}"
Tag="{Binding StepId}"
IsEnabled="{Binding IsUnlocked}">
<ui:FANavigationViewItem.IconSource>
<ui:FAFontIconSource Glyph="&#xE10F;" />
</ui:FANavigationViewItem.IconSource>
</ui:FANavigationViewItem>
</DataTemplate>
</ui:FANavigationView.MenuItemTemplate>
<Grid Margin="28,4,36,28" <ItemsControl Grid.Row="1"
RowDefinitions="*,Auto"> Margin="0,28,0,0"
<Grid Grid.Row="0"> ItemsSource="{Binding Steps}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:InstallerStepViewModel">
<Button Classes="step-nav-item"
Command="{Binding #Root.DataContext.SelectStepCommand}"
CommandParameter="{Binding}"
IsEnabled="{Binding IsUnlocked}">
<Grid MinHeight="40">
<Border Classes="step-nav-selected-fill"
IsVisible="{Binding IsSelected}" />
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10"
Margin="10,0">
<Grid Width="18"
VerticalAlignment="Center">
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Regular"
Foreground="{DynamicResource InstallerTextSecondaryBrush}"
FontSize="17"
IsVisible="{Binding !IsSelected}" />
<fi:FluentIcon Icon="{Binding Icon}"
IconVariant="Filled"
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
FontSize="17"
IsVisible="{Binding IsSelected}" />
</Grid>
<Grid Grid.Column="1"
VerticalAlignment="Center">
<TextBlock Classes="step-title"
Text="{Binding Title}"
IsVisible="{Binding !IsSelected}" />
<TextBlock Classes="step-title"
Text="{Binding Title}"
Foreground="{DynamicResource InstallerTextPrimaryBrush}"
IsVisible="{Binding IsSelected}" />
</Grid>
</Grid>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Grid.Row="2"
Classes="caption-text"
Text="安装期间请保持网络连接。下载失败时可返回上一步重新检查。" />
</Grid>
</Border>
<Border Grid.Column="2"
Background="{DynamicResource InstallerContentBackgroundBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"
Background="Transparent">
<ScrollViewer Grid.Row="0"
Padding="36,34,42,24"
VerticalScrollBarVisibility="Auto">
<Grid>
<Grid Classes="step-page" <Grid Classes="step-page"
IsVisible="{Binding IsWelcomeStep}"> IsVisible="{Binding IsWelcomeStep}">
<StackPanel Classes="installer-page-container"> <StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text" <TextBlock Classes="page-title-text"
Text="安装阑山桌面" /> Text="安装阑山桌面" />
<TextBlock Classes="page-description-text" <TextBlock Classes="page-description-text"
Text="在线安装程序会从 PLONDS 获取最新完整包,并部署到本机版本目录结构中。" /> Text="在线安装程序会获取最新完整包,并把应用部署到本机版本目录。" />
<ui:FASettingsExpander Header="准备开始" </StackPanel>
Description="安装器将检查最新版本、下载 Files 完整包、校验并部署。">
<StackPanel Spacing="8"> <Border Classes="content-card">
<TextBlock Text="首版支持 Windows 首次安装。修复和增量更新入口将在后续版本开放。" <Grid ColumnDefinitions="Auto,*"
Classes="muted" /> ColumnSpacing="14">
<TextBlock Text="安装完成后将使用 LanMountainDesktop.Launcher 作为统一入口。" <Border Width="40"
Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CloudArrowDown"
IconVariant="Regular"
FontSize="20" />
</Border>
<StackPanel Grid.Column="1"
Spacing="6">
<TextBlock Text="准备开始"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="安装器会检查最新版本、下载完整包、校验文件并激活部署。"
Classes="muted" /> Classes="muted" />
</StackPanel> </StackPanel>
</ui:FASettingsExpander> </Grid>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
<Grid Classes="step-page" <Grid Classes="step-page"
IsVisible="{Binding IsLocationStep}"> IsVisible="{Binding IsLocationStep}">
<StackPanel Classes="installer-page-container"> <StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text" <TextBlock Classes="page-title-text"
Text="选择安装位置" /> Text="选择安装位置" />
<TextBlock Classes="page-description-text" <TextBlock Classes="page-description-text"
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装保持一致。" /> Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装方式保持一致。" />
<ui:FASettingsExpander Header="安装目录" </StackPanel>
Description="安装根目录下会创建 .Launcher 和 app-{version}-0。">
<Border Classes="content-card">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Text="安装目录"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="安装根目录下会创建 .Launcher 和 app-{version}-0。"
Classes="muted" />
</StackPanel>
<Grid ColumnDefinitions="*,Auto" <Grid ColumnDefinitions="*,Auto"
ColumnSpacing="10"> ColumnSpacing="10">
<TextBox Text="{Binding InstallPath, Mode=TwoWay}" <TextBox Text="{Binding InstallPath, Mode=TwoWay}"
PlaceholderText="安装路径" /> PlaceholderText="安装路径" />
<Button Grid.Column="1" <Button Grid.Column="1"
Classes="secondary-command"
Command="{Binding BrowseCommand}"> Command="{Binding BrowseCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
@@ -158,88 +308,112 @@
</StackPanel> </StackPanel>
</Button> </Button>
</Grid> </Grid>
</ui:FASettingsExpander>
<ui:FASettingsExpander Header="安装后选项"
Description="开始菜单快捷方式会自动创建,桌面快捷方式可选。">
<StackPanel Spacing="10">
<CheckBox IsChecked="{Binding CreateDesktopShortcut}" <CheckBox IsChecked="{Binding CreateDesktopShortcut}"
Content="创建桌面快捷方式" /> Content="创建桌面快捷方式" />
<CheckBox IsChecked="{Binding CreateStartupShortcut}"
Content="开机时自动启动阑山桌面" />
</StackPanel> </StackPanel>
</ui:FASettingsExpander> </Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
<Grid Classes="step-page" <Grid Classes="step-page"
IsVisible="{Binding IsPrivacyStep}"> IsVisible="{Binding IsPrivacyStep}">
<StackPanel Classes="installer-page-container"> <StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text" <TextBlock Classes="page-title-text"
Text="确认上传数据" /> Text="确认数据使用" />
<TextBlock Classes="page-description-text" <TextBlock Classes="page-description-text"
Text="请确认安装阶段需要使用匿名数据类别。" /> Text="安装阶段需要使用匿名设备码和基础请求信息,用于安装、风控和用户量统计。" />
<ui:FASettingsExpander Header="匿名设备码" </StackPanel>
Description="与后续隐私计算使用同一设备码口径。">
<Border Classes="content-card">
<StackPanel Spacing="16">
<StackPanel Spacing="6">
<TextBlock Text="匿名设备码"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="{Binding DeviceIdPreview}" <TextBlock Text="{Binding DeviceIdPreview}"
TextWrapping="Wrap" TextWrapping="Wrap"
FontFamily="Consolas" /> FontFamily="Consolas"
</ui:FASettingsExpander> Foreground="{DynamicResource InstallerTextSecondaryBrush}" />
<ui:FASettingsExpander Header="网络与统计" </StackPanel>
Description="服务端会接收 IP 地址,用于防 DDoS 与统计用户量。"> <Border Classes="info-panel">
<StackPanel Spacing="8"> <Grid ColumnDefinitions="Auto,*"
<TextBlock Classes="muted" ColumnSpacing="10">
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP。不会上传用户名、机器名或安装目录。" /> <fi:FluentIcon Icon="Shield"
IconVariant="Regular"
Foreground="{DynamicResource InstallerAccentBrush}"
FontSize="18" />
<TextBlock Grid.Column="1"
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP不会上传用户名、机器名或安装目录。"
Classes="muted" />
</Grid>
</Border>
<CheckBox IsChecked="{Binding PrivacyConfirmed}" <CheckBox IsChecked="{Binding PrivacyConfirmed}"
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" /> Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
</StackPanel> </StackPanel>
</ui:FASettingsExpander> </Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
<Grid Classes="step-page" <Grid Classes="step-page"
IsVisible="{Binding IsDeployStep}"> IsVisible="{Binding IsDeployStep}">
<StackPanel Classes="installer-page-container"> <StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text" <TextBlock Classes="page-title-text"
Text="开始部署" /> Text="开始部署" />
<TextBlock Classes="page-description-text" <TextBlock Classes="page-description-text"
Text="安装时会下载 Files 完整包并写入当前版本目录。" /> Text="安装时会下载完整包并写入当前版本目录。" />
<Border Classes="inline-panel"> </StackPanel>
<StackPanel Spacing="14">
<Border Classes="content-card">
<StackPanel Spacing="18">
<Grid ColumnDefinitions="Auto,*" <Grid ColumnDefinitions="Auto,*"
RowDefinitions="Auto,Auto,Auto" RowDefinitions="Auto,Auto,Auto"
ColumnSpacing="12" ColumnSpacing="18"
RowSpacing="8"> RowSpacing="10">
<TextBlock Text="版本" /> <TextBlock Classes="meta-label"
Text="版本" />
<TextBlock Grid.Column="1" <TextBlock Grid.Column="1"
Classes="meta-value"
Text="{Binding TargetVersion}" /> Text="{Binding TargetVersion}" />
<TextBlock Grid.Row="1" <Border Grid.Row="1"
Grid.ColumnSpan="2"
Classes="separator" />
<TextBlock Grid.Row="2"
Classes="meta-label"
Text="来源" /> Text="来源" />
<TextBlock Grid.Row="1" <TextBlock Grid.Row="2"
Grid.Column="1" Grid.Column="1"
Classes="meta-value"
Text="{Binding SourceId}" /> Text="{Binding SourceId}" />
<TextBlock Grid.Row="2"
Text="状态" />
<TextBlock Grid.Row="2"
Grid.Column="1"
Text="{Binding StatusText}" />
</Grid> </Grid>
<StackPanel Spacing="6">
<TextBlock Text="下载进度" /> <StackPanel Spacing="8">
<TextBlock Text="{Binding StatusText}"
FontWeight="SemiBold" />
<ProgressBar Minimum="0" <ProgressBar Minimum="0"
Maximum="1" Maximum="1"
Value="{Binding DownloadProgress}" /> Value="{Binding DownloadProgress}" />
<TextBlock Classes="muted" <TextBlock Classes="caption-text"
Text="{Binding DownloadBytesText}" /> Text="{Binding DownloadBytesText}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="6">
<TextBlock Text="安装进度" /> <StackPanel Spacing="8">
<TextBlock Text="安装进度"
FontWeight="SemiBold" />
<ProgressBar Minimum="0" <ProgressBar Minimum="0"
Maximum="1" Maximum="1"
Value="{Binding InstallProgress}" /> Value="{Binding InstallProgress}" />
<TextBlock Classes="muted" <TextBlock Classes="caption-text"
Text="{Binding CurrentFile}" /> Text="{Binding CurrentFile}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="8"> Spacing="8">
<Button Command="{Binding StartInstallCommand}"> <Button Classes="primary-command"
Command="{Binding StartInstallCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="ArrowDownload" <fi:FluentIcon Icon="ArrowDownload"
@@ -247,7 +421,8 @@
<TextBlock Text="开始安装" /> <TextBlock Text="开始安装" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Command="{Binding CancelInstallCommand}" <Button Classes="secondary-command"
Command="{Binding CancelInstallCommand}"
IsEnabled="{Binding IsInstalling}"> IsEnabled="{Binding IsInstalling}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
@@ -265,38 +440,74 @@
<Grid Classes="step-page" <Grid Classes="step-page"
IsVisible="{Binding IsCompleteStep}"> IsVisible="{Binding IsCompleteStep}">
<StackPanel Classes="installer-page-container"> <StackPanel Classes="installer-page-container">
<StackPanel Spacing="8">
<TextBlock Classes="page-title-text" <TextBlock Classes="page-title-text"
Text="完成安装" /> Text="完成安装" />
<TextBlock Classes="page-description-text" <TextBlock Classes="page-description-text"
Text="阑山桌面已经部署完成。" /> Text="阑山桌面已经部署完成。" />
<ui:FASettingsExpander Header="启动应用" </StackPanel>
Description="使用 Launcher 进入首次启动流程。">
<StackPanel Spacing="12"> <Border Classes="content-card">
<TextBlock Text="如果需要,可以从这里重新启动 LanMountainDesktop.Launcher。" <Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<Border Width="40"
Height="40"
Background="{DynamicResource InstallerSubtleFillBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}">
<fi:FluentIcon Icon="CheckmarkCircle"
IconVariant="Regular"
Foreground="{DynamicResource InstallerSuccessBrush}"
FontSize="22" />
</Border>
<StackPanel Grid.Column="1"
Spacing="12">
<StackPanel Spacing="5">
<TextBlock Text="可以启动应用"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="使用 Launcher 进入首次启动流程。"
Classes="muted" /> Classes="muted" />
<Button Command="{Binding LaunchCommand}"> </StackPanel>
<Button Classes="primary-command"
HorizontalAlignment="Left"
Command="{Binding LaunchCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
<fi:FluentIcon Icon="Play" <fi:FluentIcon Icon="Play"
IconVariant="Regular" /> IconVariant="Regular" />
<TextBlock Text="启动阑山桌面" /> <TextBlock Text="打开阑山桌面" />
</StackPanel> </StackPanel>
</Button> </Button>
</StackPanel> </StackPanel>
</ui:FASettingsExpander> </Grid>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
</Grid> </Grid>
</ScrollViewer>
<Grid Grid.Row="1" <Border Grid.Row="1"
ColumnDefinitions="*,Auto,Auto" Background="Transparent"
ColumnSpacing="8" Padding="36,16,42,18">
Margin="0,16,0,0"> <Grid ColumnDefinitions="*,Auto,Auto"
<TextBlock Text="{Binding ErrorMessage}" ColumnSpacing="8">
Foreground="#C42B1C" <Border Classes="error-bar"
IsVisible="{Binding HasError}">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<fi:FluentIcon Icon="ErrorCircle"
IconVariant="Regular"
Foreground="{DynamicResource InstallerErrorBrush}"
FontSize="18" />
<TextBlock Grid.Column="1"
Text="{Binding ErrorMessage}"
Foreground="{DynamicResource InstallerErrorBrush}"
TextWrapping="Wrap" TextWrapping="Wrap"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
</Grid>
</Border>
<Button Grid.Column="1" <Button Grid.Column="1"
Classes="secondary-command"
Command="{Binding BackCommand}"> Command="{Binding BackCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
@@ -306,6 +517,7 @@
</StackPanel> </StackPanel>
</Button> </Button>
<Button Grid.Column="2" <Button Grid.Column="2"
Classes="primary-command"
Command="{Binding NextCommand}"> Command="{Binding NextCommand}">
<StackPanel Orientation="Horizontal" <StackPanel Orientation="Horizontal"
Spacing="6"> Spacing="6">
@@ -315,7 +527,10 @@
</StackPanel> </StackPanel>
</Button> </Button>
</Grid> </Grid>
</Border>
</Grid> </Grid>
</ui:FANavigationView> </Border>
</Grid> </Grid>
</Grid>
</Border>
</Window> </Window>

View File

@@ -24,9 +24,12 @@ public partial class MainWindow : Window
private async Task<string?> BrowseForFolderAsync(string currentPath) private async Task<string?> BrowseForFolderAsync(string currentPath)
{ {
var startFolder = Directory.Exists(currentPath) IStorageFolder? startFolder = null;
? await StorageProvider.TryGetFolderFromPathAsync(currentPath) if (Directory.Exists(currentPath))
: null; {
startFolder = await StorageProvider.TryGetFolderFromPathAsync(currentPath);
}
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{ {
Title = "选择安装位置", Title = "选择安装位置",
@@ -34,12 +37,28 @@ public partial class MainWindow : Window
SuggestedStartLocation = startFolder SuggestedStartLocation = startFolder
}); });
return result.Count == 0 ? null : result[0].Path.LocalPath; if (result.Count == 0)
{
return null;
}
var path = result[0].TryGetLocalPath();
if (string.IsNullOrWhiteSpace(path))
{
throw new InvalidOperationException("请选择本机文件夹作为安装位置。");
}
return path;
} }
private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
{ {
_ = sender; _ = sender;
if (e.Source is Button)
{
return;
}
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{ {
BeginMoveDrag(e); BeginMoveDrag(e);

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="0.0.0.0" name="LanDesktopPLONDS.Installer"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View File

@@ -132,6 +132,27 @@ internal sealed class DataLocationResolver
return ResolveDataRoot(config); return ResolveDataRoot(config);
} }
public string ResolveDataRoot(DataLocationMode mode, string? customPath = null)
{
return ResolveDataRoot(BuildConfig(mode, customPath));
}
public DataLocationConfig BuildConfig(DataLocationMode mode, string? customPath = null)
{
var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath;
return new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
}
private string ResolveDataRoot(DataLocationConfig? config) private string ResolveDataRoot(DataLocationConfig? config)
{ {
if (config is null) if (config is null)
@@ -193,18 +214,8 @@ internal sealed class DataLocationResolver
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false) public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{ {
var targetDataRoot = mode == DataLocationMode.Portable var config = BuildConfig(mode, customPath);
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath) var targetDataRoot = ResolveDataRoot(config);
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath;
var config = new DataLocationConfig
{
DataLocationMode = mode.ToString(),
SystemDataPath = _defaultSystemDataPath,
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
};
// 先创建目录结构 // 先创建目录结构
try try

View File

@@ -57,6 +57,23 @@ internal sealed class OobeCompletionResult
public string ErrorMessage { get; init; } = string.Empty; public string ErrorMessage { get; init; } = string.Empty;
} }
internal sealed class OobeSessionDraft
{
public DataLocationMode DataLocationMode { get; init; } = DataLocationMode.System;
public bool MigrateExistingData { get; init; }
public HostAppSettingsStartupChoices StartupChoices { get; init; }
public PrivacyConfig PrivacyConfig { get; init; } = new();
public bool PrivacyAgreementAccepted { get; init; }
public string PrivacyUserId { get; init; } = string.Empty;
public string PrivacyDeviceId { get; init; } = string.Empty;
}
internal sealed record LauncherExecutionSnapshot( internal sealed record LauncherExecutionSnapshot(
bool IsElevated, bool IsElevated,
string UserName, string UserName,

View File

@@ -1,67 +0,0 @@
using Avalonia.Threading;
using LanMountainDesktop.Launcher.Models;
using LanMountainDesktop.Launcher.Views;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class DataLocationOobeStep : IOobeStep
{
private readonly DataLocationResolver _resolver;
public DataLocationOobeStep(DataLocationResolver resolver)
{
_resolver = resolver;
}
public async Task RunAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var existingConfig = _resolver.LoadConfig();
if (existingConfig is not null)
{
Logger.Info("DataLocation OOBE step skipped: config already exists.");
return;
}
DataLocationPromptWindow? window = null;
await Dispatcher.UIThread.InvokeAsync(() =>
{
window = new DataLocationPromptWindow(_resolver);
window.Show();
});
if (window is null)
{
Logger.Warn("DataLocation OOBE step failed: window could not be created.");
return;
}
try
{
var result = await window.WaitForChoiceAsync().ConfigureAwait(false);
if (result is null)
{
Logger.Info("DataLocation OOBE step: user cancelled or closed window. Using default system location.");
_resolver.ApplyLocationChoice(DataLocationMode.System, null, false);
}
else
{
var success = _resolver.ApplyLocationChoice(result.SelectedMode, null, result.MigrateExistingData);
Logger.Info(
$"DataLocation OOBE step: user selected '{result.SelectedMode}'. " +
$"Migrate={result.MigrateExistingData}; Success={success}.");
}
}
finally
{
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (window.IsVisible)
{
window.Close();
}
});
}
}
}

View File

@@ -1,6 +1,15 @@
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Oobe; namespace LanMountainDesktop.Launcher.Oobe;
internal sealed record OobeStepResult(bool ContinueLaunch, LauncherResult? Result = null)
{
public static OobeStepResult Continue { get; } = new(true);
public static OobeStepResult Complete(LauncherResult result) => new(false, result);
}
internal interface IOobeStep internal interface IOobeStep
{ {
Task RunAsync(CancellationToken cancellationToken); Task<OobeStepResult> RunAsync(CancellationToken cancellationToken);
} }

View File

@@ -0,0 +1,92 @@
using System.Text.Json;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Oobe;
internal sealed class OobeSessionCommitService
{
private readonly DataLocationResolver _dataLocationResolver;
private readonly OobeStateService _oobeStateService;
private readonly CommandContext _context;
private readonly Func<bool, bool>? _setWindowsStartup;
public OobeSessionCommitService(
DataLocationResolver dataLocationResolver,
OobeStateService oobeStateService,
CommandContext context,
Func<bool, bool>? setWindowsStartup = null)
{
_dataLocationResolver = dataLocationResolver;
_oobeStateService = oobeStateService;
_context = context;
_setWindowsStartup = setWindowsStartup;
}
public OobeCompletionResult Commit(OobeSessionDraft draft)
{
ArgumentNullException.ThrowIfNull(draft);
if (!_dataLocationResolver.ApplyLocationChoice(
draft.DataLocationMode,
customPath: null,
draft.MigrateExistingData))
{
return Failure("data_location_save_failed", "Failed to save the selected data location.");
}
var dataRoot = _dataLocationResolver.ResolveDataRoot();
try
{
var settingsPath = HostAppSettingsOobeMerger.GetSettingsFilePath(dataRoot);
HostAppSettingsOobeMerger.MergeStartupPresentation(settingsPath, draft.StartupChoices);
}
catch (Exception ex)
{
return Failure("startup_settings_save_failed", ex.Message);
}
var setWindowsStartup = _setWindowsStartup ?? new LauncherWindowsStartupService().SetEnabled;
if (OperatingSystem.IsWindows() &&
!setWindowsStartup(draft.StartupChoices.AutoStartWithWindows))
{
return Failure("windows_startup_save_failed", "Failed to save Windows startup preference.");
}
try
{
var launcherDataPath = _dataLocationResolver.ResolveLauncherDataPath();
Directory.CreateDirectory(launcherDataPath);
var privacyConfigPath = Path.Combine(launcherDataPath, "privacy-config.json");
var privacyJson = JsonSerializer.Serialize(draft.PrivacyConfig, AppJsonContext.Default.PrivacyConfig);
File.WriteAllText(privacyConfigPath, privacyJson);
var agreementService = new PrivacyAgreementService(launcherDataPath);
if (!agreementService.SaveAgreement(
draft.PrivacyAgreementAccepted,
draft.PrivacyUserId,
draft.PrivacyDeviceId))
{
return Failure("privacy_agreement_save_failed", "Failed to save privacy agreement state.");
}
}
catch (Exception ex)
{
return Failure("privacy_settings_save_failed", ex.Message);
}
var completion = _oobeStateService.MarkCompleted(_context, dataRoot);
return completion.Success
? completion
: Failure(completion.ResultCode, completion.ErrorMessage);
}
private static OobeCompletionResult Failure(string code, string message) =>
new()
{
Success = false,
ResultCode = code,
ErrorMessage = message
};
}

View File

@@ -7,10 +7,12 @@ internal sealed class OobeStateService
{ {
private const int CurrentSchemaVersion = 1; private const int CurrentSchemaVersion = 1;
private readonly string _appRoot;
private readonly string? _stateRootOverride;
private readonly string _stateDirectory; private readonly string _stateDirectory;
private readonly string _statePath; private readonly string _statePath;
private readonly string _legacyStatePath; private readonly IReadOnlyList<string> _legacyStatePaths;
private readonly string _legacyMarkerPath; private readonly IReadOnlyList<string> _legacyMarkerPaths;
private readonly LauncherExecutionSnapshot _executionSnapshot; private readonly LauncherExecutionSnapshot _executionSnapshot;
public OobeStateService( public OobeStateService(
@@ -18,21 +20,17 @@ internal sealed class OobeStateService
string? stateRootOverride = null, string? stateRootOverride = null,
LauncherExecutionSnapshot? executionSnapshot = null) LauncherExecutionSnapshot? executionSnapshot = null)
{ {
_ = Path.GetFullPath(appRoot); _appRoot = Path.GetFullPath(appRoot);
_stateRootOverride = string.IsNullOrWhiteSpace(stateRootOverride)
? null
: Path.GetFullPath(stateRootOverride);
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture(); _executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride) var stateRoot = ResolveCurrentStateRoot();
? ResolveStateRoot(appRoot) (_stateDirectory, _statePath) = BuildStatePaths(stateRoot);
: Path.GetFullPath(stateRootOverride);
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride) _legacyStatePaths = BuildLegacyPaths("oobe-state.json");
? Path.GetFullPath(appRoot) _legacyMarkerPaths = BuildLegacyPaths("first_run_completed");
: Path.GetFullPath(stateRootOverride);
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
} }
public OobeLaunchDecision Evaluate(CommandContext context) public OobeLaunchDecision Evaluate(CommandContext context)
@@ -47,10 +45,17 @@ internal sealed class OobeStateService
} }
public OobeCompletionResult MarkCompleted(CommandContext context) public OobeCompletionResult MarkCompleted(CommandContext context)
{
return MarkCompleted(context, null);
}
public OobeCompletionResult MarkCompleted(CommandContext context, string? stateRoot)
{ {
try try
{ {
Directory.CreateDirectory(_stateDirectory); var (stateDirectory, statePath) = BuildStatePaths(
string.IsNullOrWhiteSpace(stateRoot) ? ResolveCurrentStateRoot() : Path.GetFullPath(stateRoot));
Directory.CreateDirectory(stateDirectory);
var payload = new OobeStateFile var payload = new OobeStateFile
{ {
SchemaVersion = CurrentSchemaVersion, SchemaVersion = CurrentSchemaVersion,
@@ -60,14 +65,14 @@ internal sealed class OobeStateService
LaunchSource = context.LaunchSource LaunchSource = context.LaunchSource
}; };
var tempPath = Path.Combine(_stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp"); var tempPath = Path.Combine(stateDirectory, $"oobe-state.{Guid.NewGuid():N}.tmp");
var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile); var json = JsonSerializer.Serialize(payload, AppJsonContext.Default.OobeStateFile);
File.WriteAllText(tempPath, json); File.WriteAllText(tempPath, json);
File.Move(tempPath, _statePath, overwrite: true); File.Move(tempPath, statePath, overwrite: true);
TryDeleteLegacyMarker(); TryDeleteLegacyMarker();
Logger.Info( Logger.Info(
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " + $"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{statePath}'; " +
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'."); $"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
return new OobeCompletionResult return new OobeCompletionResult
@@ -110,20 +115,27 @@ internal sealed class OobeStateService
return EvaluateStateFile(context, _statePath, migratedLegacyState: false); return EvaluateStateFile(context, _statePath, migratedLegacyState: false);
} }
if (File.Exists(_legacyStatePath)) foreach (var legacyStatePath in _legacyStatePaths)
{ {
return EvaluateStateFile(context, _legacyStatePath, migratedLegacyState: false); if (File.Exists(legacyStatePath))
{
var decision = EvaluateStateFile(context, legacyStatePath, migratedLegacyState: true);
if (decision.Status == OobeStateStatus.Completed)
{
_ = MarkCompleted(context);
} }
if (File.Exists(_legacyMarkerPath)) return decision;
}
}
foreach (var legacyMarkerPath in _legacyMarkerPaths)
{
if (File.Exists(legacyMarkerPath))
{ {
migratedLegacyMarker = TryMigrateLegacyMarker(context); migratedLegacyMarker = TryMigrateLegacyMarker(context);
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker); return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
} }
if (_executionSnapshot.IsElevated)
{
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
} }
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase)) if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
@@ -158,18 +170,21 @@ internal sealed class OobeStateService
} }
private void TryDeleteLegacyMarker() private void TryDeleteLegacyMarker()
{
foreach (var legacyMarkerPath in _legacyMarkerPaths)
{ {
try try
{ {
if (File.Exists(_legacyMarkerPath)) if (File.Exists(legacyMarkerPath))
{ {
File.Delete(_legacyMarkerPath); File.Delete(legacyMarkerPath);
} }
} }
catch catch
{ {
} }
} }
}
private OobeLaunchDecision BuildDecision( private OobeLaunchDecision BuildDecision(
CommandContext context, CommandContext context,
@@ -225,6 +240,44 @@ internal sealed class OobeStateService
}; };
} }
private string ResolveCurrentStateRoot()
{
return _stateRootOverride ?? ResolveStateRoot(_appRoot);
}
private static (string StateDirectory, string StatePath) BuildStatePaths(string stateRoot)
{
var stateDirectory = Path.Combine(Path.GetFullPath(stateRoot), "Launcher", "state");
return (stateDirectory, Path.Combine(stateDirectory, "oobe-state.json"));
}
private IReadOnlyList<string> BuildLegacyPaths(string fileName)
{
var roots = new List<string>();
if (_stateRootOverride is not null)
{
roots.Add(_stateRootOverride);
}
else
{
roots.Add(ResolveDefaultSystemStateRoot());
roots.Add(_appRoot);
try
{
roots.Add(ResolveCurrentStateRoot());
}
catch
{
}
}
return roots
.Where(root => !string.IsNullOrWhiteSpace(root))
.Select(root => Path.Combine(Path.GetFullPath(root), ".launcher", "state", fileName))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private static string ResolveStateRoot(string appRoot) private static string ResolveStateRoot(string appRoot)
{ {
try try
@@ -243,4 +296,15 @@ internal sealed class OobeStateService
return Path.Combine(appData, "LanMountainDesktop"); return Path.Combine(appData, "LanMountainDesktop");
} }
} }
private static string ResolveDefaultSystemStateRoot()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(appData))
{
return string.Empty;
}
return Path.Combine(appData, "LanMountainDesktop");
}
} }

View File

@@ -7,14 +7,19 @@ internal sealed class WelcomeOobeStep : IOobeStep
{ {
private readonly CommandContext _context; private readonly CommandContext _context;
private readonly OobeStateService _oobeStateService; private readonly OobeStateService _oobeStateService;
private readonly DataLocationResolver _dataLocationResolver;
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context) public WelcomeOobeStep(
OobeStateService oobeStateService,
CommandContext context,
DataLocationResolver dataLocationResolver)
{ {
_oobeStateService = oobeStateService; _oobeStateService = oobeStateService;
_context = context; _context = context;
_dataLocationResolver = dataLocationResolver;
} }
public async Task RunAsync(CancellationToken cancellationToken) public async Task<OobeStepResult> RunAsync(CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -27,16 +32,32 @@ internal sealed class WelcomeOobeStep : IOobeStep
if (window is null) if (window is null)
{ {
return; return BuildCancelledResult("OOBE window could not be created.");
} }
await window.WaitForEnterAsync().ConfigureAwait(false); var draft = await window.WaitForCompletionAsync().ConfigureAwait(false);
var completion = _oobeStateService.MarkCompleted(_context); if (draft is null)
{
Logger.Info("OOBE was cancelled before completion; Host launch will be skipped.");
return BuildCancelledResult("OOBE was cancelled before completion.");
}
var completion = new OobeSessionCommitService(
_dataLocationResolver,
_oobeStateService,
_context)
.Commit(draft);
if (!completion.Success) if (!completion.Success)
{ {
Logger.Warn( Logger.Warn(
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " + $"OOBE session was not persisted. ResultCode='{completion.ResultCode}'; " +
$"Error='{completion.ErrorMessage}'."); $"Error='{completion.ErrorMessage}'.");
return OobeStepResult.Complete(LaunchResultBuilder.Build(
false,
"oobe",
completion.ResultCode,
"OOBE settings could not be saved.",
errorMessage: completion.ErrorMessage));
} }
await Dispatcher.UIThread.InvokeAsync(() => await Dispatcher.UIThread.InvokeAsync(() =>
@@ -46,5 +67,16 @@ internal sealed class WelcomeOobeStep : IOobeStep
window.Close(); window.Close();
} }
}); });
return OobeStepResult.Continue;
}
private static OobeStepResult BuildCancelledResult(string message)
{
return OobeStepResult.Complete(LaunchResultBuilder.Build(
false,
"oobe",
"oobe_cancelled",
message));
} }
} }

View File

@@ -1,3 +1,4 @@
using System.Diagnostics;
using LanMountainDesktop.Shared.IPC; using LanMountainDesktop.Shared.IPC;
using LanMountainDesktop.Shared.IPC.Abstractions.Services; using LanMountainDesktop.Shared.IPC.Abstractions.Services;
@@ -24,11 +25,21 @@ internal sealed class AirAppRuntimeBridge
return; return;
} }
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest( Process? process;
try
{
process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
_appRoot, _appRoot,
Environment.ProcessId, Environment.ProcessId,
0, 0,
_dataRoot)); _dataRoot));
}
catch (Exception ex)
{
Logger.Warn($"AirApp Runtime start request failed. AppRoot='{_appRoot}'; Error='{ex.Message}'.");
return;
}
Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'."); Logger.Info($"AirApp Runtime start requested. Pid={(process is null ? -1 : process.Id)}; AppRoot='{_appRoot}'.");
for (var attempt = 1; attempt <= ConnectAttempts; attempt++) for (var attempt = 1; attempt <= ConnectAttempts; attempt++)

View File

@@ -116,7 +116,7 @@ internal static class PreviewEntryHandler
{ {
try try
{ {
await window.WaitForEnterAsync().ConfigureAwait(false); await window.WaitForCompletionAsync().ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -24,8 +24,6 @@ internal static class LauncherGuiCoordinator
var startupAttemptRegistry = new StartupAttemptRegistry(); var startupAttemptRegistry = new StartupAttemptRegistry();
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName(); var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context); var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
if (!startupAttemptRegistry.TryReserveCoordinator( if (!startupAttemptRegistry.TryReserveCoordinator(
context.LaunchSource, context.LaunchSource,
@@ -123,6 +121,7 @@ internal static class LauncherGuiCoordinator
if (result.Success) if (result.Success)
{ {
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0); var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false); await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false); await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
} }

View File

@@ -35,8 +35,7 @@ internal sealed class LauncherOrchestrator
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot()); _dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
_oobeSteps = _oobeSteps =
[ [
new WelcomeOobeStep(_oobeStateService, _context), new WelcomeOobeStep(_oobeStateService, _context, _dataLocationResolver)
new DataLocationOobeStep(_dataLocationResolver)
]; ];
_pipeline = pipeline ?? new LaunchPipeline( _pipeline = pipeline ?? new LaunchPipeline(
[ [

View File

@@ -108,6 +108,8 @@ internal sealed class HostLaunchService
return HostLaunchOutcome.FromResult(prerequisiteFailure); return HostLaunchOutcome.FromResult(prerequisiteFailure);
} }
await EnsureAirAppRuntimeStartedAsync(context.DeploymentLocator.GetAppRoot(), dataRoot).ConfigureAwait(false);
var hostPath = plan.HostPath; var hostPath = plan.HostPath;
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{ {
@@ -204,6 +206,18 @@ internal sealed class HostLaunchService
details)); details));
} }
private static async Task EnsureAirAppRuntimeStartedAsync(string appRoot, string? dataRoot)
{
try
{
await new AirAppRuntimeBridge(appRoot, dataRoot).EnsureStartedAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.Warn($"AirApp Runtime pre-start failed; Host fallback remains available. Error='{ex.Message}'.");
}
}
private static async Task<HostStartAttempt> StartHostProcessAsync( private static async Task<HostStartAttempt> StartHostProcessAsync(
HostLaunchPlan plan, HostLaunchPlan plan,
HostStartMode startMode, HostStartMode startMode,

View File

@@ -13,7 +13,18 @@ internal sealed class OobeGatePhase : ILaunchPhase
await LaunchUiPresenter.HideSplashAsync(context.SplashWindow).ConfigureAwait(false); await LaunchUiPresenter.HideSplashAsync(context.SplashWindow).ConfigureAwait(false);
foreach (var step in context.OobeSteps) foreach (var step in context.OobeSteps)
{ {
await step.RunAsync(cancellationToken).ConfigureAwait(false); var stepResult = await step.RunAsync(cancellationToken).ConfigureAwait(false);
if (!stepResult.ContinueLaunch)
{
context.WindowsClosingByOrchestrator = true;
await LaunchUiPresenter.CloseWindowsAsync(context.SplashWindow, context.LoadingDetailsWindow).ConfigureAwait(false);
return new LaunchPhaseResult(
LaunchPhaseStatus.Completed,
stepResult.Result ?? LaunchResultBuilder.BuildFailure(
"oobe",
"oobe_cancelled",
"OOBE did not complete."));
}
} }
await LaunchUiPresenter.ShowSplashAsync(context.SplashWindow).ConfigureAwait(false); await LaunchUiPresenter.ShowSplashAsync(context.SplashWindow).ConfigureAwait(false);

View File

@@ -126,7 +126,7 @@ public partial class DevDebugWindow : Window
try try
{ {
// 等待用户点击开始按钮 // 等待用户点击开始按钮
await oobeWindow.WaitForEnterAsync(); await oobeWindow.WaitForCompletionAsync();
// 用户点击后窗口会自动关闭通过OobeWindow内部的动画和关闭逻辑 // 用户点击后窗口会自动关闭通过OobeWindow内部的动画和关闭逻辑
Console.WriteLine("[DevDebugWindow] OOBE completed by user"); Console.WriteLine("[DevDebugWindow] OOBE completed by user");

View File

@@ -17,10 +17,11 @@ public partial class OobeWindow : Window
private const int AnimationDurationMs = 300; private const int AnimationDurationMs = 300;
private const int TypingDelayMs = 100; private const int TypingDelayMs = 100;
private readonly TaskCompletionSource<bool> _completionSource = new(); private readonly TaskCompletionSource<OobeSessionDraft?> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly DataLocationResolver _resolver; private readonly DataLocationResolver _resolver;
private bool _isTransitioning; private bool _isTransitioning;
private bool _isDebugMode; private bool _isDebugMode;
private bool _isCompleting;
private int _currentStep = 1; private int _currentStep = 1;
// 数据位置选择 // 数据位置选择
@@ -40,6 +41,7 @@ public partial class OobeWindow : Window
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
Loaded += OnWindowLoaded; Loaded += OnWindowLoaded;
Opened += OnWindowOpened; Opened += OnWindowOpened;
Closed += OnWindowClosed;
var appRoot = AppDomain.CurrentDomain.BaseDirectory; var appRoot = AppDomain.CurrentDomain.BaseDirectory;
_resolver = new DataLocationResolver(appRoot); _resolver = new DataLocationResolver(appRoot);
@@ -51,7 +53,7 @@ public partial class OobeWindow : Window
_isDebugMode = isDebugMode; _isDebugMode = isDebugMode;
} }
public Task WaitForEnterAsync() => _completionSource.Task; internal Task<OobeSessionDraft?> WaitForCompletionAsync() => _completionSource.Task;
private void OnWindowLoaded(object? sender, RoutedEventArgs e) private void OnWindowLoaded(object? sender, RoutedEventArgs e)
{ {
@@ -261,6 +263,14 @@ public partial class OobeWindow : Window
await PlayTypingAnimationAsync(); await PlayTypingAnimationAsync();
} }
private void OnWindowClosed(object? sender, EventArgs e)
{
if (!_isCompleting)
{
_completionSource.TrySetResult(null);
}
}
private async Task PlayTypingAnimationAsync() private async Task PlayTypingAnimationAsync()
{ {
var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock"); var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock");
@@ -477,11 +487,6 @@ public partial class OobeWindow : Window
if (_isTransitioning) return; if (_isTransitioning) return;
// 应用数据位置选择 // 应用数据位置选择
if (!_isDebugMode)
{
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
}
await NavigateToStep(4); await NavigateToStep(4);
} }
@@ -495,7 +500,6 @@ public partial class OobeWindow : Window
private async void OnStartupPresentationNextClick(object? sender, RoutedEventArgs e) private async void OnStartupPresentationNextClick(object? sender, RoutedEventArgs e)
{ {
if (_isTransitioning) return; if (_isTransitioning) return;
SaveOobeStartupPresentation();
await NavigateToStep(5); await NavigateToStep(5);
} }
@@ -521,7 +525,7 @@ public partial class OobeWindow : Window
private void RefreshOobeStartupPresentationFromDisk() private void RefreshOobeStartupPresentationFromDisk()
{ {
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(_resolver.ResolveDataRoot()); var path = HostAppSettingsOobeMerger.GetSettingsFilePath(ResolveSelectedDataRoot());
var defaults = HostAppSettingsOobeMerger.LoadStartupDefaults(path); var defaults = HostAppSettingsOobeMerger.LoadStartupDefaults(path);
if (this.FindControl<Border>("OobeSlideTransitionSection") is { } slideSection) if (this.FindControl<Border>("OobeSlideTransitionSection") is { } slideSection)
@@ -675,8 +679,6 @@ public partial class OobeWindow : Window
if (_isTransitioning) return; if (_isTransitioning) return;
// 保存隐私设置 // 保存隐私设置
SavePrivacySettings();
await NavigateToStep(6); await NavigateToStep(6);
} }
@@ -725,13 +727,15 @@ public partial class OobeWindow : Window
try try
{ {
await PlayExitAnimationAsync(); await PlayExitAnimationAsync();
_completionSource.TrySetResult(true); _isCompleting = true;
_completionSource.TrySetResult(BuildSessionDraft());
Close(); Close();
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}"); Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}");
_completionSource.TrySetResult(true); _isCompleting = true;
_completionSource.TrySetResult(BuildSessionDraft());
Close(); Close();
} }
} }
@@ -978,6 +982,43 @@ public partial class OobeWindow : Window
} }
} }
private OobeSessionDraft BuildSessionDraft()
{
var privacy = BuildPrivacyConfig();
return new OobeSessionDraft
{
DataLocationMode = _selectedDataLocationMode,
MigrateExistingData = _migrateExistingData,
StartupChoices = CollectOobeStartupChoices(),
PrivacyConfig = privacy,
PrivacyAgreementAccepted = this.FindControl<CheckBox>("PrivacyAgreementCheckBox")?.IsChecked ?? false,
PrivacyUserId = privacy.TelemetryId,
PrivacyDeviceId = GetDeviceIdentifier()
};
}
private PrivacyConfig BuildPrivacyConfig()
{
return new PrivacyConfig
{
CrashTelemetryEnabled = this.FindControl<ToggleSwitch>("CrashTelemetryToggle")?.IsChecked ?? true,
UsageTelemetryEnabled = this.FindControl<ToggleSwitch>("UsageTelemetryToggle")?.IsChecked ?? true,
TelemetryId = this.FindControl<TextBox>("TelemetryIdTextBox")?.Text ?? Guid.NewGuid().ToString("N")
};
}
private string ResolveSelectedDataRoot()
{
try
{
return _resolver.ResolveDataRoot(_selectedDataLocationMode);
}
catch
{
return _resolver.DefaultSystemDataPath;
}
}
private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - t, 3); private static double EaseOutCubic(double t) => 1 - Math.Pow(1 - t, 3);
private static double EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2); private static double EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2);
private static double EaseOutBack(double t) private static double EaseOutBack(double t)

View File

@@ -1,5 +1,6 @@
using Avalonia; using Avalonia;
using LanMountainDesktop.DesktopEditing; using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models;
using Xunit; using Xunit;
namespace LanMountainDesktop.Tests; namespace LanMountainDesktop.Tests;
@@ -170,4 +171,123 @@ public sealed class DesktopPlacementMathTests
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40))); Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
Assert.True(resizeSession.CanCommit); Assert.True(resizeSession.CanCommit);
} }
[Fact]
public void FusedCenteredPlacement_UsesGridCenterAndComponentSpan()
{
var grid = new DesktopGridGeometry(
Origin: new Point(12, 20),
CellSize: 80,
CellGap: 8,
ColumnCount: 8,
RowCount: 6);
var placement = FusedDesktopPlacementMath.CreateCenteredPlacement(
"placement-1",
"component-1",
grid,
widthCells: 4,
heightCells: 2);
Assert.Equal(2, placement.GridColumn);
Assert.Equal(2, placement.GridRow);
Assert.Equal(4, placement.GridWidthCells);
Assert.Equal(2, placement.GridHeightCells);
Assert.Equal(188, placement.X, 3);
Assert.Equal(196, placement.Y, 3);
Assert.Equal(344, placement.Width, 3);
Assert.Equal(168, placement.Height, 3);
}
[Fact]
public void FusedSnapToNearestCell_RoundsAndPersistsGridCoordinates()
{
var grid = new DesktopGridGeometry(
Origin: new Point(10, 10),
CellSize: 100,
CellGap: 12,
ColumnCount: 6,
RowCount: 5);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 212,
Height = 100,
GridWidthCells = 2,
GridHeightCells = 1
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(255, 135));
Assert.Equal(2, snapped.GridColumn);
Assert.Equal(1, snapped.GridRow);
Assert.Equal(234, snapped.X, 3);
Assert.Equal(122, snapped.Y, 3);
Assert.Equal(212, snapped.Width, 3);
Assert.Equal(100, snapped.Height, 3);
}
[Fact]
public void FusedSnapToNearestCell_ClampsInsideGridBounds()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 4,
RowCount: 3);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 168,
Height = 168,
GridWidthCells = 2,
GridHeightCells = 2
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(900, 600));
Assert.Equal(2, snapped.GridColumn);
Assert.Equal(1, snapped.GridRow);
Assert.Equal(176, snapped.X, 3);
Assert.Equal(88, snapped.Y, 3);
}
[Fact]
public void FusedSnapToNearestCell_EstimatesMissingSpanFromPixelSize()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 6,
RowCount: 6);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 168,
Height = 256
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(90, 180));
Assert.Equal(2, snapped.GridWidthCells);
Assert.Equal(3, snapped.GridHeightCells);
Assert.Equal(1, snapped.GridColumn);
Assert.Equal(2, snapped.GridRow);
Assert.Equal(168, snapped.Width, 3);
Assert.Equal(256, snapped.Height, 3);
}
} }

View File

@@ -56,17 +56,97 @@ public sealed class OnlineInstallerCoreTests : IDisposable
public async Task InstallerWorkflowNavigation_AllowsOnlyUnlockedSteps() public async Task InstallerWorkflowNavigation_AllowsOnlyUnlockedSteps()
{ {
var vm = new MainWindowViewModel(new FakeInstallService(), new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json"))); var vm = new MainWindowViewModel(new FakeInstallService(), new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")));
var deployStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Deploy);
vm.SelectedStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Deploy); Assert.False(deployStep.IsUnlocked);
vm.SelectStepCommand.Execute(deployStep);
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep); Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
Assert.True(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome).IsSelected);
await vm.NextCommand.ExecuteAsync(null); await vm.NextCommand.ExecuteAsync(null);
vm.SelectedStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome); vm.SelectStepCommand.Execute(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome));
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep); Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
} }
[Fact]
public async Task BrowseCommand_ReportsPickerFailuresWithoutChangingInstallPath()
{
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => throw new InvalidOperationException("picker failed")
};
var originalPath = vm.InstallPath;
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(originalPath, vm.InstallPath);
Assert.Contains("选择安装位置失败", vm.ErrorMessage);
Assert.Contains("picker failed", vm.ErrorMessage);
}
[Fact]
public async Task BrowseCommand_UsesSelectedLocalFolderAsInstallParent()
{
var selectedPath = Path.Combine(_tempRoot, "selected-install-root");
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => Task.FromResult<string?>(selectedPath)
};
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(Path.Combine(selectedPath, InstallerPathGuard.ApplicationDirectoryName), vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task BrowseCommand_DoesNotDuplicateApplicationFolder()
{
var selectedPath = Path.Combine(_tempRoot, InstallerPathGuard.ApplicationDirectoryName);
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => Task.FromResult<string?>(selectedPath)
};
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(selectedPath, vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task StartInstallCommand_PassesShortcutAndStartupOptions()
{
var installService = new FakeInstallService();
var vm = new MainWindowViewModel(
installService,
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
InstallPath = Path.Combine(_tempRoot, "install", "LanMountainDesktop"),
PrivacyConfirmed = true,
CreateDesktopShortcut = true,
CreateStartupShortcut = true
};
await vm.NextCommand.ExecuteAsync(null);
await vm.NextCommand.ExecuteAsync(null);
await vm.NextCommand.ExecuteAsync(null);
await vm.StartInstallCommand.ExecuteAsync(null);
Assert.NotNull(installService.LastOptions);
Assert.True(installService.LastOptions.CreateDesktopShortcut);
Assert.True(installService.LastOptions.CreateStartupShortcut);
}
[Fact] [Fact]
public void FilesZipUrlResolver_PrefersSourceSpecificThenDerivedThenFallbacks() public void FilesZipUrlResolver_PrefersSourceSpecificThenDerivedThenFallbacks()
{ {
@@ -84,6 +164,45 @@ public sealed class OnlineInstallerCoreTests : IDisposable
Assert.Contains(urls, uri => uri.AbsoluteUri == "https://github.test/Files.zip"); Assert.Contains(urls, uri => uri.AbsoluteUri == "https://github.test/Files.zip");
} }
[Fact]
public async Task FindLatest_ParsesCamelCasePlondsManifest()
{
var client = new InstallerPlondsClient(
new HttpClient(new ManifestHandler("""
{
"formatVersion": "2.0",
"currentVersion": "1.2.4",
"previousVersion": "1.2.3",
"isFullUpdate": false,
"requiresCleanInstall": true,
"channel": "preview",
"platform": "windows-x64",
"updatedAt": "2026-06-03T00:00:00Z",
"filesMap": {},
"changedFilesMap": {},
"checksums": {
"Files.zip": "md5:00000000000000000000000000000000"
},
"downloads": {
"s3": {
"filesZipUrl": "https://s3.test/Files.zip"
},
"github": {
"filesZipUrl": "https://github.test/files-windows-x64.zip"
}
}
}
""")),
Path.Combine(_tempRoot, "staging"));
var candidate = await client.FindLatestAsync(CancellationToken.None);
Assert.Equal("1.2.4", candidate.Manifest.CurrentVersion);
Assert.Equal("preview", candidate.Manifest.Channel);
Assert.Equal("https://s3.test/Files.zip", candidate.FilesZipUrl.AbsoluteUri);
}
[Theory] [Theory]
[InlineData("")] [InlineData("")]
[InlineData("C:\\")] [InlineData("C:\\")]
@@ -141,7 +260,43 @@ public sealed class OnlineInstallerCoreTests : IDisposable
manifest, manifest,
new Uri("https://s3.test/Files.zip")); new Uri("https://s3.test/Files.zip"));
await Assert.ThrowsAsync<InvalidDataException>(() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None)); var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None));
Assert.IsType<InvalidDataException>(exception.InnerException);
}
[Fact]
public async Task DownloadAndPrepareFullPackage_FallsBackWhenFirstPackageUrlFails()
{
var zipPath = Path.Combine(_tempRoot, "Files.zip");
Directory.CreateDirectory(_tempRoot);
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
{
var entry = archive.CreateEntry("LanMountainDesktop.exe");
await using var stream = entry.Open();
await using var writer = new StreamWriter(stream);
await writer.WriteAsync("host");
}
var manifest = CreateManifest(
downloads: new InstallerPlondsDownloads(
new InstallerPlondsGitHubDownloads(null, null, null, "https://github.test/files-windows-x64.zip"),
new InstallerPlondsS3Downloads(null, null, null, null, null, null, null, null, null, "https://s3.test/Files.zip", null, null)),
checksums: new Dictionary<string, string>
{
["Files.zip"] = "sha256:" + Sha256(zipPath)
});
var client = new InstallerPlondsClient(
new HttpClient(new FallbackPackageHandler(zipPath)),
Path.Combine(_tempRoot, "staging"));
var candidate = new InstallerPlondsCandidate(
new InstallerPlondsSource("s3", "s3", "https://origin.test/releases/PLONDS.json", 100),
manifest,
new Uri("https://s3.test/Files.zip"));
var package = await client.DownloadAndPrepareFullPackageAsync(candidate, null, CancellationToken.None);
Assert.True(File.Exists(Path.Combine(package.ExtractDirectory, "LanMountainDesktop.exe")));
} }
private static InstallerPlondsManifest CreateManifest( private static InstallerPlondsManifest CreateManifest(
@@ -173,6 +328,8 @@ public sealed class OnlineInstallerCoreTests : IDisposable
private sealed class FakeInstallService : IOnlineInstallService private sealed class FakeInstallService : IOnlineInstallService
{ {
public OnlineInstallOptions? LastOptions { get; private set; }
public Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken) public Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
=> Task.FromResult(new OnlineInstallPackageInfo("1.2.3", "test", new Uri("https://test/Files.zip"), 1)); => Task.FromResult(new OnlineInstallPackageInfo("1.2.3", "test", new Uri("https://test/Files.zip"), 1));
@@ -184,7 +341,10 @@ public sealed class OnlineInstallerCoreTests : IDisposable
OnlineInstallOptions options, OnlineInstallOptions options,
IProgress<InstallerDeployProgress>? progress, IProgress<InstallerDeployProgress>? progress,
CancellationToken cancellationToken) CancellationToken cancellationToken)
=> Task.CompletedTask; {
LastOptions = options;
return Task.CompletedTask;
}
public Task RepairAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken) public Task RepairAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
=> throw new NotSupportedException(); => throw new NotSupportedException();
@@ -205,4 +365,33 @@ public sealed class OnlineInstallerCoreTests : IDisposable
return Task.FromResult(response); return Task.FromResult(response);
} }
} }
private sealed class FallbackPackageHandler(string zipPath) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri?.AbsoluteUri == "https://github.test/files-windows-x64.zip")
{
var response = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new ByteArrayContent(File.ReadAllBytes(zipPath))
};
response.Content.Headers.ContentLength = new FileInfo(zipPath).Length;
return Task.FromResult(response);
}
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound));
}
}
private sealed class ManifestHandler(string manifestJson) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent(manifestJson)
});
}
}
} }

View File

@@ -0,0 +1,118 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class OobeSessionCommitServiceTests : IDisposable
{
private readonly string _tempRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(OobeSessionCommitServiceTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void ResolveDataRoot_ForChoice_DoesNotWriteConfigOrState()
{
var resolver = new DataLocationResolver(_tempRoot);
var dataRoot = resolver.ResolveDataRoot(DataLocationMode.Portable);
Assert.Equal(Path.Combine(_tempRoot, "Desktop"), dataRoot);
Assert.False(File.Exists(resolver.ResolveConfigPath()));
Assert.False(File.Exists(GetCompletedStatePath(dataRoot)));
}
[Fact]
public void Commit_WritesSettingsAndCompletedState_OnlyAfterFinalDraft()
{
var resolver = new DataLocationResolver(_tempRoot);
var oobeState = new OobeStateService(
_tempRoot,
executionSnapshot: new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var service = new OobeSessionCommitService(
resolver,
oobeState,
context,
setWindowsStartup: _ => true);
var draft = CreateDraft();
var result = service.Commit(draft);
var dataRoot = resolver.ResolveDataRoot();
Assert.True(result.Success);
Assert.True(File.Exists(resolver.ResolveConfigPath()));
Assert.True(File.Exists(HostAppSettingsOobeMerger.GetSettingsFilePath(dataRoot)));
Assert.True(File.Exists(Path.Combine(resolver.ResolveLauncherDataPath(), "privacy-config.json")));
Assert.True(File.Exists(Path.Combine(resolver.ResolveLauncherDataPath(), "privacy-agreement.state.json")));
Assert.True(File.Exists(GetCompletedStatePath(dataRoot)));
}
[Fact]
public void Commit_DoesNotWriteCompletedState_WhenFinalSaveFails()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var resolver = new DataLocationResolver(_tempRoot);
var oobeState = new OobeStateService(
_tempRoot,
executionSnapshot: new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var service = new OobeSessionCommitService(
resolver,
oobeState,
context,
setWindowsStartup: _ => false);
var result = service.Commit(CreateDraft());
var dataRoot = resolver.ResolveDataRoot();
Assert.False(result.Success);
Assert.Equal("windows_startup_save_failed", result.ResultCode);
Assert.False(File.Exists(GetCompletedStatePath(dataRoot)));
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
}
}
private static OobeSessionDraft CreateDraft() =>
new()
{
DataLocationMode = DataLocationMode.Portable,
MigrateExistingData = false,
StartupChoices = new HostAppSettingsStartupChoices(
ShowInTaskbar: true,
EnableFadeTransition: true,
EnableSlideTransition: false,
FusedPopupExperience: false,
AutoStartWithWindows: false),
PrivacyConfig = new PrivacyConfig
{
CrashTelemetryEnabled = false,
UsageTelemetryEnabled = false,
TelemetryId = "test-telemetry"
},
PrivacyAgreementAccepted = true,
PrivacyUserId = "test-telemetry",
PrivacyDeviceId = "test-device"
};
private static string GetCompletedStatePath(string dataRoot) =>
Path.Combine(dataRoot, "Launcher", "state", "oobe-state.json");
}

View File

@@ -66,16 +66,80 @@ public sealed class OobeStateServiceTests : IDisposable
} }
[Fact] [Fact]
public void Evaluate_SuppressesOobe_ForElevatedFirstRun() public void Evaluate_ReturnsFirstRun_ForElevatedFirstRun()
{ {
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test")); var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]); var context = CommandContext.FromArgs(["launch"]);
var decision = service.Evaluate(context); var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
Assert.True(decision.ShouldShowOobe);
Assert.True(decision.IsElevated);
}
[Fact]
public void Evaluate_ReturnsFirstRun_ForElevatedPostInstall()
{
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch", "--launch-source", "postinstall"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
Assert.True(decision.ShouldShowOobe);
Assert.Equal("postinstall", decision.LaunchSource);
}
[Fact]
public void Evaluate_SuppressesOobe_ForMaintenanceLaunch()
{
var service = CreateService();
var context = CommandContext.FromArgs(["plugin", "install", "--source", "x", "--plugins-dir", "p"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Suppressed, decision.Status); Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
Assert.False(decision.ShouldShowOobe); Assert.False(decision.ShouldShowOobe);
Assert.Equal("oobe_suppressed_elevated", decision.ResultCode); Assert.Equal("oobe_suppressed_maintenance", decision.ResultCode);
}
[Fact]
public void Evaluate_SuppressesOobe_ForDebugPreview()
{
var service = CreateService();
var context = CommandContext.FromArgs(["preview-oobe"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
Assert.False(decision.ShouldShowOobe);
Assert.Equal("oobe_suppressed_debug_preview", decision.ResultCode);
}
[Fact]
public void Evaluate_MigratesLegacyStateFile_ToCurrentStatePath()
{
var legacyStatePath = GetLegacyStatePath();
Directory.CreateDirectory(Path.GetDirectoryName(legacyStatePath)!);
var state = new OobeStateFile
{
SchemaVersion = 1,
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
UserName = "tester",
UserSid = "S-1-5-test",
LaunchSource = "normal"
};
File.WriteAllText(legacyStatePath, JsonSerializer.Serialize(state));
var service = CreateService();
var context = CommandContext.FromArgs(["launch"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Completed, decision.Status);
Assert.True(decision.MigratedLegacyMarker);
Assert.True(File.Exists(GetStatePath()));
} }
[Fact] [Fact]
@@ -119,5 +183,7 @@ public sealed class OobeStateServiceTests : IDisposable
private string GetStatePath() => Path.Combine(_tempRoot, "Launcher", "state", "oobe-state.json"); private string GetStatePath() => Path.Combine(_tempRoot, "Launcher", "state", "oobe-state.json");
private string GetLegacyStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed"); private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
} }

View File

@@ -0,0 +1,47 @@
using System.IO;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class UpdateInstallGatewayTests
{
[Fact]
public void GetDirectoryName_ReturnsNull_ForRootPath()
{
// 验证 Path.GetDirectoryName 在根路径场景下的行为
var rootPath = Path.GetPathRoot(Path.GetTempPath()) ?? "C:\\";
var installerPath = Path.Combine(rootPath, "installer.exe");
// 根路径下的文件GetDirectoryName 返回根路径本身(不是 null
var result = Path.GetDirectoryName(installerPath);
// 在 Windows 上,根路径文件返回根路径(如 "C:\"),不是 null
// 但如果 installerPath 本身就是根路径(无文件名),则返回 null
Assert.NotNull(result); // "C:\installer.exe" 的目录是 "C:\"
}
[Fact]
public void GetDirectoryName_ReturnsNull_ForPathWithoutDirectory()
{
// 验证极端场景:路径没有目录部分
// 这种情况在实际中很少发生,但代码应该能处理
var fileNameOnly = "installer.exe";
var result = Path.GetDirectoryName(fileNameOnly);
// 只有文件名没有路径时GetDirectoryName 返回 null
Assert.Null(result);
}
[Fact]
public void WorkingDirectoryFallback_ShouldUseValidDirectory()
{
// 验证修复后的逻辑:当 GetDirectoryName 返回 null 时,
// 应该使用 AppContext.BaseDirectory 作为后备值
var installerPath = "installer.exe"; // 模拟只有文件名的情况
var workingDir = Path.GetDirectoryName(installerPath) ?? AppContext.BaseDirectory;
// 后备值应该是有效的目录路径
Assert.NotNull(workingDir);
Assert.True(Directory.Exists(workingDir) || workingDir == AppContext.BaseDirectory);
}
}

View File

@@ -40,6 +40,8 @@ public sealed class UpdateSettingsInterfaceTests
Assert.Equal(1, update.CheckCalls); Assert.Equal(1, update.CheckCalls);
Assert.Equal("1.2.3", viewModel.LatestVersionText); Assert.Equal("1.2.3", viewModel.LatestVersionText);
Assert.True(viewModel.IsDeltaUpdate); Assert.True(viewModel.IsDeltaUpdate);
Assert.True(viewModel.CanDownload);
Assert.True(viewModel.IsProgressSectionVisible);
update.SetPhase(UpdatePhase.Checked); update.SetPhase(UpdatePhase.Checked);
await ((IAsyncRelayCommand)viewModel.DownloadCommand).ExecuteAsync(null); await ((IAsyncRelayCommand)viewModel.DownloadCommand).ExecuteAsync(null);
@@ -62,6 +64,36 @@ public sealed class UpdateSettingsInterfaceTests
Assert.Equal(1, update.CancelCalls); Assert.Equal(1, update.CancelCalls);
} }
[Fact]
public async Task UpdateSettingsViewModel_WhenCheckFailsInCheckedPhase_DoesNotExposeDownload()
{
var update = new FakeUpdateSettingsService
{
CheckReport = new UpdateCheckReport(
false,
null,
"1.0.0",
null,
null,
null,
null,
null,
null,
"No usable update manifest was found.")
};
var viewModel = new UpdateSettingsViewModel(new FakeSettingsFacade(update));
viewModel.IsUpdateAvailable = true;
viewModel.LatestVersionText = "9.9.9";
await ((IAsyncRelayCommand)viewModel.CheckCommand).ExecuteAsync(null);
Assert.False(viewModel.IsUpdateAvailable);
Assert.Empty(viewModel.LatestVersionText);
Assert.False(viewModel.CanDownload);
Assert.False(viewModel.IsProgressSectionVisible);
Assert.Equal(0, update.DownloadCalls);
}
[Fact] [Fact]
public void UpdateSettingsViewModel_SavesPreferencesThroughUpdateSettingsService() public void UpdateSettingsViewModel_SavesPreferencesThroughUpdateSettingsService()
{ {
@@ -140,6 +172,32 @@ public sealed class UpdateSettingsInterfaceTests
Assert.False(orchestratorCreated); Assert.False(orchestratorCreated);
} }
[Fact]
public async Task UpdateSettingsService_WhenPlondsCheckFails_ReturnsIdleAndNoDownload()
{
var settings = new FakeSettingsService
{
Snapshot =
{
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
}
};
var plonds = new FakePlondsService
{
LatestResult = PlondsLatestResult.Failed(new Version(1, 0, 0), "No usable PLONDS manifest was found.")
};
var service = new UpdateSettingsService(
settings,
orchestratorFactory: () => throw new InvalidOperationException("not used"),
plondsService: plonds);
var report = await service.CheckAsync(CancellationToken.None);
Assert.False(report.IsUpdateAvailable);
Assert.Equal("No usable PLONDS manifest was found.", report.ErrorMessage);
Assert.Equal(UpdatePhase.Idle, service.CurrentPhase);
}
[Fact] [Fact]
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller() public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
{ {

View File

@@ -0,0 +1,97 @@
using System;
using Avalonia;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.DesktopEditing;
internal static class FusedDesktopPlacementMath
{
public static FusedDesktopComponentPlacementSnapshot CreateCenteredPlacement(
string placementId,
string componentId,
DesktopGridGeometry grid,
int widthCells,
int heightCells)
{
ArgumentException.ThrowIfNullOrWhiteSpace(placementId);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
var safeWidthCells = Math.Max(1, widthCells);
var safeHeightCells = Math.Max(1, heightCells);
var column = Math.Clamp(
(grid.ColumnCount - safeWidthCells) / 2,
0,
Math.Max(0, grid.ColumnCount - safeWidthCells));
var row = Math.Clamp(
(grid.RowCount - safeHeightCells) / 2,
0,
Math.Max(0, grid.RowCount - safeHeightCells));
var rect = DesktopPlacementMath.GetCellRect(grid, column, row, safeWidthCells, safeHeightCells);
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = placementId,
ComponentId = componentId,
X = rect.X,
Y = rect.Y,
Width = rect.Width,
Height = rect.Height,
GridColumn = column,
GridRow = row,
GridWidthCells = safeWidthCells,
GridHeightCells = safeHeightCells
};
}
public static FusedDesktopComponentPlacementSnapshot SnapToNearestCell(
FusedDesktopComponentPlacementSnapshot placement,
DesktopGridGeometry grid,
Point requestedOrigin)
{
ArgumentNullException.ThrowIfNull(placement);
var widthCells = Math.Max(1, placement.GridWidthCells ?? EstimateCellSpan(placement.Width, grid));
var heightCells = Math.Max(1, placement.GridHeightCells ?? EstimateCellSpan(placement.Height, grid));
var maxColumn = Math.Max(0, grid.ColumnCount - widthCells);
var maxRow = Math.Max(0, grid.RowCount - heightCells);
var pitch = grid.Pitch;
if (!grid.IsValid || pitch <= 0)
{
return placement.Clone();
}
var column = Math.Clamp(
(int)Math.Round((requestedOrigin.X - grid.Origin.X) / pitch, MidpointRounding.AwayFromZero),
0,
maxColumn);
var row = Math.Clamp(
(int)Math.Round((requestedOrigin.Y - grid.Origin.Y) / pitch, MidpointRounding.AwayFromZero),
0,
maxRow);
var rect = DesktopPlacementMath.GetCellRect(grid, column, row, widthCells, heightCells);
var snapped = placement.Clone();
snapped.X = rect.X;
snapped.Y = rect.Y;
snapped.Width = rect.Width;
snapped.Height = rect.Height;
snapped.GridColumn = column;
snapped.GridRow = row;
snapped.GridWidthCells = widthCells;
snapped.GridHeightCells = heightCells;
return snapped;
}
private static int EstimateCellSpan(double pixelSize, DesktopGridGeometry grid)
{
if (!grid.IsValid || grid.CellSize <= 0)
{
return 1;
}
return Math.Max(1, (int)Math.Round(
(Math.Max(1, pixelSize) + grid.CellGap) / grid.Pitch,
MidpointRounding.AwayFromZero));
}
}

View File

@@ -537,11 +537,11 @@
"settings.update.status_channel_changed": "Update channel changed. Please check again.", "settings.update.status_channel_changed": "Update channel changed. Please check again.",
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.", "settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.", "settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
"settings.update.status_checking": "Checking GitHub releases...", "settings.update.status_checking": "Checking update sources...",
"settings.update.status_check_failed_format": "Update check failed: {0}", "settings.update.status_check_failed_format": "Update check failed: {0}",
"settings.update.status_up_to_date": "You are already on the latest version.", "settings.update.status_up_to_date": "You are already on the latest version.",
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.", "settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.", "settings.update.status_available_format": "New version {0} is available. Download it when you are ready.",
"settings.update.status_downloading": "Downloading installer...", "settings.update.status_downloading": "Downloading installer...",
"settings.update.status_downloading_delta": "Downloading incremental update...", "settings.update.status_downloading_delta": "Downloading incremental update...",
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.", "settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
@@ -550,7 +550,7 @@
"settings.update.type_delta": "Incremental Update", "settings.update.type_delta": "Incremental Update",
"settings.update.type_full": "Full Installer", "settings.update.type_full": "Full Installer",
"settings.update.status_download_failed_format": "Download failed: {0}", "settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...", "settings.update.status_launching_installer": "Download complete. You can install the update now.",
"settings.update.status_installer_missing": "Installer file was not found after download.", "settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.", "settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.", "settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
@@ -655,17 +655,17 @@
"settings.update.status_channel_changed": "Update channel changed. Please check again.", "settings.update.status_channel_changed": "Update channel changed. Please check again.",
"settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.", "settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.",
"settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.", "settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.",
"settings.update.status_checking": "Checking GitHub releases...", "settings.update.status_checking": "Checking update sources...",
"settings.update.status_check_failed_format": "Update check failed: {0}", "settings.update.status_check_failed_format": "Update check failed: {0}",
"settings.update.status_up_to_date": "You are already on the latest version.", "settings.update.status_up_to_date": "You are already on the latest version.",
"settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.", "settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.",
"settings.update.status_available_format": "New version {0} is available. Click Download & Install.", "settings.update.status_available_format": "New version {0} is available. Download it when you are ready.",
"settings.update.status_downloading": "Downloading installer...", "settings.update.status_downloading": "Downloading installer...",
"settings.update.status_downloading_delta": "Downloading incremental update...", "settings.update.status_downloading_delta": "Downloading incremental update...",
"settings.update.status_delta_applying": "Applying incremental update. The app will close for update.", "settings.update.status_delta_applying": "Applying incremental update. The app will close for update.",
"settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.", "settings.update.status_delta_launch_failed": "Failed to launch updater for incremental update.",
"settings.update.status_download_failed_format": "Download failed: {0}", "settings.update.status_download_failed_format": "Download failed: {0}",
"settings.update.status_launching_installer": "Download complete. Launching installer...", "settings.update.status_launching_installer": "Download complete. You can install the update now.",
"settings.update.status_installer_missing": "Installer file was not found after download.", "settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.", "settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.", "settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",

View File

@@ -430,14 +430,14 @@
"settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。", "settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。",
"settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。", "settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。",
"settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。", "settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。",
"settings.update.status_checking": "GitHubリリースを確認中...", "settings.update.status_checking": "更新元を確認中...",
"settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}", "settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}",
"settings.update.status_up_to_date": "最新バージョンを使用しています。", "settings.update.status_up_to_date": "最新バージョンを使用しています。",
"settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。", "settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。",
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。", "settings.update.status_available_format": "新しいバージョン{0}が利用可能です。準備ができたらダウンロードできます。",
"settings.update.status_downloading": "インストーラーをダウンロード中...", "settings.update.status_downloading": "インストーラーをダウンロード中...",
"settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}", "settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}",
"settings.update.status_launching_installer": "ダウンロード完了。インストーラーを起動中...", "settings.update.status_launching_installer": "ダウンロード完了。更新をインストールできます。",
"settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。", "settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。",
"settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。", "settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。",
"settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。", "settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",

View File

@@ -478,14 +478,14 @@
"settings.update.status_channel_changed": "업데이트 채널이 변경되었습니다. 다시 업데이트를 확인하세요.", "settings.update.status_channel_changed": "업데이트 채널이 변경되었습니다. 다시 업데이트를 확인하세요.",
"settings.update.status_channel_changed_format": "업데이트 채널이 {0}(으)로 전환되었습니다. 다시 업데이트를 확인하세요.", "settings.update.status_channel_changed_format": "업데이트 채널이 {0}(으)로 전환되었습니다. 다시 업데이트를 확인하세요.",
"settings.update.status_windows_only": "자동 설치 패키지 업데이트는 현재 Windows만 지원합니다.", "settings.update.status_windows_only": "자동 설치 패키지 업데이트는 현재 Windows만 지원합니다.",
"settings.update.status_checking": "GitHub Release 확인 중...", "settings.update.status_checking": "업데이트 소스 확인 중...",
"settings.update.status_check_failed_format": "업데이트 확인 실패: {0}", "settings.update.status_check_failed_format": "업데이트 확인 실패: {0}",
"settings.update.status_up_to_date": "현재 최신 버전입니다.", "settings.update.status_up_to_date": "현재 최신 버전입니다.",
"settings.update.status_asset_missing": "새 버전이 발견되었지만 호환되는 설치 패키지를 찾을 수 없습니다.", "settings.update.status_asset_missing": "새 버전이 발견되었지만 호환되는 설치 패키지를 찾을 수 없습니다.",
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. \"다운로드 및 설치\"를 클릭하여 계속하세요.", "settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. 준비되면 업데이트를 다운로드하세요.",
"settings.update.status_downloading": "설치 패키지 다운로드 중...", "settings.update.status_downloading": "설치 패키지 다운로드 중...",
"settings.update.status_download_failed_format": "다운로드 실패: {0}", "settings.update.status_download_failed_format": "다운로드 실패: {0}",
"settings.update.status_launching_installer": "다운로드 완료, 설치 프로그램 시작 중...", "settings.update.status_launching_installer": "다운로드 완료. 이제 업데이트를 설치할 수 있습니다.",
"settings.update.status_installer_missing": "다운로드 후 설치 패키지 파일을 찾을 수 없습니다.", "settings.update.status_installer_missing": "다운로드 후 설치 패키지 파일을 찾을 수 없습니다.",
"settings.update.status_installer_started": "설치 프로그램이 시작되었습니다. 앱이 업데이트를 위해 종료됩니다.", "settings.update.status_installer_started": "설치 프로그램이 시작되었습니다. 앱이 업데이트를 위해 종료됩니다.",
"settings.update.status_elevation_cancelled": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.", "settings.update.status_elevation_cancelled": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.",

View File

@@ -537,11 +537,11 @@
"settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。", "settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。",
"settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。", "settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。",
"settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。", "settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。",
"settings.update.status_checking": "正在检查 GitHub Release...", "settings.update.status_checking": "正在检查更新源...",
"settings.update.status_check_failed_format": "检查更新失败:{0}", "settings.update.status_check_failed_format": "检查更新失败:{0}",
"settings.update.status_up_to_date": "当前已是最新版本。", "settings.update.status_up_to_date": "当前已是最新版本。",
"settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。", "settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。",
"settings.update.status_available_format": "发现新版本 {0}点击“下载并安装”继续。", "settings.update.status_available_format": "发现新版本 {0}准备好后可下载更新。",
"settings.update.status_downloading": "正在下载安装包...", "settings.update.status_downloading": "正在下载安装包...",
"settings.update.status_downloading_delta": "正在下载增量更新包...", "settings.update.status_downloading_delta": "正在下载增量更新包...",
"settings.update.status_delta_applying": "正在应用增量更新,应用将关闭进行更新。", "settings.update.status_delta_applying": "正在应用增量更新,应用将关闭进行更新。",
@@ -550,7 +550,7 @@
"settings.update.type_delta": "增量更新", "settings.update.type_delta": "增量更新",
"settings.update.type_full": "完整安装包", "settings.update.type_full": "完整安装包",
"settings.update.status_download_failed_format": "下载失败:{0}", "settings.update.status_download_failed_format": "下载失败:{0}",
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...", "settings.update.status_launching_installer": "下载完成,现在可以安装更新。",
"settings.update.status_installer_missing": "下载后未找到安装包文件。", "settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。", "settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。", "settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",

View File

@@ -3,7 +3,9 @@ using System.Collections.Generic;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform;
using LanMountainDesktop.ComponentSystem; using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models; using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Settings;
@@ -17,7 +19,7 @@ public interface IFusedDesktopManagerService
void Initialize(); void Initialize();
void ReloadWidgets(); void ReloadWidgets();
void Shutdown(); void Shutdown();
void AddComponent(string componentId); void AddComponent(string componentId, Window? referenceWindow = null);
void RemoveComponent(string placementId); void RemoveComponent(string placementId);
void EnterEditMode(); void EnterEditMode();
void ExitEditMode(); void ExitEditMode();
@@ -40,8 +42,6 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
private bool _isEditMode; private bool _isEditMode;
private const double DefaultCellSize = 100; private const double DefaultCellSize = 100;
private const double DefaultComponentWidth = 200;
private const double DefaultComponentHeight = 200;
public bool IsEditMode => _isEditMode; public bool IsEditMode => _isEditMode;
@@ -109,7 +109,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
AppLogger.Info("FusedDesktop", "Exited edit mode."); AppLogger.Info("FusedDesktop", "Exited edit mode.");
} }
public void AddComponent(string componentId) public void AddComponent(string componentId, Window? referenceWindow = null)
{ {
EnsureRegistries(); EnsureRegistries();
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor)) if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
@@ -118,23 +118,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
return; return;
} }
var placement = new FusedDesktopComponentPlacementSnapshot var widthCells = Math.Max(1, descriptor.Definition.MinWidthCells);
{ var heightCells = Math.Max(1, descriptor.Definition.MinHeightCells);
PlacementId = Guid.NewGuid().ToString("N"), var placement = CreateCenteredPlacement(
ComponentId = componentId, Guid.NewGuid().ToString("N"),
Width = DefaultComponentWidth, componentId,
Height = DefaultComponentHeight widthCells,
}; heightCells,
referenceWindow);
var screen = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
?.MainWindow?.Screens.Primary;
if (screen is not null)
{
var scaling = screen.Scaling;
var workArea = screen.WorkingArea;
placement.X = (workArea.Width / scaling - placement.Width) / 2;
placement.Y = (workArea.Height / scaling - placement.Height) / 2;
}
_layoutService.AddComponentPlacement(placement); _layoutService.AddComponentPlacement(placement);
@@ -160,7 +151,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
_layoutService.RemoveComponentPlacement(placement.PlacementId); _layoutService.RemoveComponentPlacement(placement.PlacementId);
} }
AppLogger.Info("FusedDesktopMgr", $"Added component '{componentId}' with placement '{placement.PlacementId}'."); AppLogger.Info(
"FusedDesktopMgr",
$"Added component '{componentId}' with placement '{placement.PlacementId}' at grid {placement.GridColumn},{placement.GridRow}.");
} }
public void RemoveComponent(string placementId) public void RemoveComponent(string placementId)
@@ -249,8 +242,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
return null; return null;
} }
var cellSize = ResolveCellSize(placement);
var control = descriptor.CreateControl( var control = descriptor.CreateControl(
DefaultCellSize, cellSize,
_timeZoneService, _timeZoneService,
_weatherDataService, _weatherDataService,
_recommendationInfoService, _recommendationInfoService,
@@ -264,6 +258,140 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
var window = new DesktopWidgetWindow(control, placement.PlacementId); var window = new DesktopWidgetWindow(control, placement.PlacementId);
return window; return window;
} }
private FusedDesktopComponentPlacementSnapshot CreateCenteredPlacement(
string placementId,
string componentId,
int widthCells,
int heightCells,
Window? referenceWindow)
{
var screen = ResolveTargetScreen(referenceWindow);
if (screen is null)
{
var fallbackWidth = widthCells * DefaultCellSize;
var fallbackHeight = heightCells * DefaultCellSize;
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = placementId,
ComponentId = componentId,
X = 0,
Y = 0,
Width = fallbackWidth,
Height = fallbackHeight,
GridColumn = 0,
GridRow = 0,
GridWidthCells = widthCells,
GridHeightCells = heightCells
};
}
var workArea = screen.WorkingArea;
var scaling = Math.Max(0.1, screen.Scaling);
var viewportSize = GetScreenViewportSize(screen);
var adapter = new FusedDesktopEditGridAdapter(_settingsFacade);
if (!adapter.TryCreate(viewportSize, out var context))
{
var fallbackWidth = widthCells * DefaultCellSize;
var fallbackHeight = heightCells * DefaultCellSize;
return new FusedDesktopComponentPlacementSnapshot
{
PlacementId = placementId,
ComponentId = componentId,
X = workArea.X + Math.Max(0, (workArea.Width - fallbackWidth * scaling) / 2),
Y = workArea.Y + Math.Max(0, (workArea.Height - fallbackHeight * scaling) / 2),
Width = fallbackWidth,
Height = fallbackHeight,
GridColumn = 0,
GridRow = 0,
GridWidthCells = widthCells,
GridHeightCells = heightCells
};
}
var localPlacement = FusedDesktopPlacementMath.CreateCenteredPlacement(
placementId,
componentId,
context.Geometry,
widthCells,
heightCells);
localPlacement.X = workArea.X + localPlacement.X * scaling;
localPlacement.Y = workArea.Y + localPlacement.Y * scaling;
return localPlacement;
}
private Screen? ResolveTargetScreen(Window? referenceWindow)
{
if (referenceWindow is not null)
{
var referenceScreen = referenceWindow.Screens.ScreenFromWindow(referenceWindow);
if (referenceScreen is not null)
{
return referenceScreen;
}
}
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
return mainWindow?.Screens.Primary;
}
private static Size GetScreenViewportSize(Screen screen)
{
var scaling = Math.Max(0.1, screen.Scaling);
var workArea = screen.WorkingArea;
return new Size(workArea.Width / scaling, workArea.Height / scaling);
}
private double ResolveCellSize(FusedDesktopComponentPlacementSnapshot placement)
{
if (TryResolveScreenForPlacement(placement, out var screen))
{
var adapter = new FusedDesktopEditGridAdapter(_settingsFacade);
if (adapter.TryCreate(GetScreenViewportSize(screen), out var context))
{
return Math.Max(1, context.Metrics.CellSize);
}
}
if (placement.GridWidthCells is > 0 && placement.Width > 0)
{
return Math.Max(1, placement.Width / placement.GridWidthCells.Value);
}
if (placement.GridHeightCells is > 0 && placement.Height > 0)
{
return Math.Max(1, placement.Height / placement.GridHeightCells.Value);
}
return DefaultCellSize;
}
private bool TryResolveScreenForPlacement(
FusedDesktopComponentPlacementSnapshot placement,
out Screen screen)
{
var mainWindow = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
if (mainWindow is not null)
{
foreach (var candidate in mainWindow.Screens.All)
{
if (candidate.WorkingArea.Contains(new PixelPoint((int)placement.X, (int)placement.Y)))
{
screen = candidate;
return true;
}
}
if (mainWindow.Screens.Primary is not null)
{
screen = mainWindow.Screens.Primary;
return true;
}
}
screen = null!;
return false;
}
} }
public static class FusedDesktopManagerServiceFactory public static class FusedDesktopManagerServiceFactory

View File

@@ -4,7 +4,7 @@ internal static class PlondsClientServiceFactory
{ {
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL"; private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_MANIFEST_URL";
private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL"; private const string GitHubManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_GITHUB_MANIFEST_URL";
private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/plonds/PLONDS.json"; private const string DefaultS3ManifestUrl = "https://cn-nb1.rains3.com/lmdesktop/lanmountain/update/plonds/PLONDS.json";
private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json"; private const string DefaultGitHubManifestUrl = "https://github.com/wwiinnddyy/LanMountainDesktop/releases/latest/download/PLONDS.json";
public static IPlondsService CreateDefault(HttpClient? httpClient = null) public static IPlondsService CreateDefault(HttpClient? httpClient = null)

View File

@@ -1091,7 +1091,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
} }
var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false); var latest = await _plondsService.FindLatestAsync(currentVersion, cancellationToken).ConfigureAwait(false);
_pendingPlondsLatest = latest.Success && latest.IsUpdateAvailable ? latest : null; if (!latest.Success)
{
_pendingPlondsLatest = null;
_pendingPlondsCleanInstallCandidate = null;
_pendingPlondsInstallerManifest = null;
_pendingPlondsPackage = null;
TransitionPlonds(UpdatePhase.Idle);
SaveLastChecked();
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
}
_pendingPlondsLatest = latest.IsUpdateAvailable ? latest : null;
_pendingPlondsCleanInstallCandidate = _pendingPlondsLatest?.Candidates _pendingPlondsCleanInstallCandidate = _pendingPlondsLatest?.Candidates
.FirstOrDefault(candidate => candidate.Manifest.RequiresCleanInstall); .FirstOrDefault(candidate => candidate.Manifest.RequiresCleanInstall);
_pendingPlondsInstallerManifest = null; _pendingPlondsInstallerManifest = null;
@@ -1099,11 +1110,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
TransitionPlonds(UpdatePhase.Checked); TransitionPlonds(UpdatePhase.Checked);
SaveLastChecked(); SaveLastChecked();
if (!latest.Success)
{
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
}
var payloadKind = latest.IsUpdateAvailable var payloadKind = latest.IsUpdateAvailable
? _pendingPlondsCleanInstallCandidate is not null ? _pendingPlondsCleanInstallCandidate is not null
? UpdatePayloadKind.FullInstaller ? UpdatePayloadKind.FullInstaller
@@ -1368,7 +1374,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
_plondsPhase = phase; _plondsPhase = phase;
_phaseChanged?.Invoke(phase); _phaseChanged?.Invoke(phase);
_progressChanged?.Invoke(new UpdateProgressReport(phase, $"Phase changed to {phase}", 0, null, null)); _progressChanged?.Invoke(new UpdateProgressReport(phase, string.Empty, 0, null, null));
} }
private UpdateOrchestrator GetOrchestrator() private UpdateOrchestrator GetOrchestrator()

View File

@@ -222,7 +222,7 @@ internal sealed class UpdateInstallGateway
try try
{ {
AppLogger.Info("UpdateInstallGateway", "Launching full installer with elevation."); AppLogger.Info("UpdateInstallGateway", "Launching full installer with elevation.");
var workingDir = Path.GetDirectoryName(installerPath) ?? Path.GetDirectoryName(installerPath)!; var workingDir = Path.GetDirectoryName(installerPath) ?? AppContext.BaseDirectory;
var startInfo = new ProcessStartInfo var startInfo = new ProcessStartInfo
{ {

View File

@@ -39,7 +39,7 @@ internal sealed class UpdateStateStore
PhaseChanged?.Invoke(newPhase); PhaseChanged?.Invoke(newPhase);
ProgressChanged?.Invoke(new UpdateProgressReport( ProgressChanged?.Invoke(new UpdateProgressReport(
newPhase, newPhase,
$"Phase changed to {newPhase}", string.Empty,
0, 0,
null, null,
null)); null));

View File

@@ -2,6 +2,7 @@ using System;
using System.Globalization; using System.Globalization;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
@@ -114,18 +115,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
public bool IsBusy => CurrentPhase.IsBusy(); public bool IsBusy => CurrentPhase.IsBusy();
public bool IsPaused => CurrentPhase.IsPaused(); public bool IsPaused => CurrentPhase.IsPaused();
public bool CanCheck => CurrentPhase.CanCheck(); public bool CanCheck => CurrentPhase.CanCheck();
public bool CanDownload => CurrentPhase.CanDownload(); public bool CanDownload => IsUpdateAvailable && CurrentPhase.CanDownload();
public bool CanInstall => CurrentPhase.CanInstall(); public bool CanInstall => CurrentPhase.CanInstall();
public bool CanRollback => CurrentPhase.CanRollback(); public bool CanRollback => CurrentPhase.CanRollback();
public bool CanPause => CurrentPhase.CanPause(); public bool CanPause => CurrentPhase.CanPause();
public bool CanResume => CurrentPhase.CanResume(); public bool CanResume => CurrentPhase.CanResume();
public bool CanCancel => CurrentPhase.CanCancel(); public bool CanCancel => CurrentPhase.CanCancel();
public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack or UpdatePhase.Recovering; public bool IsProgressVisible => CurrentPhase is UpdatePhase.Checking or UpdatePhase.Downloading or UpdatePhase.PausedDownloading or UpdatePhase.Installing or UpdatePhase.Verifying or UpdatePhase.RollingBack or UpdatePhase.Recovering;
public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused; public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused || HasVisibleAction;
public string PhaseText => GetPhaseText(CurrentPhase); public string PhaseText => GetPhaseText(CurrentPhase);
public string LatestVersionDisplayText => string.IsNullOrEmpty(LatestVersionText) public string LatestVersionDisplayText => string.IsNullOrEmpty(LatestVersionText)
? L("settings.update.latest_version_none", "Up to date") ? L("settings.update.latest_version_none", "Up to date")
: LatestVersionText; : LatestVersionText;
private bool HasVisibleAction => CanDownload || CanInstall || CanRollback || CanPause || CanResume || CanCancel;
partial void OnCurrentPhaseChanged(UpdatePhase value) partial void OnCurrentPhaseChanged(UpdatePhase value)
{ {
@@ -150,6 +152,13 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
CancelCommand.NotifyCanExecuteChanged(); CancelCommand.NotifyCanExecuteChanged();
} }
partial void OnIsUpdateAvailableChanged(bool value)
{
OnPropertyChanged(nameof(CanDownload));
OnPropertyChanged(nameof(IsProgressSectionVisible));
DownloadCommand.NotifyCanExecuteChanged();
}
partial void OnSelectedUpdateChannelValueChanged(string value) partial void OnSelectedUpdateChannelValueChanged(string value)
{ {
SavePreferenceState(); SavePreferenceState();
@@ -221,9 +230,21 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty; PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
UpdateTypeText = GetUpdateTypeText(report.PayloadKind); UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds; IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds;
StatusMessage = report.LatestVersion is null var availableMessage = report.LatestVersion is null
? GetUpdateAvailableStatusText(string.Empty) ? GetUpdateAvailableStatusText(string.Empty)
: string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), report.LatestVersion); : string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Download it when you are ready."), report.LatestVersion);
StatusMessage = string.IsNullOrWhiteSpace(report.ErrorMessage)
? availableMessage
: $"{availableMessage} {report.ErrorMessage}";
}
else if (!string.IsNullOrWhiteSpace(report.ErrorMessage))
{
IsUpdateAvailable = false;
LatestVersionText = string.Empty;
PublishedAtText = string.Empty;
UpdateTypeText = string.Empty;
IsDeltaUpdate = false;
StatusMessage = report.ErrorMessage;
} }
else else
{ {
@@ -315,10 +336,15 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
private void OnUpdatePhaseChanged(UpdatePhase phase) private void OnUpdatePhaseChanged(UpdatePhase phase)
{ {
CurrentPhase = phase; RunOnUiThread(() => CurrentPhase = phase);
} }
private void OnUpdateProgressChanged(UpdateProgressReport report) private void OnUpdateProgressChanged(UpdateProgressReport report)
{
RunOnUiThread(() => ApplyUpdateProgress(report));
}
private void ApplyUpdateProgress(UpdateProgressReport report)
{ {
ProgressFraction = report.ProgressFraction; ProgressFraction = report.ProgressFraction;
@@ -338,13 +364,37 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
} }
else else
{ {
StatusMessage = string.IsNullOrWhiteSpace(report.Message) if (!string.IsNullOrWhiteSpace(report.Message))
? GetPhaseStatusText(CurrentPhase) {
: report.Message; StatusMessage = report.Message;
}
ProgressDetail = string.Empty; ProgressDetail = string.Empty;
} }
} }
private void RunOnUiThread(Action action)
{
if (_disposed)
{
return;
}
if (Dispatcher.UIThread.CheckAccess())
{
action();
return;
}
Dispatcher.UIThread.Post(() =>
{
if (!_disposed)
{
action();
}
}, DispatcherPriority.Normal);
}
private void LoadPreferenceState() private void LoadPreferenceState()
{ {
@@ -565,19 +615,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
=> L("settings.update.status_ready", "Ready to check for updates."); => L("settings.update.status_ready", "Ready to check for updates.");
private string GetCheckingStatusText() private string GetCheckingStatusText()
=> L("settings.update.status_checking", "Checking GitHub releases..."); => L("settings.update.status_checking", "Checking update sources...");
private string GetUpToDateStatusText() private string GetUpToDateStatusText()
=> L("settings.update.status_up_to_date", "You are already on the latest version."); => L("settings.update.status_up_to_date", "You are already on the latest version.");
private string GetUpdateAvailableStatusText(string version) private string GetUpdateAvailableStatusText(string version)
=> string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Click Download and Install."), version); => string.Format(CultureInfo.CurrentCulture, L("settings.update.status_available_format", "New version {0} is available. Download it when you are ready."), version);
private string GetDownloadingStatusText() private string GetDownloadingStatusText()
=> L("settings.update.status_downloading", "Downloading installer..."); => L("settings.update.status_downloading", "Downloading installer...");
private string GetDownloadCompleteStatusText() private string GetDownloadCompleteStatusText()
=> L("settings.update.status_launching_installer", "Download complete. Launching installer..."); => L("settings.update.status_launching_installer", "Download complete. You can install the update now.");
private string GetDownloadFailedStatusText() private string GetDownloadFailedStatusText()
=> L("settings.update.status_download_failed", "Download failed."); => L("settings.update.status_download_failed", "Download failed.");

View File

@@ -4,7 +4,10 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Threading; using Avalonia.Threading;
using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services; using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Views; namespace LanMountainDesktop.Views;
@@ -12,6 +15,7 @@ public partial class DesktopWidgetWindow : Window
{ {
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate(); private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate(); private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
private bool _isEditMode; private bool _isEditMode;
private bool _isDragging; private bool _isDragging;
@@ -174,8 +178,7 @@ public partial class DesktopWidgetWindow : Window
p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase)); p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase));
if (placement is not null) if (placement is not null)
{ {
placement.X = Position.X; ApplySnappedDragPlacement(placement);
placement.Y = Position.Y;
layoutService.Save(layout); layoutService.Save(layout);
} }
} }
@@ -183,6 +186,70 @@ public partial class DesktopWidgetWindow : Window
RefreshDesktopLayer(); RefreshDesktopLayer();
} }
private void ApplySnappedDragPlacement(FusedDesktopComponentPlacementSnapshot placement)
{
if (!TrySnapToCurrentScreenGrid(placement, Position, out var snappedPosition) ||
!snappedPosition.HasValue)
{
placement.X = Position.X;
placement.Y = Position.Y;
return;
}
placement.X = snappedPosition.Value.X;
placement.Y = snappedPosition.Value.Y;
Position = snappedPosition.Value;
}
private bool TrySnapToCurrentScreenGrid(
FusedDesktopComponentPlacementSnapshot placement,
PixelPoint requestedPosition,
out PixelPoint? snappedPosition)
{
snappedPosition = null;
var screen = Screens.ScreenFromWindow(this) ?? Screens.Primary;
if (screen is null)
{
return false;
}
var scaling = Math.Max(0.1, screen.Scaling);
var workArea = screen.WorkingArea;
var viewportSize = new Size(workArea.Width / scaling, workArea.Height / scaling);
var adapter = new FusedDesktopEditGridAdapter(_settingsFacade);
if (!adapter.TryCreate(viewportSize, out var context))
{
return false;
}
var requestedLocalOrigin = new Point(
(requestedPosition.X - workArea.X) / scaling,
(requestedPosition.Y - workArea.Y) / scaling);
var localPlacement = placement.Clone();
localPlacement.X = requestedLocalOrigin.X;
localPlacement.Y = requestedLocalOrigin.Y;
var snappedLocalPlacement = FusedDesktopPlacementMath.SnapToNearestCell(
localPlacement,
context.Geometry,
requestedLocalOrigin);
placement.Width = snappedLocalPlacement.Width;
placement.Height = snappedLocalPlacement.Height;
placement.GridColumn = snappedLocalPlacement.GridColumn;
placement.GridRow = snappedLocalPlacement.GridRow;
placement.GridWidthCells = snappedLocalPlacement.GridWidthCells;
placement.GridHeightCells = snappedLocalPlacement.GridHeightCells;
snappedPosition = new PixelPoint(
workArea.X + (int)Math.Round(snappedLocalPlacement.X * scaling),
workArea.Y + (int)Math.Round(snappedLocalPlacement.Y * scaling));
placement.X = snappedPosition.Value.X;
placement.Y = snappedPosition.Value.Y;
UpdateComponentLayout(placement.Width, placement.Height);
return true;
}
private void ShowContextMenu(PointerPressedEventArgs e) private void ShowContextMenu(PointerPressedEventArgs e)
{ {
var removeItem = new MenuItem var removeItem = new MenuItem

View File

@@ -146,15 +146,22 @@
TextWrapping="Wrap" TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<Border Grid.Row="2" <Border x:Name="PreviewInteractionHost"
Grid.Row="2"
CornerRadius="{DynamicResource DesignCornerRadiusLg}" CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}" Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}" BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
BorderThickness="1" BorderThickness="1"
Width="390" Width="390"
Height="230" Height="230"
Focusable="True"
HorizontalAlignment="Center" HorizontalAlignment="Center"
VerticalAlignment="Center"> VerticalAlignment="Center"
PointerPressed="OnPreviewPointerPressed"
PointerReleased="OnPreviewPointerReleased"
PointerCaptureLost="OnPreviewPointerCaptureLost"
PointerWheelChanged="OnPreviewPointerWheelChanged"
KeyDown="OnPreviewKeyDown">
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}" <Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}" BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Avalonia; using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using FluentIcons.Common; using FluentIcons.Common;
@@ -18,6 +19,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
{ {
public event EventHandler<string>? AddComponentRequested; public event EventHandler<string>? AddComponentRequested;
private const double PreviewSwipeThreshold = 48d;
private static readonly LocalizationService LocalizationService = new(); private static readonly LocalizationService LocalizationService = new();
private readonly ComponentLibraryWindowViewModel _viewModel = new(); private readonly ComponentLibraryWindowViewModel _viewModel = new();
@@ -28,9 +31,13 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService(); private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
private List<DesktopComponentDefinition> _allDefinitions = new(); private List<DesktopComponentDefinition> _allDefinitions = new();
private IReadOnlyList<DesktopComponentDefinition> _selectedCategoryDefinitions = [];
private int _selectedComponentIndex;
private ComponentRegistry? _componentRegistry; private ComponentRegistry? _componentRegistry;
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry; private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
private Control? _selectedPreviewControl; private Control? _selectedPreviewControl;
private bool _isPreviewSwipeActive;
private Point _previewSwipeStartPoint;
public FusedDesktopComponentLibraryControl() public FusedDesktopComponentLibraryControl()
{ {
@@ -157,17 +164,114 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
.Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase)) .Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase); .OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase);
var firstComponent = filtered.FirstOrDefault(); _selectedCategoryDefinitions = filtered.ToList();
if (firstComponent is null) _selectedComponentIndex = 0;
ApplySelectedComponentIndex();
}
private void ApplySelectedComponentIndex()
{
if (_selectedCategoryDefinitions.Count == 0)
{ {
_viewModel.SelectedComponent = null; _viewModel.SelectedComponent = null;
SetSelectedPreviewControl(null); SetSelectedPreviewControl(null);
return; return;
} }
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id) _selectedComponentIndex = NormalizeComponentIndex(_selectedComponentIndex);
?? CreateComponentItem(firstComponent, _settingsFacade.Region.Get().LanguageCode); var selectedDefinition = _selectedCategoryDefinitions[_selectedComponentIndex];
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent)); _viewModel.SelectedComponent = CreateComponentItem(selectedDefinition, _settingsFacade.Region.Get().LanguageCode);
SetSelectedPreviewControl(CreateStaticPreviewControl(selectedDefinition));
}
private int NormalizeComponentIndex(int index)
{
if (_selectedCategoryDefinitions.Count == 0)
{
return 0;
}
var count = _selectedCategoryDefinitions.Count;
return ((index % count) + count) % count;
}
private void MoveSelectedComponent(int direction)
{
if (_selectedCategoryDefinitions.Count <= 1 || direction == 0)
{
return;
}
_selectedComponentIndex = NormalizeComponentIndex(_selectedComponentIndex + direction);
ApplySelectedComponentIndex();
}
private void OnPreviewPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
_isPreviewSwipeActive = true;
_previewSwipeStartPoint = e.GetPosition(this);
PreviewInteractionHost.Focus();
e.Pointer.Capture(PreviewInteractionHost);
}
}
private void OnPreviewPointerReleased(object? sender, PointerReleasedEventArgs e)
{
_ = sender;
if (!_isPreviewSwipeActive)
{
return;
}
_isPreviewSwipeActive = false;
e.Pointer.Capture(null);
var endPoint = e.GetPosition(this);
var delta = endPoint - _previewSwipeStartPoint;
if (Math.Abs(delta.Y) < PreviewSwipeThreshold || Math.Abs(delta.Y) <= Math.Abs(delta.X))
{
return;
}
MoveSelectedComponent(delta.Y < 0 ? 1 : -1);
e.Handled = true;
}
private void OnPreviewPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
_ = sender;
_ = e;
_isPreviewSwipeActive = false;
}
private void OnPreviewPointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
_ = sender;
if (Math.Abs(e.Delta.Y) <= 0)
{
return;
}
MoveSelectedComponent(e.Delta.Y < 0 ? 1 : -1);
e.Handled = true;
}
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
{
_ = sender;
if (e.Key == Key.Down)
{
MoveSelectedComponent(1);
e.Handled = true;
}
else if (e.Key == Key.Up)
{
MoveSelectedComponent(-1);
e.Handled = true;
}
} }
private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition) private Control? CreateStaticPreviewControl(DesktopComponentDefinition definition)

View File

@@ -65,9 +65,8 @@ public partial class FusedDesktopComponentLibraryWindow : Window
private void OnAddComponentRequested(object? sender, string componentId) private void OnAddComponentRequested(object? sender, string componentId)
{ {
FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId); FusedDesktopManagerServiceFactory.GetOrCreate().AddComponent(componentId, this);
AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop."); AppLogger.Info("FusedDesktopLibrary", $"Added component '{componentId}' directly to fused desktop.");
Close();
} }
private void OnCloseClick(object? sender, RoutedEventArgs e) private void OnCloseClick(object? sender, RoutedEventArgs e)