mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-30 15:04:25 +08:00
Compare commits
3 Commits
e888b0423a
...
v0.8.8.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c88e305ee | ||
|
|
bb4e90ea8d | ||
|
|
75c7aece4f |
@@ -164,3 +164,25 @@
|
||||
|
||||
* ~~搜索功能~~:根据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`.
|
||||
|
||||
@@ -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 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.
|
||||
- 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
|
||||
|
||||
- `UpdateSettingsPage` shows Fluent Avalonia controls for channel, mode, thread count, forced reinstall, pause/resume, and cancel.
|
||||
- `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.
|
||||
- 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`.
|
||||
|
||||
@@ -5,19 +5,67 @@
|
||||
x:Class="LanDesktopPLONDS.Installer.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<Application.Resources>
|
||||
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">6</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusLg">10</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusComponent">12</CornerRadius>
|
||||
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F7F9FC" />
|
||||
<SolidColorBrush x:Key="InstallerTintBrush" Color="#DDF8FAFF" />
|
||||
<SolidColorBrush x:Key="InstallerSurfaceBrush" Color="#F9FFFFFF" />
|
||||
<SolidColorBrush x:Key="InstallerBorderBrush" Color="#22000000" />
|
||||
<SolidColorBrush x:Key="InstallerSecondaryTextBrush" Color="#A0000000" />
|
||||
<ResourceDictionary>
|
||||
<FontFamily x:Key="AppFontFamily">Inter, Segoe UI, Microsoft YaHei UI</FontFamily>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMicro">2</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXs">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusSm">4</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusMd">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusLg">8</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusXl">12</CornerRadius>
|
||||
<CornerRadius x:Key="DesignCornerRadiusComponent">8</CornerRadius>
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Default">
|
||||
<SolidColorBrush x:Key="InstallerWindowBackgroundBrush" Color="#F3F3F3" />
|
||||
<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.Styles>
|
||||
@@ -29,9 +77,14 @@
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
</Style>
|
||||
<Style Selector="fi|FluentIcon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
<Setter Property="FontWeight" Value="Normal" />
|
||||
</Style>
|
||||
<Style Selector="Button.titlebar-icon-button">
|
||||
<Setter Property="Width" Value="40" />
|
||||
<Setter Property="Height" Value="40" />
|
||||
@@ -41,19 +94,109 @@
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
</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">
|
||||
<Setter Property="Spacing" Value="18" />
|
||||
<Setter Property="Margin" Value="0,20,0,24" />
|
||||
<Setter Property="MaxWidth" Value="860" />
|
||||
<Setter Property="Spacing" Value="20" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="MaxWidth" Value="780" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.page-title-text">
|
||||
<Setter Property="FontSize" Value="28" />
|
||||
<Setter Property="FontSize" Value="30" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LineHeight" Value="38" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextPrimaryBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.page-description-text">
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
@@ -22,10 +22,12 @@ public partial class App : Application
|
||||
var privacyIdentity = new PrivacyDeviceIdentityProvider();
|
||||
var installService = OnlineInstallService.CreateDefault(privacyIdentity);
|
||||
var consentStore = new InstallerPrivacyConsentStore();
|
||||
desktop.MainWindow = new MainWindow
|
||||
var mainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(installService, privacyIdentity, consentStore)
|
||||
};
|
||||
desktop.MainWindow = mainWindow;
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||
<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>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -56,7 +56,7 @@ internal sealed class FilesPackageInstaller
|
||||
null));
|
||||
|
||||
ActivateInitialDeployment(launcherRoot, targetDeployment);
|
||||
CreateWindowsShortcutsIfAvailable(launcherRoot, options.CreateDesktopShortcut);
|
||||
CreateWindowsShortcutsIfAvailable(launcherRoot, options);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Completed",
|
||||
@@ -273,7 +273,7 @@ internal sealed class FilesPackageInstaller
|
||||
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
|
||||
{
|
||||
@@ -315,19 +315,25 @@ internal sealed class FilesPackageInstaller
|
||||
var shortcutPath = Path.Combine(programs, "LanMountainDesktop.url");
|
||||
WriteUrlShortcut(shortcutPath, launcherPath);
|
||||
|
||||
if (!createDesktopShortcut)
|
||||
if (options.CreateDesktopShortcut)
|
||||
{
|
||||
return;
|
||||
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||
if (!string.IsNullOrWhiteSpace(desktop))
|
||||
{
|
||||
Directory.CreateDirectory(desktop);
|
||||
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
|
||||
}
|
||||
}
|
||||
|
||||
var desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
|
||||
if (string.IsNullOrWhiteSpace(desktop))
|
||||
if (options.CreateStartupShortcut)
|
||||
{
|
||||
return;
|
||||
var startup = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
|
||||
if (!string.IsNullOrWhiteSpace(startup))
|
||||
{
|
||||
Directory.CreateDirectory(startup);
|
||||
WriteUrlShortcut(Path.Combine(startup, "LanMountainDesktop.url"), launcherPath);
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(desktop);
|
||||
WriteUrlShortcut(Path.Combine(desktop, "LanMountainDesktop.url"), launcherPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -2,5 +2,10 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = System.Text.Json.JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true)]
|
||||
[JsonSerializable(typeof(InstallerPlondsManifest))]
|
||||
internal sealed partial class InstallerJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -2,6 +2,8 @@ namespace LanDesktopPLONDS.Installer.Services;
|
||||
|
||||
public static class InstallerPathGuard
|
||||
{
|
||||
public const string ApplicationDirectoryName = "LanMountainDesktop";
|
||||
|
||||
public static string GetDefaultInstallPath()
|
||||
{
|
||||
var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
@@ -12,7 +14,29 @@ public static class InstallerPathGuard
|
||||
"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)
|
||||
|
||||
@@ -10,7 +10,7 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
|
||||
{
|
||||
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_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 static readonly JsonSerializerOptions JsonOptions = new()
|
||||
@@ -78,30 +78,54 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
|
||||
{
|
||||
var version = ParseVersion(candidate.Manifest.CurrentVersion).ToString();
|
||||
var packageRoot = Path.Combine(stagingRoot, SanitizePathSegment(version), SanitizePathSegment(candidate.Source.Id), "full");
|
||||
if (Directory.Exists(packageRoot))
|
||||
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)
|
||||
{
|
||||
Directory.Delete(packageRoot, recursive: true);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (Directory.Exists(packageRoot))
|
||||
{
|
||||
Directory.Delete(packageRoot, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(packageRoot);
|
||||
var zipPath = Path.Combine(packageRoot, "Files.zip");
|
||||
var extractDirectory = Path.Combine(packageRoot, "Files");
|
||||
Directory.CreateDirectory(extractDirectory);
|
||||
var attempt = candidate with { FilesZipUrl = filesZipUrl };
|
||||
|
||||
try
|
||||
{
|
||||
await DownloadToFileAsync(attempt, zipPath, progress, cancellationToken).ConfigureAwait(false);
|
||||
await VerifyPackageAsync(zipPath, attempt.Manifest, filesZipUrl, cancellationToken).ConfigureAwait(false);
|
||||
ExtractZip(zipPath, extractDirectory);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Files package prepared",
|
||||
version,
|
||||
1,
|
||||
0.10,
|
||||
"Files.zip",
|
||||
new FileInfo(zipPath).Length,
|
||||
new FileInfo(zipPath).Length));
|
||||
|
||||
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(packageRoot);
|
||||
var zipPath = Path.Combine(packageRoot, "Files.zip");
|
||||
var extractDirectory = Path.Combine(packageRoot, "Files");
|
||||
Directory.CreateDirectory(extractDirectory);
|
||||
|
||||
await DownloadToFileAsync(candidate, zipPath, progress, cancellationToken).ConfigureAwait(false);
|
||||
await VerifyPackageAsync(zipPath, candidate.Manifest, candidate.FilesZipUrl, cancellationToken).ConfigureAwait(false);
|
||||
ExtractZip(zipPath, extractDirectory);
|
||||
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Files package prepared",
|
||||
version,
|
||||
1,
|
||||
0.10,
|
||||
"Files.zip",
|
||||
new FileInfo(zipPath).Length,
|
||||
new FileInfo(zipPath).Length));
|
||||
|
||||
return new PreparedFilesPackage(version, candidate.Source.Id, zipPath, extractDirectory, candidate.Manifest);
|
||||
throw new InvalidOperationException("Failed to download and prepare the PLONDS Files package.", lastError);
|
||||
}
|
||||
|
||||
public static long EstimateInstallBytes(InstallerPlondsManifest manifest)
|
||||
@@ -140,33 +164,43 @@ internal sealed class InstallerPlondsClient(HttpClient httpClient, string stagin
|
||||
var totalBytes = response.Content.Headers.ContentLength;
|
||||
var partialPath = $"{destinationPath}.partial";
|
||||
long downloaded = 0;
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var target = File.Create(partialPath))
|
||||
try
|
||||
{
|
||||
var buffer = new byte[128 * 1024];
|
||||
while (true)
|
||||
await using (var source = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false))
|
||||
await using (var target = File.Create(partialPath))
|
||||
{
|
||||
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
var buffer = new byte[128 * 1024];
|
||||
while (true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
var read = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
downloaded += read;
|
||||
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Downloading Files.zip",
|
||||
candidate.Manifest.CurrentVersion,
|
||||
fraction,
|
||||
0,
|
||||
"Files.zip",
|
||||
downloaded,
|
||||
totalBytes));
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
|
||||
downloaded += read;
|
||||
var fraction = totalBytes is > 0 ? Math.Clamp((double)downloaded / totalBytes.Value, 0, 1) : 0;
|
||||
progress?.Report(new InstallerDeployProgress(
|
||||
"Downloading Files.zip",
|
||||
candidate.Manifest.CurrentVersion,
|
||||
fraction,
|
||||
0,
|
||||
"Files.zip",
|
||||
downloaded,
|
||||
totalBytes));
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(partialPath, destinationPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(partialPath))
|
||||
{
|
||||
File.Delete(partialPath);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(partialPath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
private static async Task VerifyPackageAsync(
|
||||
|
||||
@@ -63,9 +63,11 @@ public sealed record OnlineInstallPackageInfo(
|
||||
Uri FilesZipUrl,
|
||||
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(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using FluentIcons.Common;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
|
||||
namespace LanDesktopPLONDS.Installer.ViewModels;
|
||||
@@ -6,7 +7,7 @@ namespace LanDesktopPLONDS.Installer.ViewModels;
|
||||
public sealed partial class InstallerStepViewModel(
|
||||
InstallerStepId stepId,
|
||||
string title,
|
||||
string iconKey) : ObservableObject
|
||||
Icon icon) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private bool _isUnlocked;
|
||||
@@ -18,5 +19,5 @@ public sealed partial class InstallerStepViewModel(
|
||||
|
||||
public string Title { get; } = title;
|
||||
|
||||
public string IconKey { get; } = iconKey;
|
||||
public Icon Icon { get; } = icon;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using FluentIcons.Common;
|
||||
using LanDesktopPLONDS.Installer.Models;
|
||||
using LanDesktopPLONDS.Installer.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Privacy;
|
||||
@@ -14,7 +15,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
private readonly IPrivacyDeviceIdentityProvider _privacyIdentity;
|
||||
private readonly InstallerPrivacyConsentStore _privacyConsentStore;
|
||||
private CancellationTokenSource? _installCts;
|
||||
private bool _isNavigatingInternally;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
|
||||
@@ -61,13 +61,15 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(StartInstallCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(BackCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
|
||||
private bool _isInstalling;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _createDesktopShortcut;
|
||||
|
||||
[ObservableProperty]
|
||||
private InstallerStepViewModel? _selectedStep;
|
||||
private bool _createStartupShortcut;
|
||||
|
||||
public MainWindowViewModel(
|
||||
IOnlineInstallService installService,
|
||||
@@ -79,14 +81,13 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
_privacyConsentStore = privacyConsentStore ?? new InstallerPrivacyConsentStore();
|
||||
Steps =
|
||||
[
|
||||
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", "Play"),
|
||||
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", "Folder"),
|
||||
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", "Info"),
|
||||
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", "Apps"),
|
||||
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", "Circle")
|
||||
new InstallerStepViewModel(InstallerStepId.Welcome, "开始安装", Icon.Play),
|
||||
new InstallerStepViewModel(InstallerStepId.InstallLocation, "安装位置", Icon.Folder),
|
||||
new InstallerStepViewModel(InstallerStepId.PrivacyConfirm, "数据确认", Icon.Info),
|
||||
new InstallerStepViewModel(InstallerStepId.Deploy, "开始部署", Icon.ArrowDownload),
|
||||
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle)
|
||||
];
|
||||
SyncSteps();
|
||||
SelectedStep = Steps[0];
|
||||
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
|
||||
PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview);
|
||||
}
|
||||
@@ -109,6 +110,8 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
public bool IsCompleteStep => CurrentStep == InstallerStepId.Complete;
|
||||
|
||||
public bool HasError => !string.IsNullOrWhiteSpace(ErrorMessage);
|
||||
|
||||
public bool CanGoBack => CurrentStep > InstallerStepId.Welcome && !IsInstalling;
|
||||
|
||||
public bool CanGoNext => CurrentStep switch
|
||||
@@ -145,26 +148,24 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
SyncSteps();
|
||||
}
|
||||
|
||||
partial void OnErrorMessageChanged(string? value)
|
||||
{
|
||||
_ = value;
|
||||
OnPropertyChanged(nameof(HasError));
|
||||
}
|
||||
|
||||
partial void OnMaxUnlockedStepChanged(InstallerStepId value)
|
||||
{
|
||||
_ = value;
|
||||
SyncSteps();
|
||||
}
|
||||
|
||||
partial void OnSelectedStepChanged(InstallerStepViewModel? value)
|
||||
partial void OnIsInstallingChanged(bool value)
|
||||
{
|
||||
if (_isNavigatingInternally || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.StepId <= MaxUnlockedStep)
|
||||
{
|
||||
CurrentStep = value.StepId;
|
||||
return;
|
||||
}
|
||||
|
||||
SyncSteps();
|
||||
_ = value;
|
||||
OnPropertyChanged(nameof(CanGoBack));
|
||||
OnPropertyChanged(nameof(CanGoNext));
|
||||
OnPropertyChanged(nameof(CanStartInstall));
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoNext))]
|
||||
@@ -198,24 +199,48 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
[RelayCommand(CanExecute = nameof(CanGoBack))]
|
||||
private void Back()
|
||||
{
|
||||
if (IsInstalling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (CurrentStep > InstallerStepId.Welcome)
|
||||
{
|
||||
CurrentStep -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectStep(InstallerStepViewModel? step)
|
||||
{
|
||||
if (step is null || IsInstalling || step.StepId > MaxUnlockedStep)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentStep = step.StepId;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task BrowseAsync()
|
||||
{
|
||||
ErrorMessage = null;
|
||||
if (BrowseRequested is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = await BrowseRequested(InstallPath);
|
||||
if (!string.IsNullOrWhiteSpace(selected))
|
||||
try
|
||||
{
|
||||
InstallPath = selected;
|
||||
var selected = await BrowseRequested(InstallPath);
|
||||
if (!string.IsNullOrWhiteSpace(selected))
|
||||
{
|
||||
InstallPath = InstallerPathGuard.GetInstallPathForSelectedFolder(selected);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"选择安装位置失败:{ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +255,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
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);
|
||||
UnlockAndNavigate(InstallerStepId.Complete);
|
||||
StatusText = "安装完成";
|
||||
@@ -313,22 +338,10 @@ public sealed partial class MainWindowViewModel : ObservableObject
|
||||
|
||||
private void SyncSteps()
|
||||
{
|
||||
_isNavigatingInternally = true;
|
||||
try
|
||||
foreach (var step in Steps)
|
||||
{
|
||||
foreach (var step in Steps)
|
||||
{
|
||||
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
|
||||
step.IsSelected = step.StepId == CurrentStep;
|
||||
if (step.StepId == CurrentStep && !ReferenceEquals(SelectedStep, step))
|
||||
{
|
||||
SelectedStep = step;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isNavigatingInternally = false;
|
||||
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
|
||||
step.IsSelected = step.StepId == CurrentStep;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:vm="using:LanDesktopPLONDS.Installer.ViewModels"
|
||||
x:Class="LanDesktopPLONDS.Installer.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Width="1080"
|
||||
Height="720"
|
||||
MinWidth="860"
|
||||
Width="1040"
|
||||
Height="680"
|
||||
MinWidth="900"
|
||||
MinHeight="620"
|
||||
CanResize="True"
|
||||
x:Name="Root"
|
||||
Title="{Binding WindowTitle}"
|
||||
Background="Transparent"
|
||||
TransparencyLevelHint="Mica, AcrylicBlur, None"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="48"
|
||||
WindowDecorations="None">
|
||||
@@ -19,49 +20,129 @@
|
||||
<Style Selector="Grid.step-page">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="Grid.step-page.visible">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerSecondaryTextBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource InstallerTextSecondaryBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="LineHeight" Value="20" />
|
||||
</Style>
|
||||
<Style Selector="Border.inline-panel">
|
||||
<Setter Property="Background" Value="{DynamicResource InstallerSurfaceBrush}" />
|
||||
<Style Selector="Button.step-nav-item">
|
||||
<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="BorderThickness" Value="1" />
|
||||
<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>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid x:Name="RootGrid"
|
||||
Background="{DynamicResource InstallerWindowBackgroundBrush}"
|
||||
RowDefinitions="48,*">
|
||||
<Border Grid.RowSpan="2"
|
||||
Background="{DynamicResource InstallerTintBrush}"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Border Background="{DynamicResource InstallerWindowBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
|
||||
ClipToBounds="True">
|
||||
<Grid x:Name="RootGrid"
|
||||
RowDefinitions="48,*"
|
||||
Background="Transparent">
|
||||
<Border Grid.Row="0"
|
||||
Background="Transparent"
|
||||
Background="{DynamicResource InstallerWindowBackgroundBrush}"
|
||||
PointerPressed="OnTitleBarPointerPressed">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Margin="12,0,0,0"
|
||||
Spacing="8"
|
||||
Margin="16,0,0,0"
|
||||
Spacing="10"
|
||||
VerticalAlignment="Center">
|
||||
<fi:FluentIcon Icon="ArrowDownload"
|
||||
IconVariant="Regular"
|
||||
FontSize="18" />
|
||||
<Border Width="28"
|
||||
Height="28"
|
||||
Background="{DynamicResource InstallerAccentBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}">
|
||||
<fi:FluentIcon Icon="ArrowDownload"
|
||||
IconVariant="Regular"
|
||||
Foreground="{DynamicResource InstallerOnAccentBrush}"
|
||||
FontSize="16" />
|
||||
</Border>
|
||||
<TextBlock Text="{Binding WindowTitle}"
|
||||
FontSize="12"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="4"
|
||||
Spacing="2"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="titlebar-icon-button"
|
||||
@@ -82,240 +163,374 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ui:FANavigationView x:Name="StepNavigation"
|
||||
Grid.Row="1"
|
||||
PaneDisplayMode="Left"
|
||||
OpenPaneLength="272"
|
||||
IsPaneOpen="True"
|
||||
IsSettingsVisible="False"
|
||||
IsBackButtonVisible="False"
|
||||
IsPaneToggleButtonVisible="False"
|
||||
IsPaneVisible="True"
|
||||
MenuItemsSource="{Binding Steps}"
|
||||
SelectedItem="{Binding SelectedStep, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
Margin="0,0,0,0">
|
||||
<ui:FANavigationView.Resources>
|
||||
<SolidColorBrush x:Key="NavigationViewContentBackground" Color="Transparent" />
|
||||
<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="" />
|
||||
</ui:FANavigationViewItem.IconSource>
|
||||
</ui:FANavigationViewItem>
|
||||
</DataTemplate>
|
||||
</ui:FANavigationView.MenuItemTemplate>
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="260,10,*"
|
||||
Margin="10,0,10,10">
|
||||
<Border Grid.Column="0"
|
||||
Background="{DynamicResource InstallerPaneBackgroundBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Padding="22,24">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="阑山桌面"
|
||||
FontSize="22"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="在线安装程序"
|
||||
Classes="caption-text" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Margin="28,4,36,28"
|
||||
RowDefinitions="*,Auto">
|
||||
<Grid Grid.Row="0">
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsWelcomeStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="安装阑山桌面" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="在线安装程序会从 PLONDS 获取最新完整包,并部署到本机的版本目录结构中。" />
|
||||
<ui:FASettingsExpander Header="准备开始"
|
||||
Description="安装器将检查最新版本、下载 Files 完整包、校验并部署。">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="首版支持 Windows 首次安装。修复和增量更新入口将在后续版本开放。"
|
||||
Classes="muted" />
|
||||
<TextBlock Text="安装完成后将使用 LanMountainDesktop.Launcher 作为统一入口。"
|
||||
Classes="muted" />
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsLocationStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="选择安装位置" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装器保持一致。" />
|
||||
<ui:FASettingsExpander Header="安装目录"
|
||||
Description="安装根目录下会创建 .Launcher 和 app-{version}-0。">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBox Text="{Binding InstallPath, Mode=TwoWay}"
|
||||
PlaceholderText="安装路径" />
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding BrowseCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="FolderOpen"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="浏览" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</ui:FASettingsExpander>
|
||||
<ui:FASettingsExpander Header="安装后选项"
|
||||
Description="开始菜单快捷方式会自动创建,桌面快捷方式可选。">
|
||||
<StackPanel Spacing="10">
|
||||
<CheckBox IsChecked="{Binding CreateDesktopShortcut}"
|
||||
Content="创建桌面快捷方式" />
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsPrivacyStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="确认上传数据" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="请确认安装阶段需要使用的匿名数据类别。" />
|
||||
<ui:FASettingsExpander Header="匿名设备码"
|
||||
Description="与后续隐私计算使用同一设备码口径。">
|
||||
<TextBlock Text="{Binding DeviceIdPreview}"
|
||||
TextWrapping="Wrap"
|
||||
FontFamily="Consolas" />
|
||||
</ui:FASettingsExpander>
|
||||
<ui:FASettingsExpander Header="网络与统计"
|
||||
Description="服务端会接收 IP 地址,用于防 DDoS 与统计用户量。">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="muted"
|
||||
Text="安装器会发送匿名设备码、系统与架构信息、目标版本和请求 IP。不会上传用户名、机器名或安装目录。" />
|
||||
<CheckBox IsChecked="{Binding PrivacyConfirmed}"
|
||||
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsDeployStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="开始部署" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="安装时会下载 Files 完整包并写入当前版本目录。" />
|
||||
<Border Classes="inline-panel">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
RowDefinitions="Auto,Auto,Auto"
|
||||
ColumnSpacing="12"
|
||||
RowSpacing="8">
|
||||
<TextBlock Text="版本" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding TargetVersion}" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Text="来源" />
|
||||
<TextBlock Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Text="{Binding SourceId}" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="状态" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Text="{Binding StatusText}" />
|
||||
<ItemsControl Grid.Row="1"
|
||||
Margin="0,28,0,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>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="下载进度" />
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding DownloadProgress}" />
|
||||
<TextBlock Classes="muted"
|
||||
Text="{Binding DownloadBytesText}" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="安装进度" />
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding InstallProgress}" />
|
||||
<TextBlock Classes="muted"
|
||||
Text="{Binding CurrentFile}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button Command="{Binding StartInstallCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowDownload"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="开始安装" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Command="{Binding CancelInstallCommand}"
|
||||
IsEnabled="{Binding IsInstalling}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="取消" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsCompleteStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="完成安装" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="阑山桌面已经部署完成。" />
|
||||
<ui:FASettingsExpander Header="启动应用"
|
||||
Description="使用 Launcher 进入首次启动流程。">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="如果需要,可以从这里重新启动 LanMountainDesktop.Launcher。"
|
||||
Classes="muted" />
|
||||
<Button Command="{Binding LaunchCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
<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"
|
||||
IsVisible="{Binding IsWelcomeStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="安装阑山桌面" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="在线安装程序会获取最新完整包,并把应用部署到本机版本目录。" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="content-card">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="14">
|
||||
<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">
|
||||
<fi:FluentIcon Icon="Play"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="启动阑山桌面" />
|
||||
<TextBlock Text="准备开始"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="安装器会检查最新版本、下载完整包、校验文件并激活部署。"
|
||||
Classes="muted" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</ui:FASettingsExpander>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="*,Auto,Auto"
|
||||
ColumnSpacing="8"
|
||||
Margin="0,16,0,0">
|
||||
<TextBlock Text="{Binding ErrorMessage}"
|
||||
Foreground="#C42B1C"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding BackCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="上一步" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Command="{Binding NextCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<TextBlock Text="下一步" />
|
||||
<fi:FluentIcon Icon="ArrowRight"
|
||||
IconVariant="Regular" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsLocationStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="选择安装位置" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="请选择一个专用文件夹。默认位置需要管理员权限,和现有安装方式保持一致。" />
|
||||
</StackPanel>
|
||||
|
||||
<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"
|
||||
ColumnSpacing="10">
|
||||
<TextBox Text="{Binding InstallPath, Mode=TwoWay}"
|
||||
PlaceholderText="安装路径" />
|
||||
<Button Grid.Column="1"
|
||||
Classes="secondary-command"
|
||||
Command="{Binding BrowseCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="FolderOpen"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="浏览" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
<CheckBox IsChecked="{Binding CreateDesktopShortcut}"
|
||||
Content="创建桌面快捷方式" />
|
||||
<CheckBox IsChecked="{Binding CreateStartupShortcut}"
|
||||
Content="开机时自动启动阑山桌面" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsPrivacyStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="确认数据使用" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="安装阶段需要使用匿名设备码和基础请求信息,用于安装、风控和用户量统计。" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="content-card">
|
||||
<StackPanel Spacing="16">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="匿名设备码"
|
||||
FontSize="16"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding DeviceIdPreview}"
|
||||
TextWrapping="Wrap"
|
||||
FontFamily="Consolas"
|
||||
Foreground="{DynamicResource InstallerTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<Border Classes="info-panel">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="10">
|
||||
<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}"
|
||||
Content="我确认上述匿名数据可用于安装、风控和用户量统计。" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsDeployStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="开始部署" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="安装时会下载完整包,并写入当前版本目录。" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="content-card">
|
||||
<StackPanel Spacing="18">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
RowDefinitions="Auto,Auto,Auto"
|
||||
ColumnSpacing="18"
|
||||
RowSpacing="10">
|
||||
<TextBlock Classes="meta-label"
|
||||
Text="版本" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Classes="meta-value"
|
||||
Text="{Binding TargetVersion}" />
|
||||
<Border Grid.Row="1"
|
||||
Grid.ColumnSpan="2"
|
||||
Classes="separator" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Classes="meta-label"
|
||||
Text="来源" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Grid.Column="1"
|
||||
Classes="meta-value"
|
||||
Text="{Binding SourceId}" />
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="{Binding StatusText}"
|
||||
FontWeight="SemiBold" />
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding DownloadProgress}" />
|
||||
<TextBlock Classes="caption-text"
|
||||
Text="{Binding DownloadBytesText}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="安装进度"
|
||||
FontWeight="SemiBold" />
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding InstallProgress}" />
|
||||
<TextBlock Classes="caption-text"
|
||||
Text="{Binding CurrentFile}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<Button Classes="primary-command"
|
||||
Command="{Binding StartInstallCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowDownload"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="开始安装" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="secondary-command"
|
||||
Command="{Binding CancelInstallCommand}"
|
||||
IsEnabled="{Binding IsInstalling}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Dismiss"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="取消" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Classes="step-page"
|
||||
IsVisible="{Binding IsCompleteStep}">
|
||||
<StackPanel Classes="installer-page-container">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="page-title-text"
|
||||
Text="完成安装" />
|
||||
<TextBlock Classes="page-description-text"
|
||||
Text="阑山桌面已经部署完成。" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="content-card">
|
||||
<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" />
|
||||
</StackPanel>
|
||||
<Button Classes="primary-command"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding LaunchCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="Play"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="打开阑山桌面" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Background="Transparent"
|
||||
Padding="36,16,42,18">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto"
|
||||
ColumnSpacing="8">
|
||||
<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"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button Grid.Column="1"
|
||||
Classes="secondary-command"
|
||||
Command="{Binding BackCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<fi:FluentIcon Icon="ArrowLeft"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock Text="上一步" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Classes="primary-command"
|
||||
Command="{Binding NextCommand}">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Spacing="6">
|
||||
<TextBlock Text="下一步" />
|
||||
<fi:FluentIcon Icon="ArrowRight"
|
||||
IconVariant="Regular" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ui:FANavigationView>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
@@ -24,9 +24,12 @@ public partial class MainWindow : Window
|
||||
|
||||
private async Task<string?> BrowseForFolderAsync(string currentPath)
|
||||
{
|
||||
var startFolder = Directory.Exists(currentPath)
|
||||
? await StorageProvider.TryGetFolderFromPathAsync(currentPath)
|
||||
: null;
|
||||
IStorageFolder? startFolder = null;
|
||||
if (Directory.Exists(currentPath))
|
||||
{
|
||||
startFolder = await StorageProvider.TryGetFolderFromPathAsync(currentPath);
|
||||
}
|
||||
|
||||
var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "选择安装位置",
|
||||
@@ -34,12 +37,28 @@ public partial class MainWindow : Window
|
||||
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)
|
||||
{
|
||||
_ = sender;
|
||||
if (e.Source is Button)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
BeginMoveDrag(e);
|
||||
|
||||
18
LanDesktopPLONDS.installer/app.Debug.manifest
Normal file
18
LanDesktopPLONDS.installer/app.Debug.manifest
Normal 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>
|
||||
@@ -132,6 +132,27 @@ internal sealed class DataLocationResolver
|
||||
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)
|
||||
{
|
||||
if (config is null)
|
||||
@@ -193,18 +214,8 @@ internal sealed class DataLocationResolver
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable
|
||||
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||
? customPath
|
||||
: DefaultPortableDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
{
|
||||
DataLocationMode = mode.ToString(),
|
||||
SystemDataPath = _defaultSystemDataPath,
|
||||
PortableDataPath = mode == DataLocationMode.Portable ? targetDataRoot : null
|
||||
};
|
||||
var config = BuildConfig(mode, customPath);
|
||||
var targetDataRoot = ResolveDataRoot(config);
|
||||
|
||||
// 先创建目录结构
|
||||
try
|
||||
|
||||
@@ -57,6 +57,23 @@ internal sealed class OobeCompletionResult
|
||||
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(
|
||||
bool IsElevated,
|
||||
string UserName,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
|
||||
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
|
||||
{
|
||||
Task RunAsync(CancellationToken cancellationToken);
|
||||
Task<OobeStepResult> RunAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
92
LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs
Normal file
92
LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -7,10 +7,12 @@ internal sealed class OobeStateService
|
||||
{
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
private readonly string _appRoot;
|
||||
private readonly string? _stateRootOverride;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly string _statePath;
|
||||
private readonly string _legacyStatePath;
|
||||
private readonly string _legacyMarkerPath;
|
||||
private readonly IReadOnlyList<string> _legacyStatePaths;
|
||||
private readonly IReadOnlyList<string> _legacyMarkerPaths;
|
||||
private readonly LauncherExecutionSnapshot _executionSnapshot;
|
||||
|
||||
public OobeStateService(
|
||||
@@ -18,21 +20,17 @@ internal sealed class OobeStateService
|
||||
string? stateRootOverride = null,
|
||||
LauncherExecutionSnapshot? executionSnapshot = null)
|
||||
{
|
||||
_ = Path.GetFullPath(appRoot);
|
||||
_appRoot = Path.GetFullPath(appRoot);
|
||||
_stateRootOverride = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? null
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_executionSnapshot = executionSnapshot ?? LauncherExecutionContext.Capture();
|
||||
|
||||
var stateRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? ResolveStateRoot(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
_stateDirectory = Path.Combine(stateRoot, "Launcher", "state");
|
||||
_statePath = Path.Combine(_stateDirectory, "oobe-state.json");
|
||||
var stateRoot = ResolveCurrentStateRoot();
|
||||
(_stateDirectory, _statePath) = BuildStatePaths(stateRoot);
|
||||
|
||||
var legacyRoot = string.IsNullOrWhiteSpace(stateRootOverride)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Path.GetFullPath(stateRootOverride);
|
||||
var legacyStateDirectory = Path.Combine(legacyRoot, ".launcher", "state");
|
||||
_legacyStatePath = Path.Combine(legacyStateDirectory, "oobe-state.json");
|
||||
_legacyMarkerPath = Path.Combine(legacyStateDirectory, "first_run_completed");
|
||||
_legacyStatePaths = BuildLegacyPaths("oobe-state.json");
|
||||
_legacyMarkerPaths = BuildLegacyPaths("first_run_completed");
|
||||
}
|
||||
|
||||
public OobeLaunchDecision Evaluate(CommandContext context)
|
||||
@@ -47,10 +45,17 @@ internal sealed class OobeStateService
|
||||
}
|
||||
|
||||
public OobeCompletionResult MarkCompleted(CommandContext context)
|
||||
{
|
||||
return MarkCompleted(context, null);
|
||||
}
|
||||
|
||||
public OobeCompletionResult MarkCompleted(CommandContext context, string? stateRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var (stateDirectory, statePath) = BuildStatePaths(
|
||||
string.IsNullOrWhiteSpace(stateRoot) ? ResolveCurrentStateRoot() : Path.GetFullPath(stateRoot));
|
||||
Directory.CreateDirectory(stateDirectory);
|
||||
var payload = new OobeStateFile
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
@@ -60,14 +65,14 @@ internal sealed class OobeStateService
|
||||
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);
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _statePath, overwrite: true);
|
||||
File.Move(tempPath, statePath, overwrite: true);
|
||||
TryDeleteLegacyMarker();
|
||||
|
||||
Logger.Info(
|
||||
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{_statePath}'; " +
|
||||
$"OOBE completion persisted. LaunchSource='{context.LaunchSource}'; StatePath='{statePath}'; " +
|
||||
$"UserSid='{_executionSnapshot.UserSid ?? string.Empty}'.");
|
||||
|
||||
return new OobeCompletionResult
|
||||
@@ -110,20 +115,27 @@ internal sealed class OobeStateService
|
||||
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);
|
||||
}
|
||||
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
foreach (var legacyMarkerPath in _legacyMarkerPaths)
|
||||
{
|
||||
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||
}
|
||||
|
||||
if (_executionSnapshot.IsElevated)
|
||||
{
|
||||
return BuildSuppressedDecision(context, "elevated", "oobe_suppressed_elevated");
|
||||
if (File.Exists(legacyMarkerPath))
|
||||
{
|
||||
migratedLegacyMarker = TryMigrateLegacyMarker(context);
|
||||
return BuildDecision(context, OobeStateStatus.Completed, shouldShowOobe: false, usedLegacyMarker: true, migratedLegacyMarker: migratedLegacyMarker);
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(context.LaunchSource, "postinstall", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -159,15 +171,18 @@ internal sealed class OobeStateService
|
||||
|
||||
private void TryDeleteLegacyMarker()
|
||||
{
|
||||
try
|
||||
foreach (var legacyMarkerPath in _legacyMarkerPaths)
|
||||
{
|
||||
if (File.Exists(_legacyMarkerPath))
|
||||
try
|
||||
{
|
||||
if (File.Exists(legacyMarkerPath))
|
||||
{
|
||||
File.Delete(legacyMarkerPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
File.Delete(_legacyMarkerPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
try
|
||||
@@ -243,4 +296,15 @@ internal sealed class OobeStateService
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
{
|
||||
private readonly CommandContext _context;
|
||||
private readonly OobeStateService _oobeStateService;
|
||||
private readonly DataLocationResolver _dataLocationResolver;
|
||||
|
||||
public WelcomeOobeStep(OobeStateService oobeStateService, CommandContext context)
|
||||
public WelcomeOobeStep(
|
||||
OobeStateService oobeStateService,
|
||||
CommandContext context,
|
||||
DataLocationResolver dataLocationResolver)
|
||||
{
|
||||
_oobeStateService = oobeStateService;
|
||||
_context = context;
|
||||
_dataLocationResolver = dataLocationResolver;
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
public async Task<OobeStepResult> RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -27,16 +32,32 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
|
||||
if (window is null)
|
||||
{
|
||||
return;
|
||||
return BuildCancelledResult("OOBE window could not be created.");
|
||||
}
|
||||
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
var completion = _oobeStateService.MarkCompleted(_context);
|
||||
var draft = await window.WaitForCompletionAsync().ConfigureAwait(false);
|
||||
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)
|
||||
{
|
||||
Logger.Warn(
|
||||
$"OOBE completion state was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||
$"OOBE session was not persisted. ResultCode='{completion.ResultCode}'; " +
|
||||
$"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(() =>
|
||||
@@ -46,5 +67,16 @@ internal sealed class WelcomeOobeStep : IOobeStep
|
||||
window.Close();
|
||||
}
|
||||
});
|
||||
|
||||
return OobeStepResult.Continue;
|
||||
}
|
||||
|
||||
private static OobeStepResult BuildCancelledResult(string message)
|
||||
{
|
||||
return OobeStepResult.Complete(LaunchResultBuilder.Build(
|
||||
false,
|
||||
"oobe",
|
||||
"oobe_cancelled",
|
||||
message));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using LanMountainDesktop.Shared.IPC;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
|
||||
@@ -24,11 +25,21 @@ internal sealed class AirAppRuntimeBridge
|
||||
return;
|
||||
}
|
||||
|
||||
var process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||
_appRoot,
|
||||
Environment.ProcessId,
|
||||
0,
|
||||
_dataRoot));
|
||||
Process? process;
|
||||
try
|
||||
{
|
||||
process = AirAppRuntimeProcessStarter.Start(new AirAppRuntimeStartRequest(
|
||||
_appRoot,
|
||||
Environment.ProcessId,
|
||||
0,
|
||||
_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}'.");
|
||||
|
||||
for (var attempt = 1; attempt <= ConnectAttempts; attempt++)
|
||||
|
||||
@@ -116,7 +116,7 @@ internal static class PreviewEntryHandler
|
||||
{
|
||||
try
|
||||
{
|
||||
await window.WaitForEnterAsync().ConfigureAwait(false);
|
||||
await window.WaitForCompletionAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -24,8 +24,6 @@ internal static class LauncherGuiCoordinator
|
||||
var startupAttemptRegistry = new StartupAttemptRegistry();
|
||||
var coordinatorPipeName = LauncherCoordinatorIpcServer.CreatePipeName();
|
||||
var successPolicy = LauncherOrchestrator.ResolveSuccessPolicyKey(context);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.EnsureStartedAsync().ConfigureAwait(false);
|
||||
|
||||
if (!startupAttemptRegistry.TryReserveCoordinator(
|
||||
context.LaunchSource,
|
||||
@@ -123,6 +121,7 @@ internal static class LauncherGuiCoordinator
|
||||
if (result.Success)
|
||||
{
|
||||
var hostPid = ResolveManagedHostPid(result, startupAttemptRegistry.GetOwnedAttempt()?.HostPid ?? 0);
|
||||
var airAppRuntimeBridge = new AirAppRuntimeBridge(appRoot, dataLocationResolver.ResolveDataRoot());
|
||||
await airAppRuntimeBridge.AttachHostAsync(hostPid).ConfigureAwait(false);
|
||||
await WaitForHostProcessToExitAsync(hostPid).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -35,8 +35,7 @@ internal sealed class LauncherOrchestrator
|
||||
_dataLocationResolver = new DataLocationResolver(deploymentLocator.GetAppRoot());
|
||||
_oobeSteps =
|
||||
[
|
||||
new WelcomeOobeStep(_oobeStateService, _context),
|
||||
new DataLocationOobeStep(_dataLocationResolver)
|
||||
new WelcomeOobeStep(_oobeStateService, _context, _dataLocationResolver)
|
||||
];
|
||||
_pipeline = pipeline ?? new LaunchPipeline(
|
||||
[
|
||||
|
||||
@@ -108,6 +108,8 @@ internal sealed class HostLaunchService
|
||||
return HostLaunchOutcome.FromResult(prerequisiteFailure);
|
||||
}
|
||||
|
||||
await EnsureAirAppRuntimeStartedAsync(context.DeploymentLocator.GetAppRoot(), dataRoot).ConfigureAwait(false);
|
||||
|
||||
var hostPath = plan.HostPath;
|
||||
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
|
||||
{
|
||||
@@ -204,6 +206,18 @@ internal sealed class HostLaunchService
|
||||
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(
|
||||
HostLaunchPlan plan,
|
||||
HostStartMode startMode,
|
||||
|
||||
@@ -13,7 +13,18 @@ internal sealed class OobeGatePhase : ILaunchPhase
|
||||
await LaunchUiPresenter.HideSplashAsync(context.SplashWindow).ConfigureAwait(false);
|
||||
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);
|
||||
|
||||
@@ -126,7 +126,7 @@ public partial class DevDebugWindow : Window
|
||||
try
|
||||
{
|
||||
// 等待用户点击开始按钮
|
||||
await oobeWindow.WaitForEnterAsync();
|
||||
await oobeWindow.WaitForCompletionAsync();
|
||||
|
||||
// 用户点击后,窗口会自动关闭(通过OobeWindow内部的动画和关闭逻辑)
|
||||
Console.WriteLine("[DevDebugWindow] OOBE completed by user");
|
||||
|
||||
@@ -17,10 +17,11 @@ public partial class OobeWindow : Window
|
||||
private const int AnimationDurationMs = 300;
|
||||
private const int TypingDelayMs = 100;
|
||||
|
||||
private readonly TaskCompletionSource<bool> _completionSource = new();
|
||||
private readonly TaskCompletionSource<OobeSessionDraft?> _completionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly DataLocationResolver _resolver;
|
||||
private bool _isTransitioning;
|
||||
private bool _isDebugMode;
|
||||
private bool _isCompleting;
|
||||
private int _currentStep = 1;
|
||||
|
||||
// 数据位置选择
|
||||
@@ -40,6 +41,7 @@ public partial class OobeWindow : Window
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
Loaded += OnWindowLoaded;
|
||||
Opened += OnWindowOpened;
|
||||
Closed += OnWindowClosed;
|
||||
|
||||
var appRoot = AppDomain.CurrentDomain.BaseDirectory;
|
||||
_resolver = new DataLocationResolver(appRoot);
|
||||
@@ -51,7 +53,7 @@ public partial class OobeWindow : Window
|
||||
_isDebugMode = isDebugMode;
|
||||
}
|
||||
|
||||
public Task WaitForEnterAsync() => _completionSource.Task;
|
||||
internal Task<OobeSessionDraft?> WaitForCompletionAsync() => _completionSource.Task;
|
||||
|
||||
private void OnWindowLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -261,6 +263,14 @@ public partial class OobeWindow : Window
|
||||
await PlayTypingAnimationAsync();
|
||||
}
|
||||
|
||||
private void OnWindowClosed(object? sender, EventArgs e)
|
||||
{
|
||||
if (!_isCompleting)
|
||||
{
|
||||
_completionSource.TrySetResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PlayTypingAnimationAsync()
|
||||
{
|
||||
var typingTextBlock = this.FindControl<TextBlock>("TypingTextBlock");
|
||||
@@ -477,11 +487,6 @@ public partial class OobeWindow : Window
|
||||
if (_isTransitioning) return;
|
||||
|
||||
// 应用数据位置选择
|
||||
if (!_isDebugMode)
|
||||
{
|
||||
_resolver.ApplyLocationChoice(_selectedDataLocationMode, null, _migrateExistingData);
|
||||
}
|
||||
|
||||
await NavigateToStep(4);
|
||||
}
|
||||
|
||||
@@ -495,7 +500,6 @@ public partial class OobeWindow : Window
|
||||
private async void OnStartupPresentationNextClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isTransitioning) return;
|
||||
SaveOobeStartupPresentation();
|
||||
await NavigateToStep(5);
|
||||
}
|
||||
|
||||
@@ -521,7 +525,7 @@ public partial class OobeWindow : Window
|
||||
|
||||
private void RefreshOobeStartupPresentationFromDisk()
|
||||
{
|
||||
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(_resolver.ResolveDataRoot());
|
||||
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(ResolveSelectedDataRoot());
|
||||
var defaults = HostAppSettingsOobeMerger.LoadStartupDefaults(path);
|
||||
|
||||
if (this.FindControl<Border>("OobeSlideTransitionSection") is { } slideSection)
|
||||
@@ -675,8 +679,6 @@ public partial class OobeWindow : Window
|
||||
if (_isTransitioning) return;
|
||||
|
||||
// 保存隐私设置
|
||||
SavePrivacySettings();
|
||||
|
||||
await NavigateToStep(6);
|
||||
}
|
||||
|
||||
@@ -725,13 +727,15 @@ public partial class OobeWindow : Window
|
||||
try
|
||||
{
|
||||
await PlayExitAnimationAsync();
|
||||
_completionSource.TrySetResult(true);
|
||||
_isCompleting = true;
|
||||
_completionSource.TrySetResult(BuildSessionDraft());
|
||||
Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[OobeWindow] Error: {ex.Message}");
|
||||
_completionSource.TrySetResult(true);
|
||||
_isCompleting = true;
|
||||
_completionSource.TrySetResult(BuildSessionDraft());
|
||||
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 EaseOutQuad(double t) => 1 - Math.Pow(1 - t, 2);
|
||||
private static double EaseOutBack(double t)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using LanMountainDesktop.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -170,4 +171,123 @@ public sealed class DesktopPlacementMathTests
|
||||
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,17 +56,97 @@ public sealed class OnlineInstallerCoreTests : IDisposable
|
||||
public async Task InstallerWorkflowNavigation_AllowsOnlyUnlockedSteps()
|
||||
{
|
||||
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.True(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome).IsSelected);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public void FilesZipUrlResolver_PrefersSourceSpecificThenDerivedThenFallbacks()
|
||||
{
|
||||
@@ -84,6 +164,45 @@ public sealed class OnlineInstallerCoreTests : IDisposable
|
||||
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]
|
||||
[InlineData("")]
|
||||
[InlineData("C:\\")]
|
||||
@@ -141,7 +260,43 @@ public sealed class OnlineInstallerCoreTests : IDisposable
|
||||
manifest,
|
||||
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(
|
||||
@@ -173,6 +328,8 @@ public sealed class OnlineInstallerCoreTests : IDisposable
|
||||
|
||||
private sealed class FakeInstallService : IOnlineInstallService
|
||||
{
|
||||
public OnlineInstallOptions? LastOptions { get; private set; }
|
||||
|
||||
public Task<OnlineInstallPackageInfo> CheckLatestAsync(CancellationToken cancellationToken)
|
||||
=> 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,
|
||||
IProgress<InstallerDeployProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
{
|
||||
LastOptions = options;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RepairAsync(string installPath, IProgress<InstallerDeployProgress>? progress, CancellationToken cancellationToken)
|
||||
=> throw new NotSupportedException();
|
||||
@@ -205,4 +365,33 @@ public sealed class OnlineInstallerCoreTests : IDisposable
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
LanMountainDesktop.Tests/OobeSessionCommitServiceTests.cs
Normal file
118
LanMountainDesktop.Tests/OobeSessionCommitServiceTests.cs
Normal 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");
|
||||
}
|
||||
@@ -66,16 +66,80 @@ public sealed class OobeStateServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Evaluate_SuppressesOobe_ForElevatedFirstRun()
|
||||
public void Evaluate_ReturnsFirstRun_ForElevatedFirstRun()
|
||||
{
|
||||
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
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.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]
|
||||
@@ -119,5 +183,7 @@ public sealed class OobeStateServiceTests : IDisposable
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
47
LanMountainDesktop.Tests/UpdateInstallGatewayTests.cs
Normal file
47
LanMountainDesktop.Tests/UpdateInstallGatewayTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
Assert.Equal(1, update.CheckCalls);
|
||||
Assert.Equal("1.2.3", viewModel.LatestVersionText);
|
||||
Assert.True(viewModel.IsDeltaUpdate);
|
||||
Assert.True(viewModel.CanDownload);
|
||||
Assert.True(viewModel.IsProgressSectionVisible);
|
||||
|
||||
update.SetPhase(UpdatePhase.Checked);
|
||||
await ((IAsyncRelayCommand)viewModel.DownloadCommand).ExecuteAsync(null);
|
||||
@@ -62,6 +64,36 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
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]
|
||||
public void UpdateSettingsViewModel_SavesPreferencesThroughUpdateSettingsService()
|
||||
{
|
||||
@@ -140,6 +172,32 @@ public sealed class UpdateSettingsInterfaceTests
|
||||
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]
|
||||
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -537,11 +537,11 @@
|
||||
"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_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_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_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_delta": "Downloading incremental 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_full": "Full Installer",
|
||||
"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_started": "Installer started. The app will close for update.",
|
||||
"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_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_checking": "Checking GitHub releases...",
|
||||
"settings.update.status_checking": "Checking update sources...",
|
||||
"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_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_delta": "Downloading incremental 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_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_started": "Installer started. The app will close for update.",
|
||||
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
|
||||
|
||||
@@ -430,14 +430,14 @@
|
||||
"settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。",
|
||||
"settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。",
|
||||
"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_up_to_date": "最新バージョンを使用しています。",
|
||||
"settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。",
|
||||
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。",
|
||||
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。準備ができたらダウンロードできます。",
|
||||
"settings.update.status_downloading": "インストーラーをダウンロード中...",
|
||||
"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_started": "インストーラーが開始されました。アプリはアップデートのために終了します。",
|
||||
"settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",
|
||||
|
||||
@@ -478,14 +478,14 @@
|
||||
"settings.update.status_channel_changed": "업데이트 채널이 변경되었습니다. 다시 업데이트를 확인하세요.",
|
||||
"settings.update.status_channel_changed_format": "업데이트 채널이 {0}(으)로 전환되었습니다. 다시 업데이트를 확인하세요.",
|
||||
"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_up_to_date": "현재 최신 버전입니다.",
|
||||
"settings.update.status_asset_missing": "새 버전이 발견되었지만 호환되는 설치 패키지를 찾을 수 없습니다.",
|
||||
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. \"다운로드 및 설치\"를 클릭하여 계속하세요.",
|
||||
"settings.update.status_available_format": "새 버전 {0}이(가) 발견되었습니다. 준비되면 업데이트를 다운로드하세요.",
|
||||
"settings.update.status_downloading": "설치 패키지 다운로드 중...",
|
||||
"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_started": "설치 프로그램이 시작되었습니다. 앱이 업데이트를 위해 종료됩니다.",
|
||||
"settings.update.status_elevation_cancelled": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.",
|
||||
|
||||
@@ -537,11 +537,11 @@
|
||||
"settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。",
|
||||
"settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。",
|
||||
"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_up_to_date": "当前已是最新版本。",
|
||||
"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_delta": "正在下载增量更新包...",
|
||||
"settings.update.status_delta_applying": "正在应用增量更新,应用将关闭进行更新。",
|
||||
@@ -550,7 +550,7 @@
|
||||
"settings.update.type_delta": "增量更新",
|
||||
"settings.update.type_full": "完整安装包",
|
||||
"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_started": "安装程序已启动,应用将关闭进行更新。",
|
||||
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
|
||||
|
||||
@@ -3,7 +3,9 @@ using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
@@ -17,7 +19,7 @@ public interface IFusedDesktopManagerService
|
||||
void Initialize();
|
||||
void ReloadWidgets();
|
||||
void Shutdown();
|
||||
void AddComponent(string componentId);
|
||||
void AddComponent(string componentId, Window? referenceWindow = null);
|
||||
void RemoveComponent(string placementId);
|
||||
void EnterEditMode();
|
||||
void ExitEditMode();
|
||||
@@ -40,8 +42,6 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
private bool _isEditMode;
|
||||
|
||||
private const double DefaultCellSize = 100;
|
||||
private const double DefaultComponentWidth = 200;
|
||||
private const double DefaultComponentHeight = 200;
|
||||
|
||||
public bool IsEditMode => _isEditMode;
|
||||
|
||||
@@ -109,7 +109,7 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
AppLogger.Info("FusedDesktop", "Exited edit mode.");
|
||||
}
|
||||
|
||||
public void AddComponent(string componentId)
|
||||
public void AddComponent(string componentId, Window? referenceWindow = null)
|
||||
{
|
||||
EnsureRegistries();
|
||||
if (_componentRuntimeRegistry is null || !_componentRuntimeRegistry.TryGetDescriptor(componentId, out var descriptor))
|
||||
@@ -118,23 +118,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
return;
|
||||
}
|
||||
|
||||
var placement = new FusedDesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = Guid.NewGuid().ToString("N"),
|
||||
ComponentId = componentId,
|
||||
Width = DefaultComponentWidth,
|
||||
Height = DefaultComponentHeight
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
var widthCells = Math.Max(1, descriptor.Definition.MinWidthCells);
|
||||
var heightCells = Math.Max(1, descriptor.Definition.MinHeightCells);
|
||||
var placement = CreateCenteredPlacement(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
componentId,
|
||||
widthCells,
|
||||
heightCells,
|
||||
referenceWindow);
|
||||
|
||||
_layoutService.AddComponentPlacement(placement);
|
||||
|
||||
@@ -160,7 +151,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
_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)
|
||||
@@ -249,8 +242,9 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
return null;
|
||||
}
|
||||
|
||||
var cellSize = ResolveCellSize(placement);
|
||||
var control = descriptor.CreateControl(
|
||||
DefaultCellSize,
|
||||
cellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
@@ -264,6 +258,140 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
|
||||
var window = new DesktopWidgetWindow(control, placement.PlacementId);
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ internal static class PlondsClientServiceFactory
|
||||
{
|
||||
private const string S3ManifestUrlEnvironmentVariable = "LANMOUNTAIN_PLONDS_S3_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";
|
||||
|
||||
public static IPlondsService CreateDefault(HttpClient? httpClient = null)
|
||||
|
||||
@@ -1091,7 +1091,18 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
}
|
||||
|
||||
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
|
||||
.FirstOrDefault(candidate => candidate.Manifest.RequiresCleanInstall);
|
||||
_pendingPlondsInstallerManifest = null;
|
||||
@@ -1099,11 +1110,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
TransitionPlonds(UpdatePhase.Checked);
|
||||
SaveLastChecked();
|
||||
|
||||
if (!latest.Success)
|
||||
{
|
||||
return new UpdateCheckReport(false, null, currentVersionText, null, null, null, null, null, null, latest.ErrorMessage);
|
||||
}
|
||||
|
||||
var payloadKind = latest.IsUpdateAvailable
|
||||
? _pendingPlondsCleanInstallCandidate is not null
|
||||
? UpdatePayloadKind.FullInstaller
|
||||
@@ -1368,7 +1374,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
|
||||
_plondsPhase = 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()
|
||||
|
||||
@@ -222,7 +222,7 @@ internal sealed class UpdateInstallGateway
|
||||
try
|
||||
{
|
||||
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
|
||||
{
|
||||
|
||||
@@ -39,7 +39,7 @@ internal sealed class UpdateStateStore
|
||||
PhaseChanged?.Invoke(newPhase);
|
||||
ProgressChanged?.Invoke(new UpdateProgressReport(
|
||||
newPhase,
|
||||
$"Phase changed to {newPhase}",
|
||||
string.Empty,
|
||||
0,
|
||||
null,
|
||||
null));
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -114,18 +115,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
public bool IsBusy => CurrentPhase.IsBusy();
|
||||
public bool IsPaused => CurrentPhase.IsPaused();
|
||||
public bool CanCheck => CurrentPhase.CanCheck();
|
||||
public bool CanDownload => CurrentPhase.CanDownload();
|
||||
public bool CanDownload => IsUpdateAvailable && CurrentPhase.CanDownload();
|
||||
public bool CanInstall => CurrentPhase.CanInstall();
|
||||
public bool CanRollback => CurrentPhase.CanRollback();
|
||||
public bool CanPause => CurrentPhase.CanPause();
|
||||
public bool CanResume => CurrentPhase.CanResume();
|
||||
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 IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused;
|
||||
public bool IsProgressSectionVisible => IsBusy || IsProgressVisible || IsPaused || HasVisibleAction;
|
||||
public string PhaseText => GetPhaseText(CurrentPhase);
|
||||
public string LatestVersionDisplayText => string.IsNullOrEmpty(LatestVersionText)
|
||||
? L("settings.update.latest_version_none", "Up to date")
|
||||
: LatestVersionText;
|
||||
private bool HasVisibleAction => CanDownload || CanInstall || CanRollback || CanPause || CanResume || CanCancel;
|
||||
|
||||
partial void OnCurrentPhaseChanged(UpdatePhase value)
|
||||
{
|
||||
@@ -150,6 +152,13 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
CancelCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnIsUpdateAvailableChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(IsProgressSectionVisible));
|
||||
DownloadCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnSelectedUpdateChannelValueChanged(string value)
|
||||
{
|
||||
SavePreferenceState();
|
||||
@@ -221,9 +230,21 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
PublishedAtText = report.PublishedAt?.ToLocalTime().ToString("g", CultureInfo.CurrentCulture) ?? string.Empty;
|
||||
UpdateTypeText = GetUpdateTypeText(report.PayloadKind);
|
||||
IsDeltaUpdate = report.PayloadKind is UpdatePayloadKind.DeltaPlonds;
|
||||
StatusMessage = report.LatestVersion is null
|
||||
var availableMessage = report.LatestVersion is null
|
||||
? 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
|
||||
{
|
||||
@@ -315,10 +336,15 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void OnUpdatePhaseChanged(UpdatePhase phase)
|
||||
{
|
||||
CurrentPhase = phase;
|
||||
RunOnUiThread(() => CurrentPhase = phase);
|
||||
}
|
||||
|
||||
private void OnUpdateProgressChanged(UpdateProgressReport report)
|
||||
{
|
||||
RunOnUiThread(() => ApplyUpdateProgress(report));
|
||||
}
|
||||
|
||||
private void ApplyUpdateProgress(UpdateProgressReport report)
|
||||
{
|
||||
ProgressFraction = report.ProgressFraction;
|
||||
|
||||
@@ -338,13 +364,37 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusMessage = string.IsNullOrWhiteSpace(report.Message)
|
||||
? GetPhaseStatusText(CurrentPhase)
|
||||
: report.Message;
|
||||
if (!string.IsNullOrWhiteSpace(report.Message))
|
||||
{
|
||||
StatusMessage = report.Message;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -565,19 +615,19 @@ public sealed partial class UpdateSettingsViewModel : ViewModelBase, IDisposable
|
||||
=> L("settings.update.status_ready", "Ready to check for updates.");
|
||||
|
||||
private string GetCheckingStatusText()
|
||||
=> L("settings.update.status_checking", "Checking GitHub releases...");
|
||||
=> L("settings.update.status_checking", "Checking update sources...");
|
||||
|
||||
private string GetUpToDateStatusText()
|
||||
=> L("settings.update.status_up_to_date", "You are already on the latest 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()
|
||||
=> L("settings.update.status_downloading", "Downloading installer...");
|
||||
|
||||
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()
|
||||
=> L("settings.update.status_download_failed", "Download failed.");
|
||||
|
||||
@@ -4,7 +4,10 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -12,6 +15,7 @@ public partial class DesktopWidgetWindow : Window
|
||||
{
|
||||
private readonly IWindowBottomMostService _bottomMostService = WindowBottomMostServiceFactory.GetOrCreate();
|
||||
private readonly IRegionPassthroughService _regionPassthroughService = RegionPassthroughServiceFactory.GetOrCreate();
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
|
||||
private bool _isEditMode;
|
||||
private bool _isDragging;
|
||||
@@ -174,8 +178,7 @@ public partial class DesktopWidgetWindow : Window
|
||||
p => string.Equals(p.PlacementId, PlacementId, StringComparison.OrdinalIgnoreCase));
|
||||
if (placement is not null)
|
||||
{
|
||||
placement.X = Position.X;
|
||||
placement.Y = Position.Y;
|
||||
ApplySnappedDragPlacement(placement);
|
||||
layoutService.Save(layout);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +186,70 @@ public partial class DesktopWidgetWindow : Window
|
||||
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)
|
||||
{
|
||||
var removeItem = new MenuItem
|
||||
|
||||
@@ -146,15 +146,22 @@
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
<Border x:Name="PreviewInteractionHost"
|
||||
Grid.Row="2"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
|
||||
Background="{DynamicResource AdaptiveSurfaceOverlayBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
BorderThickness="1"
|
||||
Width="390"
|
||||
Height="230"
|
||||
Focusable="True"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
VerticalAlignment="Center"
|
||||
PointerPressed="OnPreviewPointerPressed"
|
||||
PointerReleased="OnPreviewPointerReleased"
|
||||
PointerCaptureLost="OnPreviewPointerCaptureLost"
|
||||
PointerWheelChanged="OnPreviewPointerWheelChanged"
|
||||
KeyDown="OnPreviewKeyDown">
|
||||
<Border CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
using FluentIcons.Common;
|
||||
@@ -18,6 +19,8 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
{
|
||||
public event EventHandler<string>? AddComponentRequested;
|
||||
|
||||
private const double PreviewSwipeThreshold = 48d;
|
||||
|
||||
private static readonly LocalizationService LocalizationService = new();
|
||||
|
||||
private readonly ComponentLibraryWindowViewModel _viewModel = new();
|
||||
@@ -28,9 +31,13 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
|
||||
private List<DesktopComponentDefinition> _allDefinitions = new();
|
||||
private IReadOnlyList<DesktopComponentDefinition> _selectedCategoryDefinitions = [];
|
||||
private int _selectedComponentIndex;
|
||||
private ComponentRegistry? _componentRegistry;
|
||||
private DesktopComponentRuntimeRegistry? _componentRuntimeRegistry;
|
||||
private Control? _selectedPreviewControl;
|
||||
private bool _isPreviewSwipeActive;
|
||||
private Point _previewSwipeStartPoint;
|
||||
|
||||
public FusedDesktopComponentLibraryControl()
|
||||
{
|
||||
@@ -157,17 +164,114 @@ public partial class FusedDesktopComponentLibraryControl : UserControl
|
||||
.Where(definition => string.Equals(definition.Category, selectedCategory.Id, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(static definition => definition.DisplayName, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var firstComponent = filtered.FirstOrDefault();
|
||||
if (firstComponent is null)
|
||||
_selectedCategoryDefinitions = filtered.ToList();
|
||||
_selectedComponentIndex = 0;
|
||||
ApplySelectedComponentIndex();
|
||||
}
|
||||
|
||||
private void ApplySelectedComponentIndex()
|
||||
{
|
||||
if (_selectedCategoryDefinitions.Count == 0)
|
||||
{
|
||||
_viewModel.SelectedComponent = null;
|
||||
SetSelectedPreviewControl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel.SelectedComponent = selectedCategory.Components.FirstOrDefault(component => component.ComponentId == firstComponent.Id)
|
||||
?? CreateComponentItem(firstComponent, _settingsFacade.Region.Get().LanguageCode);
|
||||
SetSelectedPreviewControl(CreateStaticPreviewControl(firstComponent));
|
||||
_selectedComponentIndex = NormalizeComponentIndex(_selectedComponentIndex);
|
||||
var selectedDefinition = _selectedCategoryDefinitions[_selectedComponentIndex];
|
||||
_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)
|
||||
|
||||
@@ -65,9 +65,8 @@ public partial class FusedDesktopComponentLibraryWindow : Window
|
||||
|
||||
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.");
|
||||
Close();
|
||||
}
|
||||
|
||||
private void OnCloseClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
Reference in New Issue
Block a user