diff --git a/LanDesktopPLONDS.installer/App.axaml b/LanDesktopPLONDS.installer/App.axaml
index 0154504..681bf05 100644
--- a/LanDesktopPLONDS.installer/App.axaml
+++ b/LanDesktopPLONDS.installer/App.axaml
@@ -5,19 +5,65 @@
x:Class="LanDesktopPLONDS.Installer.App"
RequestedThemeVariant="Default">
- Inter, Segoe UI, Microsoft YaHei UI
- 2
- 4
- 6
- 8
- 10
- 12
- 12
-
-
-
-
-
+
+ Inter, Segoe UI, Microsoft YaHei UI
+ 2
+ 4
+ 4
+ 8
+ 8
+ 12
+ 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -29,9 +75,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs b/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
index 3dec9c6..8b6734e 100644
--- a/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
+++ b/LanDesktopPLONDS.installer/Services/FilesPackageInstaller.cs
@@ -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
{
diff --git a/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs b/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
index afe5d75..2e6533e 100644
--- a/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
+++ b/LanDesktopPLONDS.installer/Services/PlondsInstallerModels.cs
@@ -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(
diff --git a/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs b/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs
index c4f479f..d4f5b4a 100644
--- a/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs
+++ b/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs
@@ -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;
@@ -14,9 +15,43 @@ public sealed partial class InstallerStepViewModel(
[ObservableProperty]
private bool _isSelected;
+ [ObservableProperty]
+ private bool _isCompleted;
+
public InstallerStepId StepId { get; } = stepId;
public string Title { get; } = title;
- public string IconKey { get; } = iconKey;
+ public Icon Icon { get; } = icon;
+
+ public bool IsLocked => !IsUnlocked;
+
+ public Icon DisplayIcon => IsLocked
+ ? Icon.LockClosed
+ : IsCompleted
+ ? Icon.CheckmarkCircle
+ : Icon;
+
+ public bool IsAvailable => IsUnlocked && !IsSelected && !IsCompleted;
+
+ partial void OnIsUnlockedChanged(bool value)
+ {
+ _ = value;
+ OnPropertyChanged(nameof(IsLocked));
+ OnPropertyChanged(nameof(IsAvailable));
+ OnPropertyChanged(nameof(DisplayIcon));
+ }
+
+ partial void OnIsSelectedChanged(bool value)
+ {
+ _ = value;
+ OnPropertyChanged(nameof(IsAvailable));
+ }
+
+ partial void OnIsCompletedChanged(bool value)
+ {
+ _ = value;
+ OnPropertyChanged(nameof(DisplayIcon));
+ OnPropertyChanged(nameof(IsAvailable));
+ }
}
diff --git a/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
index 1b66f3d..cca0586 100644
--- a/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
+++ b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
@@ -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;
@@ -61,11 +62,16 @@ 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 bool _createStartupShortcut;
+
[ObservableProperty]
private InstallerStepViewModel? _selectedStep;
@@ -79,11 +85,11 @@ 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];
@@ -109,6 +115,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,12 +153,26 @@ 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 OnIsInstallingChanged(bool value)
+ {
+ _ = value;
+ OnPropertyChanged(nameof(CanGoBack));
+ OnPropertyChanged(nameof(CanGoNext));
+ OnPropertyChanged(nameof(CanStartInstall));
+ }
+
partial void OnSelectedStepChanged(InstallerStepViewModel? value)
{
if (_isNavigatingInternally || value is null)
@@ -158,7 +180,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
return;
}
- if (value.StepId <= MaxUnlockedStep)
+ if (!IsInstalling && value.StepId <= MaxUnlockedStep)
{
CurrentStep = value.StepId;
return;
@@ -198,6 +220,11 @@ public sealed partial class MainWindowViewModel : ObservableObject
[RelayCommand(CanExecute = nameof(CanGoBack))]
private void Back()
{
+ if (IsInstalling)
+ {
+ return;
+ }
+
if (CurrentStep > InstallerStepId.Welcome)
{
CurrentStep -= 1;
@@ -207,15 +234,23 @@ public sealed partial class MainWindowViewModel : ObservableObject
[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 = selected;
+ }
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"选择安装位置失败:{ex.Message}";
}
}
@@ -230,7 +265,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
try
{
var progress = new Progress(ApplyProgress);
- var options = new OnlineInstallOptions(CreateDesktopShortcut);
+ var options = new OnlineInstallOptions(CreateDesktopShortcut, CreateStartupShortcut);
await _installService.InstallFreshAsync(InstallPath, options, progress, _installCts.Token);
UnlockAndNavigate(InstallerStepId.Complete);
StatusText = "安装完成";
@@ -320,6 +355,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
{
step.IsUnlocked = step.StepId <= MaxUnlockedStep;
step.IsSelected = step.StepId == CurrentStep;
+ step.IsCompleted = step.StepId < CurrentStep;
if (step.StepId == CurrentStep && !ReferenceEquals(SelectedStep, step))
{
SelectedStep = step;
diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml b/LanDesktopPLONDS.installer/Views/MainWindow.axaml
index ee90e8e..f0ef70d 100644
--- a/LanDesktopPLONDS.installer/Views/MainWindow.axaml
+++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml
@@ -1,17 +1,16 @@
@@ -19,49 +18,114 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+ RowDefinitions="48,*"
+ Background="{DynamicResource InstallerWindowBackgroundBrush}">
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs b/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
index 542ae96..34bee2f 100644
--- a/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
+++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml.cs
@@ -24,9 +24,12 @@ public partial class MainWindow : Window
private async Task 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);
diff --git a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
index 493ee20..ccff0b6 100644
--- a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
+++ b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
@@ -67,6 +67,65 @@ public sealed class OnlineInstallerCoreTests : IDisposable
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_UsesSelectedLocalFolder()
+ {
+ var selectedPath = Path.Combine(_tempRoot, "selected-install-root");
+ var vm = new MainWindowViewModel(
+ new FakeInstallService(),
+ new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
+ {
+ BrowseRequested = _ => Task.FromResult(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()
{
@@ -248,6 +307,8 @@ public sealed class OnlineInstallerCoreTests : IDisposable
private sealed class FakeInstallService : IOnlineInstallService
{
+ public OnlineInstallOptions? LastOptions { get; private set; }
+
public Task CheckLatestAsync(CancellationToken cancellationToken)
=> Task.FromResult(new OnlineInstallPackageInfo("1.2.3", "test", new Uri("https://test/Files.zip"), 1));
@@ -259,7 +320,10 @@ public sealed class OnlineInstallerCoreTests : IDisposable
OnlineInstallOptions options,
IProgress? progress,
CancellationToken cancellationToken)
- => Task.CompletedTask;
+ {
+ LastOptions = options;
+ return Task.CompletedTask;
+ }
public Task RepairAsync(string installPath, IProgress? progress, CancellationToken cancellationToken)
=> throw new NotSupportedException();