diff --git a/.trae/specs/fused-desktop-library-redesign/spec.md b/.trae/specs/fused-desktop-library-redesign/spec.md
index 4e8e4c3..0c1ff69 100644
--- a/.trae/specs/fused-desktop-library-redesign/spec.md
+++ b/.trae/specs/fused-desktop-library-redesign/spec.md
@@ -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`.
diff --git a/.trae/specs/update-settings-fluent-controls/spec.md b/.trae/specs/update-settings-fluent-controls/spec.md
index d2b3442..d4bc45b 100644
--- a/.trae/specs/update-settings-fluent-controls/spec.md
+++ b/.trae/specs/update-settings-fluent-controls/spec.md
@@ -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`.
diff --git a/LanDesktopPLONDS.installer/App.axaml b/LanDesktopPLONDS.installer/App.axaml
index 681bf05..dea67f8 100644
--- a/LanDesktopPLONDS.installer/App.axaml
+++ b/LanDesktopPLONDS.installer/App.axaml
@@ -24,6 +24,7 @@
+
@@ -47,6 +48,7 @@
+
diff --git a/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs b/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
index 5763e48..ba2b7ed 100644
--- a/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
+++ b/LanDesktopPLONDS.installer/Services/InstallerPathGuard.cs
@@ -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)
diff --git a/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs b/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs
index d4f5b4a..7879976 100644
--- a/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs
+++ b/LanDesktopPLONDS.installer/ViewModels/InstallerStepViewModel.cs
@@ -15,43 +15,9 @@ public sealed partial class InstallerStepViewModel(
[ObservableProperty]
private bool _isSelected;
- [ObservableProperty]
- private bool _isCompleted;
-
public InstallerStepId StepId { get; } = stepId;
public string Title { get; } = title;
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 cca0586..345b817 100644
--- a/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
+++ b/LanDesktopPLONDS.installer/ViewModels/MainWindowViewModel.cs
@@ -15,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))]
@@ -72,9 +71,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private bool _createStartupShortcut;
- [ObservableProperty]
- private InstallerStepViewModel? _selectedStep;
-
public MainWindowViewModel(
IOnlineInstallService installService,
IPrivacyDeviceIdentityProvider privacyIdentity,
@@ -92,7 +88,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
new InstallerStepViewModel(InstallerStepId.Complete, "完成安装", Icon.Circle)
];
SyncSteps();
- SelectedStep = Steps[0];
DeviceIdPreview = _privacyIdentity.GetOrCreateDeviceId();
PrivacyConfirmed = _privacyConsentStore.HasConfirmed(DeviceIdPreview);
}
@@ -173,22 +168,6 @@ public sealed partial class MainWindowViewModel : ObservableObject
OnPropertyChanged(nameof(CanStartInstall));
}
- partial void OnSelectedStepChanged(InstallerStepViewModel? value)
- {
- if (_isNavigatingInternally || value is null)
- {
- return;
- }
-
- if (!IsInstalling && value.StepId <= MaxUnlockedStep)
- {
- CurrentStep = value.StepId;
- return;
- }
-
- SyncSteps();
- }
-
[RelayCommand(CanExecute = nameof(CanGoNext))]
private async Task NextAsync()
{
@@ -231,6 +210,17 @@ public sealed partial class MainWindowViewModel : ObservableObject
}
}
+ [RelayCommand]
+ private void SelectStep(InstallerStepViewModel? step)
+ {
+ if (step is null || IsInstalling || step.StepId > MaxUnlockedStep)
+ {
+ return;
+ }
+
+ CurrentStep = step.StepId;
+ }
+
[RelayCommand]
private async Task BrowseAsync()
{
@@ -245,7 +235,7 @@ public sealed partial class MainWindowViewModel : ObservableObject
var selected = await BrowseRequested(InstallPath);
if (!string.IsNullOrWhiteSpace(selected))
{
- InstallPath = selected;
+ InstallPath = InstallerPathGuard.GetInstallPathForSelectedFolder(selected);
}
}
catch (Exception ex)
@@ -348,23 +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;
- step.IsCompleted = 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;
}
}
diff --git a/LanDesktopPLONDS.installer/Views/MainWindow.axaml b/LanDesktopPLONDS.installer/Views/MainWindow.axaml
index f0ef70d..de4fcf3 100644
--- a/LanDesktopPLONDS.installer/Views/MainWindow.axaml
+++ b/LanDesktopPLONDS.installer/Views/MainWindow.axaml
@@ -9,8 +9,10 @@
MinWidth="900"
MinHeight="620"
CanResize="True"
+ x:Name="Root"
Title="{Binding WindowTitle}"
- Background="{DynamicResource InstallerWindowBackgroundBrush}"
+ Background="Transparent"
+ TransparencyLevelHint="Mica, AcrylicBlur, None"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="48"
WindowDecorations="None">
@@ -23,40 +25,54 @@
-
-
-
-
-
+
+
+
-
-
+
+
+ ColumnDefinitions="260,10,*"
+ Margin="10,0,10,10">
+ CornerRadius="{DynamicResource DesignCornerRadiusLg}"
+ Padding="22,24">
-
-
+
+
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
+
-
-
+
+
-
+
+
@@ -488,9 +487,7 @@
@@ -531,7 +528,9 @@
-
+
+
-
+
+
diff --git a/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs b/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs
index ce8f052..e28ebc4 100644
--- a/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs
+++ b/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs
@@ -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
diff --git a/LanMountainDesktop.Launcher/Models/OobeStateModels.cs b/LanMountainDesktop.Launcher/Models/OobeStateModels.cs
index 6f025a7..5653fac 100644
--- a/LanMountainDesktop.Launcher/Models/OobeStateModels.cs
+++ b/LanMountainDesktop.Launcher/Models/OobeStateModels.cs
@@ -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,
diff --git a/LanMountainDesktop.Launcher/Oobe/DataLocationOobeStep.cs b/LanMountainDesktop.Launcher/Oobe/DataLocationOobeStep.cs
deleted file mode 100644
index bab3d5f..0000000
--- a/LanMountainDesktop.Launcher/Oobe/DataLocationOobeStep.cs
+++ /dev/null
@@ -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();
- }
- });
- }
- }
-}
diff --git a/LanMountainDesktop.Launcher/Oobe/IOobeStep.cs b/LanMountainDesktop.Launcher/Oobe/IOobeStep.cs
index e66ee91..b084780 100644
--- a/LanMountainDesktop.Launcher/Oobe/IOobeStep.cs
+++ b/LanMountainDesktop.Launcher/Oobe/IOobeStep.cs
@@ -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 RunAsync(CancellationToken cancellationToken);
}
diff --git a/LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs b/LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs
new file mode 100644
index 0000000..86581ca
--- /dev/null
+++ b/LanMountainDesktop.Launcher/Oobe/OobeSessionCommitService.cs
@@ -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? _setWindowsStartup;
+
+ public OobeSessionCommitService(
+ DataLocationResolver dataLocationResolver,
+ OobeStateService oobeStateService,
+ CommandContext context,
+ Func? 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
+ };
+}
diff --git a/LanMountainDesktop.Launcher/Oobe/OobeStateService.cs b/LanMountainDesktop.Launcher/Oobe/OobeStateService.cs
index 079ef0e..dde5330 100644
--- a/LanMountainDesktop.Launcher/Oobe/OobeStateService.cs
+++ b/LanMountainDesktop.Launcher/Oobe/OobeStateService.cs
@@ -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 _legacyStatePaths;
+ private readonly IReadOnlyList _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 BuildLegacyPaths(string fileName)
+ {
+ var roots = new List();
+ 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");
+ }
}
diff --git a/LanMountainDesktop.Launcher/Oobe/WelcomeOobeStep.cs b/LanMountainDesktop.Launcher/Oobe/WelcomeOobeStep.cs
index 2b34b6d..1b8ab0d 100644
--- a/LanMountainDesktop.Launcher/Oobe/WelcomeOobeStep.cs
+++ b/LanMountainDesktop.Launcher/Oobe/WelcomeOobeStep.cs
@@ -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 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));
}
}
diff --git a/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs b/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
index efa2990..9a1dddb 100644
--- a/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
+++ b/LanMountainDesktop.Launcher/Shell/AirAppRuntimeBridge.cs
@@ -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++)
diff --git a/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs b/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs
index a6489a4..866b550 100644
--- a/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs
+++ b/LanMountainDesktop.Launcher/Shell/EntryHandlers/PreviewEntryHandler.cs
@@ -116,7 +116,7 @@ internal static class PreviewEntryHandler
{
try
{
- await window.WaitForEnterAsync().ConfigureAwait(false);
+ await window.WaitForCompletionAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
diff --git a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
index 746b5a3..e8f6200 100644
--- a/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
+++ b/LanMountainDesktop.Launcher/Shell/LauncherGuiCoordinator.cs
@@ -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);
}
diff --git a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs
index 755070d..889da79 100644
--- a/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs
+++ b/LanMountainDesktop.Launcher/Shell/LauncherOrchestrator.cs
@@ -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(
[
diff --git a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
index da11603..c302790 100644
--- a/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
+++ b/LanMountainDesktop.Launcher/Startup/HostLaunchService.cs
@@ -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 StartHostProcessAsync(
HostLaunchPlan plan,
HostStartMode startMode,
diff --git a/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs b/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs
index 2730d5c..f52a5b2 100644
--- a/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs
+++ b/LanMountainDesktop.Launcher/Startup/Phases/OobeGatePhase.cs
@@ -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);
diff --git a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
index 5de8daa..95db7cd 100644
--- a/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/DevDebugWindow.axaml.cs
@@ -126,7 +126,7 @@ public partial class DevDebugWindow : Window
try
{
// 等待用户点击开始按钮
- await oobeWindow.WaitForEnterAsync();
+ await oobeWindow.WaitForCompletionAsync();
// 用户点击后,窗口会自动关闭(通过OobeWindow内部的动画和关闭逻辑)
Console.WriteLine("[DevDebugWindow] OOBE completed by user");
diff --git a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
index 8ef9ea1..ed7ae09 100644
--- a/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
+++ b/LanMountainDesktop.Launcher/Views/OobeWindow.axaml.cs
@@ -17,10 +17,11 @@ public partial class OobeWindow : Window
private const int AnimationDurationMs = 300;
private const int TypingDelayMs = 100;
- private readonly TaskCompletionSource _completionSource = new();
+ private readonly TaskCompletionSource _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 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("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("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("PrivacyAgreementCheckBox")?.IsChecked ?? false,
+ PrivacyUserId = privacy.TelemetryId,
+ PrivacyDeviceId = GetDeviceIdentifier()
+ };
+ }
+
+ private PrivacyConfig BuildPrivacyConfig()
+ {
+ return new PrivacyConfig
+ {
+ CrashTelemetryEnabled = this.FindControl("CrashTelemetryToggle")?.IsChecked ?? true,
+ UsageTelemetryEnabled = this.FindControl("UsageTelemetryToggle")?.IsChecked ?? true,
+ TelemetryId = this.FindControl("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)
diff --git a/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs b/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
index 85d4866..5fb9e25 100644
--- a/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
+++ b/LanMountainDesktop.Tests/DesktopPlacementMathTests.cs
@@ -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);
+ }
}
diff --git a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
index ccff0b6..bb39fa3 100644
--- a/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
+++ b/LanMountainDesktop.Tests/OnlineInstallerCoreTests.cs
@@ -56,13 +56,17 @@ 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);
}
@@ -86,7 +90,7 @@ public sealed class OnlineInstallerCoreTests : IDisposable
}
[Fact]
- public async Task BrowseCommand_UsesSelectedLocalFolder()
+ public async Task BrowseCommand_UsesSelectedLocalFolderAsInstallParent()
{
var selectedPath = Path.Combine(_tempRoot, "selected-install-root");
var vm = new MainWindowViewModel(
@@ -98,6 +102,23 @@ public sealed class OnlineInstallerCoreTests : IDisposable
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(selectedPath)
+ };
+
+ await vm.BrowseCommand.ExecuteAsync(null);
+
Assert.Equal(selectedPath, vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}
diff --git a/LanMountainDesktop.Tests/OobeSessionCommitServiceTests.cs b/LanMountainDesktop.Tests/OobeSessionCommitServiceTests.cs
new file mode 100644
index 0000000..e4e7399
--- /dev/null
+++ b/LanMountainDesktop.Tests/OobeSessionCommitServiceTests.cs
@@ -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");
+}
diff --git a/LanMountainDesktop.Tests/OobeStateServiceTests.cs b/LanMountainDesktop.Tests/OobeStateServiceTests.cs
index 395b5be..c6921f9 100644
--- a/LanMountainDesktop.Tests/OobeStateServiceTests.cs
+++ b/LanMountainDesktop.Tests/OobeStateServiceTests.cs
@@ -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");
}
diff --git a/LanMountainDesktop.Tests/UpdateInstallGatewayTests.cs b/LanMountainDesktop.Tests/UpdateInstallGatewayTests.cs
new file mode 100644
index 0000000..2116319
--- /dev/null
+++ b/LanMountainDesktop.Tests/UpdateInstallGatewayTests.cs
@@ -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);
+ }
+}
\ No newline at end of file
diff --git a/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs b/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs
index ac49def..efba420 100644
--- a/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs
+++ b/LanMountainDesktop.Tests/UpdateSettingsInterfaceTests.cs
@@ -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()
{
diff --git a/LanMountainDesktop/DesktopEditing/FusedDesktopPlacementMath.cs b/LanMountainDesktop/DesktopEditing/FusedDesktopPlacementMath.cs
new file mode 100644
index 0000000..1a3de14
--- /dev/null
+++ b/LanMountainDesktop/DesktopEditing/FusedDesktopPlacementMath.cs
@@ -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));
+ }
+}
diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json
index 931cdd8..2bd1207 100644
--- a/LanMountainDesktop/Localization/en-US.json
+++ b/LanMountainDesktop/Localization/en-US.json
@@ -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.",
diff --git a/LanMountainDesktop/Localization/ja-JP.json b/LanMountainDesktop/Localization/ja-JP.json
index 2f87e16..903dcdd 100644
--- a/LanMountainDesktop/Localization/ja-JP.json
+++ b/LanMountainDesktop/Localization/ja-JP.json
@@ -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": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",
diff --git a/LanMountainDesktop/Localization/ko-KR.json b/LanMountainDesktop/Localization/ko-KR.json
index 12ec1b8..2efe5fc 100644
--- a/LanMountainDesktop/Localization/ko-KR.json
+++ b/LanMountainDesktop/Localization/ko-KR.json
@@ -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": "관리자 권한이 부여되지 않아 업데이트가 취소되었습니다.",
diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json
index ea0a31f..8aaf7ab 100644
--- a/LanMountainDesktop/Localization/zh-CN.json
+++ b/LanMountainDesktop/Localization/zh-CN.json
@@ -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": "未授予管理员权限,更新已取消。",
diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs
index 0dac720..d6d554c 100644
--- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs
+++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs
@@ -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
diff --git a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
index a4766e6..3d11ea5 100644
--- a/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
+++ b/LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
@@ -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()
diff --git a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
index d2269ea..bbe1fa6 100644
--- a/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
+++ b/LanMountainDesktop/Services/Update/UpdateInstallGateway.cs
@@ -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
{
diff --git a/LanMountainDesktop/Services/Update/UpdateStateStore.cs b/LanMountainDesktop/Services/Update/UpdateStateStore.cs
index 201a176..335544a 100644
--- a/LanMountainDesktop/Services/Update/UpdateStateStore.cs
+++ b/LanMountainDesktop/Services/Update/UpdateStateStore.cs
@@ -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));
diff --git a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
index 7982748..064522d 100644
--- a/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
+++ b/LanMountainDesktop/ViewModels/UpdateSettingsViewModel.cs
@@ -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.");
diff --git a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
index 4eaf451..fa29bc5 100644
--- a/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
+++ b/LanMountainDesktop/Views/DesktopWidgetWindow.axaml.cs
@@ -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
diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml
index 53bdce6..f75edf0 100644
--- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml
+++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryControl.axaml
@@ -146,15 +146,22 @@
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"/>
-
+ VerticalAlignment="Center"
+ PointerPressed="OnPreviewPointerPressed"
+ PointerReleased="OnPreviewPointerReleased"
+ PointerCaptureLost="OnPreviewPointerCaptureLost"
+ PointerWheelChanged="OnPreviewPointerWheelChanged"
+ KeyDown="OnPreviewKeyDown">
? 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 _allDefinitions = new();
+ private IReadOnlyList _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)
diff --git a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs
index 12bf473..8794e62 100644
--- a/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs
+++ b/LanMountainDesktop/Views/FusedDesktopComponentLibraryWindow.axaml.cs
@@ -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)