From b12dd68ba7b6b1c18585f1338205425ff69ff5b3 Mon Sep 17 00:00:00 2001 From: lincube Date: Tue, 14 Apr 2026 00:22:02 +0800 Subject: [PATCH] =?UTF-8?q?fix.=E5=BC=80=E5=8F=91=E8=80=85=E8=B0=83?= =?UTF-8?q?=E8=AF=95=E5=B7=A5=E5=85=B7=E8=AE=BE=E7=BD=AE=E6=97=A0=E6=B3=95?= =?UTF-8?q?=E6=AD=A3=E5=B8=B8=E6=8C=81=E4=B9=85=E5=8C=96=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=E3=80=82=E4=BF=AE=E5=A4=8D=E4=BA=86=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=BF=9B=E8=A1=8C=E6=9B=B4=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 +- ...MountainDesktop.PluginUpgradeHelper.csproj | 16 + .../Program.cs | 372 ++++++++++++++++++ .../Program.cs | 50 ++- LanMountainDesktop.slnx | 1 + LanMountainDesktop/App.axaml.cs | 39 +- .../Models/AppSettingsSnapshot.cs | 2 + .../Services/FusedDesktopManagerService.cs | 8 + .../HostApplicationLifecycleService.cs | 115 +++++- .../Services/PendingPluginUpgradeService.cs | 160 ++++++++ .../ViewModels/SettingsViewModels.cs | 81 ++-- .../Views/SettingsPages/DevSettingsPage.axaml | 20 + .../SettingsPages/GeneralSettingsPage.axaml | 7 - .../plugins/PluginMarketEmbeddedView.cs | 44 ++- .../plugins/PluginMarketInstallService.cs | 202 +++++++++- .../plugins/PluginMarketModels.cs | 3 +- .../plugins/PluginRuntimeService.cs | 40 +- 17 files changed, 1081 insertions(+), 95 deletions(-) create mode 100644 LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj create mode 100644 LanMountainDesktop.PluginUpgradeHelper/Program.cs create mode 100644 LanMountainDesktop/Services/PendingPluginUpgradeService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f108ec9..0391813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,10 @@ # 更新日志 / Changelog -## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12 +## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12 ### 新增 (Added) -- ✨ **开发者调试工具**: 新增开发者调试工具,优化插件开发体验 - - 提供便捷的调试功能,帮助开发者快速定位和解决问题 - - 支持插件运行时状态监控和日志查看 - - 提升插件开发效率和调试体验 +- 无 ### 变更 (Changed) @@ -15,12 +12,21 @@ - 插件开发者可以通过 View 自定义设置页面的 UI 和交互 - 提供更灵活的设置页面展示方式,提升插件用户体验 - 兼容原有的设置方式,平滑过渡 +- 🔧 **三指滑动与融合桌面功能开关位置调整**: 将三指滑动与融合桌面功能开关移动到了开发者设置界面 + - 优化设置页面结构,将高级功能集中管理 + - 普通用户界面更加简洁,开发者可在已有的开发者设置界面中访问相关设置 ### 修复 (Fixed) - 🐛 **快捷方式组件透明问题**: 修复了快捷方式组件无法正常透明的问题 - 问题原因: 组件背景透明属性设置异常或渲染层级问题 - 修复方案: 修正透明属性配置,确保快捷方式组件背景透明效果正常显示 +- 🐛 **插件无法正常升级问题**: 修复了插件无法正常升级的问题 + - 问题原因: 插件升级流程中存在异常,导致升级操作失败或中断 + - 修复方案: 修复插件升级逻辑,确保插件可以正常检测、下载和安装更新 +- 🐛 **开发者设置项持久化问题**: 修复了开发者设置项不能正确持久化的问题 + - 问题原因: 开发者设置项的保存或读取逻辑存在缺陷,导致设置无法正确保存或恢复 + - 修复方案: 修复设置持久化逻辑,确保开发者设置项能够正确保存并在重启后恢复 ### 移除 (Removed) diff --git a/LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj b/LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj new file mode 100644 index 0000000..0c305b4 --- /dev/null +++ b/LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj @@ -0,0 +1,16 @@ + + + + Exe + net10.0 + disable + enable + LanMountainDesktop.PluginUpgradeHelper + LanMountainDesktop.PluginUpgradeHelper + + + + + + + diff --git a/LanMountainDesktop.PluginUpgradeHelper/Program.cs b/LanMountainDesktop.PluginUpgradeHelper/Program.cs new file mode 100644 index 0000000..6ded3f9 --- /dev/null +++ b/LanMountainDesktop.PluginUpgradeHelper/Program.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using LanMountainDesktop.PluginSdk; + +namespace LanMountainDesktop.PluginUpgradeHelper; + +internal static class Program +{ + private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json"; + private const string LogFileName = "plugin-upgrade-helper.log"; + + private static int Main(string[] args) + { + var logPath = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", LogFileName); + Directory.CreateDirectory(Path.GetDirectoryName(logPath)!); + File.AppendAllText(logPath, $"\n[{DateTime.Now:O}] PluginUpgradeHelper started. Args: {string.Join(" ", args)}\n"); + + try + { + var parsedArgs = ParseArgs(args); + + if (!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) || + string.IsNullOrWhiteSpace(pluginsDirectory)) + { + LogError(logPath, "Missing required argument: --plugins-dir"); + return 1; + } + + if (!parsedArgs.TryGetValue("parent-pid", out var parentPidStr) || + !int.TryParse(parentPidStr, out var parentPid)) + { + LogError(logPath, "Missing or invalid argument: --parent-pid"); + return 1; + } + + parsedArgs.TryGetValue("launch", out var launchCommand); + + LogInfo(logPath, $"Waiting for parent process {parentPid} to exit..."); + WaitForParentProcess(parentPid); + + LogInfo(logPath, $"Processing pending upgrades in '{pluginsDirectory}'..."); + var upgradeResults = ProcessPendingUpgrades(pluginsDirectory, logPath); + + LogInfo(logPath, $"Upgrades completed. Success: {upgradeResults.SuccessCount}, Failed: {upgradeResults.FailureCount}"); + + if (!string.IsNullOrWhiteSpace(launchCommand)) + { + LogInfo(logPath, $"Launching application: {launchCommand}"); + LaunchApplication(launchCommand, parsedArgs); + } + + return upgradeResults.FailureCount > 0 ? 2 : 0; + } + catch (Exception ex) + { + LogError(logPath, $"Unexpected error: {ex}"); + return 1; + } + } + + private static void WaitForParentProcess(int parentPid) + { + try + { + var parentProcess = Process.GetProcessById(parentPid); + parentProcess.WaitForExit(TimeSpan.FromSeconds(30)); + } + catch (ArgumentException) + { + // Process already exited + } + catch (Exception) + { + // Ignore errors, continue anyway + } + + Thread.Sleep(500); + } + + private static UpgradeResults ProcessPendingUpgrades(string pluginsDirectory, string logPath) + { + var pendingUpgradesPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName); + var successCount = 0; + var failureCount = 0; + + if (!File.Exists(pendingUpgradesPath)) + { + LogInfo(logPath, "No pending upgrades found."); + return new UpgradeResults(0, 0); + } + + List? pendingUpgrades; + try + { + var json = File.ReadAllText(pendingUpgradesPath); + pendingUpgrades = JsonSerializer.Deserialize>(json); + } + catch (Exception ex) + { + LogError(logPath, $"Failed to read pending upgrades: {ex.Message}"); + return new UpgradeResults(0, 0); + } + + if (pendingUpgrades is null || pendingUpgrades.Count == 0) + { + LogInfo(logPath, "No pending upgrades to process."); + return new UpgradeResults(0, 0); + } + + Directory.CreateDirectory(pluginsDirectory); + var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions"); + Directory.CreateDirectory(pendingDeletionDir); + + foreach (var upgrade in pendingUpgrades) + { + if (!upgrade.IsValid()) + { + LogWarn(logPath, $"Skipping invalid upgrade entry for plugin '{upgrade.PluginId}'."); + failureCount++; + continue; + } + + try + { + LogInfo(logPath, $"Processing upgrade for plugin '{upgrade.PluginId}' to version '{upgrade.TargetVersion}'..."); + + var manifest = ReadManifestFromPackage(upgrade.SourcePackagePath); + var destinationPath = Path.Combine(pluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); + + RemoveExistingPluginPackages(pluginsDirectory, manifest.Id, destinationPath, pendingDeletionDir, logPath); + + File.Copy(upgrade.SourcePackagePath, destinationPath, overwrite: true); + + LogInfo(logPath, $"Successfully upgraded plugin '{upgrade.PluginId}' to '{upgrade.TargetVersion}'."); + successCount++; + } + catch (Exception ex) + { + LogError(logPath, $"Failed to upgrade plugin '{upgrade.PluginId}': {ex.Message}"); + failureCount++; + } + } + + try + { + File.Delete(pendingUpgradesPath); + } + catch (Exception ex) + { + LogWarn(logPath, $"Failed to delete pending upgrades file: {ex.Message}"); + } + + CleanupPendingDeletions(pendingDeletionDir, logPath); + + return new UpgradeResults(successCount, failureCount); + } + + private static void RemoveExistingPluginPackages( + string pluginsDirectory, + string pluginId, + string destinationPath, + string pendingDeletionDir, + string logPath) + { + var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), ".runtime")); + + foreach (var existingPackagePath in Directory + .EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.AllDirectories) + .Select(Path.GetFullPath) + .Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase))) + { + try + { + if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var existingManifest = ReadManifestFromPackage(existingPackagePath); + if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + TryDeleteOrMoveFile(existingPackagePath, pendingDeletionDir, logPath); + } + catch + { + // Ignore unrelated or malformed packages + } + } + } + + private static void TryDeleteOrMoveFile(string filePath, string pendingDeletionDir, string logPath) + { + try + { + File.Delete(filePath); + LogInfo(logPath, $"Deleted existing package: {filePath}"); + } + catch (IOException) + { + var fileName = Path.GetFileName(filePath); + var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending"); + try + { + File.Move(filePath, pendingPath); + LogInfo(logPath, $"Moved existing package to pending deletion: {filePath} -> {pendingPath}"); + } + catch (Exception ex) + { + LogWarn(logPath, $"Failed to move existing package '{filePath}': {ex.Message}"); + } + } + } + + private static void CleanupPendingDeletions(string pendingDeletionDir, string logPath) + { + if (!Directory.Exists(pendingDeletionDir)) + { + return; + } + + foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending")) + { + try + { + File.Delete(pendingFile); + } + catch (Exception ex) + { + LogWarn(logPath, $"Failed to delete pending file '{pendingFile}': {ex.Message}"); + } + } + + try + { + if (Directory.GetFiles(pendingDeletionDir).Length == 0 && + Directory.GetDirectories(pendingDeletionDir).Length == 0) + { + Directory.Delete(pendingDeletionDir); + } + } + catch + { + // Ignore + } + } + + private static void LaunchApplication(string launchCommand, Dictionary args) + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = launchCommand, + UseShellExecute = true, + WorkingDirectory = args.TryGetValue("working-dir", out var workingDir) + ? workingDir + : AppContext.BaseDirectory + }; + + if (args.TryGetValue("launch-args", out var launchArgs) && !string.IsNullOrWhiteSpace(launchArgs)) + { + startInfo.Arguments = launchArgs; + } + + Process.Start(startInfo); + } + catch (Exception ex) + { + Debug.WriteLine($"[PluginUpgradeHelper] Failed to launch application: {ex}"); + } + } + + private static PluginManifest ReadManifestFromPackage(string packagePath) + { + using var archive = ZipFile.OpenRead(packagePath); + var entries = archive.Entries + .Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (entries.Length == 0) + { + throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'."); + } + + if (entries.Length > 1) + { + throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files."); + } + + using var stream = entries[0].Open(); + return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); + } + + private static string BuildInstalledPackageFileName(string pluginId) + { + var invalidChars = Path.GetInvalidFileNameChars(); + var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + return fileName + ".laapp"; + } + + private static string EnsureTrailingSeparator(string path) + { + return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) + ? path + : path + Path.DirectorySeparatorChar; + } + + private static Dictionary ParseArgs(string[] args) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length; i++) + { + var current = args[i]; + if (!current.StartsWith("--", StringComparison.Ordinal)) + { + continue; + } + + var key = current[2..]; + if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length) + { + continue; + } + + values[key] = args[++i]; + } + + return values; + } + + private static void LogInfo(string logPath, string message) + { + File.AppendAllText(logPath, $"[{DateTime.Now:O}] [INFO] {message}\n"); + } + + private static void LogWarn(string logPath, string message) + { + File.AppendAllText(logPath, $"[{DateTime.Now:O}] [WARN] {message}\n"); + } + + private static void LogError(string logPath, string message) + { + File.AppendAllText(logPath, $"[{DateTime.Now:O}] [ERROR] {message}\n"); + } + + private sealed record PendingUpgrade( + string PluginId, + string SourcePackagePath, + string TargetVersion, + DateTimeOffset CreatedAt) + { + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(PluginId) && + !string.IsNullOrWhiteSpace(SourcePackagePath) && + !string.IsNullOrWhiteSpace(TargetVersion) && + File.Exists(SourcePackagePath); + } + } + + private sealed record UpgradeResults(int SuccessCount, int FailureCount); +} diff --git a/LanMountainDesktop.PluginsInstallHelper/Program.cs b/LanMountainDesktop.PluginsInstallHelper/Program.cs index 3f04ac8..a51ca83 100644 --- a/LanMountainDesktop.PluginsInstallHelper/Program.cs +++ b/LanMountainDesktop.PluginsInstallHelper/Program.cs @@ -135,6 +135,9 @@ internal static class Program private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath) { var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName)); + var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions"); + Directory.CreateDirectory(pendingDeletionDir); + foreach (var existingPackagePath in Directory .EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories) .Select(Path.GetFullPath) @@ -154,13 +157,58 @@ internal static class Program continue; } - DeleteFileWithRetry(existingPackagePath); + TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir); } catch { // Ignore unrelated or malformed packages while replacing an install target. } } + + CleanupPendingDeletions(pendingDeletionDir); + } + + private static void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir) + { + try + { + DeleteFileWithRetry(existingPackagePath); + } + catch (IOException) + { + var fileName = Path.GetFileName(existingPackagePath); + var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending"); + try + { + File.Move(existingPackagePath, pendingPath); + } + catch (IOException moveEx) + { + throw new IOException( + $"Cannot delete or move existing plugin package '{existingPackagePath}'. " + + $"The file may be in use by another process. Error: {moveEx.Message}", moveEx); + } + } + } + + private static void CleanupPendingDeletions(string pendingDeletionDir) + { + if (!Directory.Exists(pendingDeletionDir)) + { + return; + } + + foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending")) + { + try + { + File.Delete(pendingFile); + } + catch + { + // Ignore cleanup failures for pending deletions. + } + } } private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite) diff --git a/LanMountainDesktop.slnx b/LanMountainDesktop.slnx index acef7e8..e2c3009 100644 --- a/LanMountainDesktop.slnx +++ b/LanMountainDesktop.slnx @@ -8,6 +8,7 @@ + diff --git a/LanMountainDesktop/App.axaml.cs b/LanMountainDesktop/App.axaml.cs index c09f0b8..92abfc4 100644 --- a/LanMountainDesktop/App.axaml.cs +++ b/LanMountainDesktop/App.axaml.cs @@ -404,10 +404,7 @@ public partial class App : Application _traySettingsMenuItem.Header = L("tray.menu.settings", "Settings"); } - if (_trayComponentLibraryMenuItem is not null) - { - _trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library"); - } + RefreshFusedDesktopMenuItemVisibility(); if (_trayRestartMenuItem is not null) { @@ -420,6 +417,30 @@ public partial class App : Application } } + private void RefreshFusedDesktopMenuItemVisibility() + { + if (_trayComponentLibraryMenuItem is null) + { + return; + } + + // 仅在 Windows 上支持融合桌面功能 + if (!OperatingSystem.IsWindows()) + { + _trayComponentLibraryMenuItem.IsVisible = false; + return; + } + + // 检查融合桌面功能是否启用 + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + _trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop; + + if (_trayComponentLibraryMenuItem.IsVisible) + { + _trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library"); + } + } + private void DisposeTrayIcon() { if (_trayIcon is null) @@ -687,6 +708,16 @@ public partial class App : Application ApplyCurrentCultureFromSettings(); RefreshTrayIconContent(); } + + // 检查融合桌面设置是否变更 + var fusedDesktopChanged = + refreshAll || + changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase); + + if (fusedDesktopChanged) + { + RefreshFusedDesktopMenuItemVisibility(); + } }, DispatcherPriority.Background); } diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index 7fb4155..f1d61c4 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -152,6 +152,8 @@ public sealed class AppSettingsSnapshot public bool EnableThreeFingerSwipe { get; set; } = false; + public bool EnableFusedDesktop { get; set; } = false; + public List DisabledPluginIds { get; set; } = []; public bool IsDevModeEnabled { get; set; } diff --git a/LanMountainDesktop/Services/FusedDesktopManagerService.cs b/LanMountainDesktop/Services/FusedDesktopManagerService.cs index e15404c..3810525 100644 --- a/LanMountainDesktop/Services/FusedDesktopManagerService.cs +++ b/LanMountainDesktop/Services/FusedDesktopManagerService.cs @@ -58,6 +58,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService { if (!OperatingSystem.IsWindows()) return; + // 检查融合桌面功能是否启用 + var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + if (!appSnapshot.EnableFusedDesktop) + { + AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization."); + return; + } + EnsureRegistries(); ReloadWidgets(); } diff --git a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs index 5af6817..81bfa25 100644 --- a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs +++ b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.IO; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Threading; @@ -9,6 +10,8 @@ namespace LanMountainDesktop.Services; public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle { + private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe"; + public bool TryExit(HostApplicationLifecycleRequest? request = null) { App? app = null; @@ -50,28 +53,14 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle App? app = null; try { - var startInfo = AppRestartService.CreateRestartStartInfo(); - if (startInfo is null) + app = Application.Current as App; + + if (HasPendingPluginUpgrades()) { - AppLogger.Warn( - "HostLifecycle", - $"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'."); - return false; + return TryRestartWithUpgradeHelper(request); } - Process.Start(startInfo); - app = Application.Current as App; - app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown"); - var exitRequest = request is null - ? new HostApplicationLifecycleRequest(Reason: "Restart accepted.") - : request with - { - Reason = string.IsNullOrWhiteSpace(request.Reason) - ? "Restart accepted." - : request.Reason - }; - - return TryExit(exitRequest); + return TryRestartDirectly(request); } catch (Exception ex) { @@ -80,4 +69,92 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle return false; } } + + private static bool HasPendingPluginUpgrades() + { + try + { + var pluginsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + "Extensions", + "Plugins"); + var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json"); + return File.Exists(pendingUpgradesPath); + } + catch + { + return false; + } + } + + private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request) + { + AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart."); + + var helperPath = ResolveUpgradeHelperPath(); + if (!File.Exists(helperPath)) + { + AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart."); + return TryRestartDirectly(request); + } + + var pluginsDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "LanMountainDesktop", + "Extensions", + "Plugins"); + + var startInfo = AppRestartService.CreateRestartStartInfo(); + var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory; + var launchArgs = startInfo?.Arguments ?? ""; + + var helperStartInfo = new ProcessStartInfo + { + FileName = helperPath, + Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"", + UseShellExecute = true, + WorkingDirectory = AppContext.BaseDirectory + }; + + AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}"); + + Process.Start(helperStartInfo); + + var app = Application.Current as App; + app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown"); + + return TryExit(request); + } + + private bool TryRestartDirectly(HostApplicationLifecycleRequest? request) + { + var startInfo = AppRestartService.CreateRestartStartInfo(); + if (startInfo is null) + { + AppLogger.Warn( + "HostLifecycle", + $"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'."); + return false; + } + + Process.Start(startInfo); + var app = Application.Current as App; + app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown"); + var exitRequest = request is null + ? new HostApplicationLifecycleRequest(Reason: "Restart accepted.") + : request with + { + Reason = string.IsNullOrWhiteSpace(request.Reason) + ? "Restart accepted." + : request.Reason + }; + + return TryExit(exitRequest); + } + + private static string ResolveUpgradeHelperPath() + { + return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName); + } } diff --git a/LanMountainDesktop/Services/PendingPluginUpgradeService.cs b/LanMountainDesktop/Services/PendingPluginUpgradeService.cs new file mode 100644 index 0000000..f28f627 --- /dev/null +++ b/LanMountainDesktop/Services/PendingPluginUpgradeService.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; + +namespace LanMountainDesktop.Services; + +public sealed class PendingPluginUpgradeService +{ + private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json"; + private static readonly Lock Gate = new(); + private readonly string _pendingUpgradesFilePath; + + public PendingPluginUpgradeService(string pluginsDirectory) + { + _pendingUpgradesFilePath = Path.Combine(pluginsDirectory, PendingUpgradesFileName); + } + + public IReadOnlyList GetPendingUpgrades() + { + lock (Gate) + { + return ReadPendingUpgradesCore(); + } + } + + public void AddPendingUpgrade(string pluginId, string sourcePackagePath, string targetVersion) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath); + ArgumentException.ThrowIfNullOrWhiteSpace(targetVersion); + + lock (Gate) + { + var upgrades = ReadPendingUpgradesCore().ToList(); + + upgrades.RemoveAll(u => + string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase)); + + upgrades.Add(new PendingPluginUpgrade( + pluginId, + Path.GetFullPath(sourcePackagePath), + targetVersion, + DateTimeOffset.UtcNow)); + + SavePendingUpgradesCore(upgrades); + + AppLogger.Info( + "PendingPluginUpgrade", + $"Added pending upgrade. PluginId='{pluginId}'; TargetVersion='{targetVersion}'; SourcePath='{sourcePackagePath}'."); + } + } + + public void RemovePendingUpgrade(string pluginId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); + + lock (Gate) + { + var upgrades = ReadPendingUpgradesCore().ToList(); + var removed = upgrades.RemoveAll(u => + string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase)); + + if (removed > 0) + { + SavePendingUpgradesCore(upgrades); + AppLogger.Info( + "PendingPluginUpgrade", + $"Removed pending upgrade. PluginId='{pluginId}'."); + } + } + } + + public void ClearPendingUpgrades() + { + lock (Gate) + { + if (File.Exists(_pendingUpgradesFilePath)) + { + File.Delete(_pendingUpgradesFilePath); + AppLogger.Info("PendingPluginUpgrade", "Cleared all pending upgrades."); + } + } + } + + public bool HasPendingUpgrades() + { + lock (Gate) + { + return ReadPendingUpgradesCore().Count > 0; + } + } + + private List ReadPendingUpgradesCore() + { + if (!File.Exists(_pendingUpgradesFilePath)) + { + return []; + } + + try + { + var json = File.ReadAllText(_pendingUpgradesFilePath); + var upgrades = JsonSerializer.Deserialize>(json); + return upgrades?.Where(u => u.IsValid()).ToList() ?? []; + } + catch (Exception ex) + { + AppLogger.Warn( + "PendingPluginUpgrade", + $"Failed to read pending upgrades from '{_pendingUpgradesFilePath}'.", + ex); + return []; + } + } + + private void SavePendingUpgradesCore(List upgrades) + { + try + { + var directory = Path.GetDirectoryName(_pendingUpgradesFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(upgrades, new JsonSerializerOptions + { + WriteIndented = true + }); + + File.WriteAllText(_pendingUpgradesFilePath, json); + } + catch (Exception ex) + { + AppLogger.Error( + "PendingPluginUpgrade", + $"Failed to save pending upgrades to '{_pendingUpgradesFilePath}'.", + ex); + throw; + } + } +} + +public sealed record PendingPluginUpgrade( + string PluginId, + string SourcePackagePath, + string TargetVersion, + DateTimeOffset CreatedAt) +{ + public bool IsValid() + { + return !string.IsNullOrWhiteSpace(PluginId) && + !string.IsNullOrWhiteSpace(SourcePackagePath) && + !string.IsNullOrWhiteSpace(TargetVersion) && + File.Exists(SourcePackagePath); + } +} diff --git a/LanMountainDesktop/ViewModels/SettingsViewModels.cs b/LanMountainDesktop/ViewModels/SettingsViewModels.cs index 5a46eb9..b006eab 100644 --- a/LanMountainDesktop/ViewModels/SettingsViewModels.cs +++ b/LanMountainDesktop/ViewModels/SettingsViewModels.cs @@ -174,9 +174,6 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo private bool _isInitializing; private bool _disposed; - [ObservableProperty] - private bool _enableThreeFingerSwipe; - public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade) { _settingsFacade = settingsFacade; @@ -204,7 +201,6 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo SelectedRenderMode = RenderModes.FirstOrDefault(option => string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase)) ?? RenderModes[0]; - EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe; _isInitializing = false; RefreshPreview(); @@ -236,33 +232,6 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo { return; } - - // 如果是其他设置变更,重新加载我们的设置 - _isInitializing = true; - try - { - var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe; - } - finally - { - _isInitializing = false; - } - } - - partial void OnEnableThreeFingerSwipeChanged(bool value) - { - if (_isInitializing) - { - return; - } - - var appSnapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); - appSnapshot.EnableThreeFingerSwipe = value; - _settingsFacade.Settings.SaveSnapshot( - SettingsScope.App, - appSnapshot, - changedKeys: [nameof(AppSettingsSnapshot.EnableThreeFingerSwipe)]); } public event Action? RestartRequested; @@ -3100,6 +3069,9 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase _isInitializing = true; LoadSettings(); _isInitializing = false; + + // 监听设置变更,防止被意外重置 + _settingsFacade.Settings.Changed += OnSettingsChanged; } [ObservableProperty] @@ -3108,6 +3080,12 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase [ObservableProperty] private string _devPluginPath = string.Empty; + [ObservableProperty] + private bool _enableThreeFingerSwipe; + + [ObservableProperty] + private bool _enableFusedDesktop; + partial void OnIsDevModeEnabledChanged(bool value) { if (_isInitializing) return; @@ -3120,11 +3098,52 @@ public sealed partial class DevSettingsPageViewModel : ViewModelBase SaveField(nameof(AppSettingsSnapshot.DevPluginPath), value); } + partial void OnEnableThreeFingerSwipeChanged(bool value) + { + if (_isInitializing) return; + SaveField(nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), value); + } + + partial void OnEnableFusedDesktopChanged(bool value) + { + if (_isInitializing) return; + SaveField(nameof(AppSettingsSnapshot.EnableFusedDesktop), value); + } + private void LoadSettings() { var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); IsDevModeEnabled = snapshot.IsDevModeEnabled; DevPluginPath = snapshot.DevPluginPath ?? string.Empty; + EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe; + EnableFusedDesktop = snapshot.EnableFusedDesktop; + } + + private void OnSettingsChanged(object? sender, SettingsChangedEvent e) + { + if (e.Scope != SettingsScope.App) + { + return; + } + + var changedKeys = e.ChangedKeys?.ToArray(); + if (changedKeys is null || changedKeys.Length == 0) + { + return; + } + + // 如果是其他设置变更,重新加载我们的设置 + _isInitializing = true; + try + { + var snapshot = _settingsFacade.Settings.LoadSnapshot(SettingsScope.App); + EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe; + EnableFusedDesktop = snapshot.EnableFusedDesktop; + } + finally + { + _isInitializing = false; + } } private void SaveField(string key, T value) diff --git a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml index 65ba67c..343a26e 100644 --- a/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml @@ -25,6 +25,26 @@ + + + + + + + + + + + + + + + + + + - - - - - - diff --git a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs index e49b7c6..0e66123 100644 --- a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs +++ b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs @@ -784,12 +784,28 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable } RefreshInstalledSnapshot(); - SetStatus( - F( - "market.status.install_success_format", - "Plugin '{0}' has been staged. Restart the app to apply it.", - result.Manifest.Name), - SuccessBrush); + + if (result.RestartRequired) + { + SetStatus( + F( + "market.status.upgrade_staged_format", + "Plugin '{0}' v{1} has been downloaded. Restart to complete the upgrade.", + result.Manifest.Name, + result.Manifest.Version), + WarningBrush); + PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); + } + else + { + SetStatus( + F( + "market.status.install_success_format", + "Plugin '{0}' has been installed successfully.", + result.Manifest.Name), + SuccessBrush); + } + RebuildSurface(); } catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested) @@ -1015,14 +1031,22 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable private static int CompareVersions(string? left, string? right) { - if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion)) + var leftParsed = AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion); + var rightParsed = AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion); + + if (!leftParsed && !rightParsed) { - leftVersion = new Version(0, 0, 0); + return 0; } - if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion)) + if (!leftParsed) { - rightVersion = new Version(0, 0, 0); + return -1; + } + + if (!rightParsed) + { + return 1; } return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0)); diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index a21ac9d..a54ab79 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using System.Security.Cryptography; @@ -20,7 +21,9 @@ internal sealed class AirAppMarketInstallService : IDisposable private readonly HttpClient _httpClient; private readonly ResumableDownloadService _downloadService; private readonly AirAppMarketReleaseResolverService _releaseResolverService; + private readonly PendingPluginUpgradeService _pendingUpgradeService; private readonly string _downloadsDirectory; + private readonly Version? _hostVersion; public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory) { @@ -33,6 +36,8 @@ internal sealed class AirAppMarketInstallService : IDisposable _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0"); _downloadService = new ResumableDownloadService(_httpClient); _releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient); + _pendingUpgradeService = new PendingPluginUpgradeService(runtime.PluginsDirectory); + _hostVersion = typeof(App).Assembly.GetName().Version; } public async Task InstallAsync( @@ -41,18 +46,6 @@ internal sealed class AirAppMarketInstallService : IDisposable { ArgumentNullException.ThrowIfNull(plugin); - if (OperatingSystem.IsWindows()) - { - var helperPath = ResolveHelperPath(); - if (!File.Exists(helperPath)) - { - return new AirAppMarketInstallResult( - false, - null, - $"Plugins install helper was not found at '{helperPath}'."); - } - } - Directory.CreateDirectory(_downloadsDirectory); var sources = plugin.GetPackageSourcesInInstallOrder(); if (sources.Count == 0) @@ -67,6 +60,39 @@ internal sealed class AirAppMarketInstallService : IDisposable "PluginMarket", $"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'."); + var compatibilityError = ValidateCompatibility(plugin); + if (!string.IsNullOrWhiteSpace(compatibilityError)) + { + AppLogger.Warn("PluginMarket", $"Compatibility check failed. PluginId='{plugin.Id}'; Error='{compatibilityError}'."); + return new AirAppMarketInstallResult(false, null, compatibilityError); + } + + var isUpgrade = IsPluginInstalled(plugin.Id); + if (isUpgrade) + { + return await InstallUpgradeAsync(plugin, sources, cancellationToken).ConfigureAwait(false); + } + + return await InstallNewAsync(plugin, sources, cancellationToken).ConfigureAwait(false); + } + + private async Task InstallNewAsync( + AirAppMarketPluginEntry plugin, + IReadOnlyList sources, + CancellationToken cancellationToken) + { + if (OperatingSystem.IsWindows()) + { + var helperPath = ResolveHelperPath(); + if (!File.Exists(helperPath)) + { + return new AirAppMarketInstallResult( + false, + null, + $"Plugins install helper was not found at '{helperPath}'."); + } + } + var sourceErrors = new List(); foreach (var source in sources) { @@ -93,6 +119,88 @@ internal sealed class AirAppMarketInstallService : IDisposable return new AirAppMarketInstallResult(false, null, combinedMessage); } + private async Task InstallUpgradeAsync( + AirAppMarketPluginEntry plugin, + IReadOnlyList sources, + CancellationToken cancellationToken) + { + AppLogger.Info("PluginMarket", $"Detected upgrade scenario. Downloading package for deferred upgrade. PluginId='{plugin.Id}'."); + + foreach (var source in sources) + { + var downloadResult = await DownloadPackageAsync(plugin, source, cancellationToken).ConfigureAwait(false); + if (downloadResult.Success && !string.IsNullOrWhiteSpace(downloadResult.PackagePath)) + { + _pendingUpgradeService.AddPendingUpgrade(plugin.Id, downloadResult.PackagePath, plugin.Version); + + AppLogger.Info( + "PluginMarket", + $"Upgrade staged for next restart. PluginId='{plugin.Id}'; Version='{plugin.Version}'; PackagePath='{downloadResult.PackagePath}'."); + + var manifest = ReadManifestFromPackage(downloadResult.PackagePath); + return new AirAppMarketInstallResult(true, manifest, null, RestartRequired: true); + } + } + + return new AirAppMarketInstallResult( + false, + null, + $"Failed to download upgrade package for plugin '{plugin.Id}' from all available sources."); + } + + private bool IsPluginInstalled(string pluginId) + { + return _runtime.Catalog.Any(entry => + string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase)); + } + + private string? ValidateCompatibility(AirAppMarketPluginEntry plugin) + { + if (_hostVersion is null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(plugin.MinHostVersion)) + { + if (!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) || + minHostVersion is null) + { + return $"Plugin '{plugin.Id}' declares invalid minimum host version '{plugin.MinHostVersion}'."; + } + + if (_hostVersion < minHostVersion) + { + return $"Plugin '{plugin.Id}' requires host version {plugin.MinHostVersion} or newer. Current host version is {_hostVersion}."; + } + } + + if (!string.IsNullOrWhiteSpace(plugin.ApiVersion)) + { + if (!AirAppMarketIndexDocument.TryParseVersion(plugin.ApiVersion, out var pluginApiVersion) || + pluginApiVersion is null) + { + return $"Plugin '{plugin.Id}' declares invalid API version '{plugin.ApiVersion}'."; + } + + var hostApiVersion = PluginSdkInfo.ApiVersion; + if (hostApiVersion is not null) + { + if (!AirAppMarketIndexDocument.TryParseVersion(hostApiVersion, out var hostApiVersionParsed) || + hostApiVersionParsed is null) + { + AppLogger.Warn("PluginMarket", $"Host API version '{hostApiVersion}' could not be parsed. Skipping API version check."); + } + else if (pluginApiVersion.Major != hostApiVersionParsed.Major) + { + return $"Plugin '{plugin.Id}' uses incompatible API version {plugin.ApiVersion}. Host API version is {hostApiVersion}. Major version must match."; + } + } + } + + return null; + } + private async Task TryInstallFromSourceAsync( AirAppMarketPluginEntry plugin, AirAppMarketPluginPackageSourceEntry source, @@ -275,6 +383,71 @@ internal sealed class AirAppMarketInstallService : IDisposable } } + private async Task DownloadPackageAsync( + AirAppMarketPluginEntry plugin, + AirAppMarketPluginPackageSourceEntry source, + CancellationToken cancellationToken) + { + var packagePath = Path.Combine( + _downloadsDirectory, + $"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp"); + + try + { + var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false); + AppLogger.Info( + "PluginMarket", + $"Downloading upgrade package for '{plugin.Id}' from '{resolvedDownloadUrl}'."); + + var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, packagePath, cancellationToken).ConfigureAwait(false); + if (!acquireResult.Success) + { + TryDeleteFile(packagePath); + return new DownloadPackageResult(false, null, acquireResult.ErrorMessage); + } + + var verificationResult = await VerifyPackageAsync(plugin, packagePath, cancellationToken).ConfigureAwait(false); + if (!verificationResult.Success) + { + TryDeleteFile(packagePath); + return new DownloadPackageResult(false, null, verificationResult.ErrorMessage); + } + + return new DownloadPackageResult(true, packagePath, null); + } + catch (OperationCanceledException) + { + TryDeleteFile(packagePath); + throw; + } + catch (Exception ex) + { + TryDeleteFile(packagePath); + return new DownloadPackageResult(false, null, ex.Message); + } + } + + private static PluginManifest ReadManifestFromPackage(string packagePath) + { + using var archive = System.IO.Compression.ZipFile.OpenRead(packagePath); + var entries = archive.Entries + .Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (entries.Length == 0) + { + throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'."); + } + + if (entries.Length > 1) + { + throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files."); + } + + using var stream = entries[0].Open(); + return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}"); + } + public void Dispose() { _httpClient.Dispose(); @@ -299,4 +472,9 @@ internal sealed class AirAppMarketInstallService : IDisposable private sealed record AirAppMarketVerificationResult( bool Success, string? ErrorMessage); + + private sealed record DownloadPackageResult( + bool Success, + string? PackagePath, + string? ErrorMessage); } diff --git a/LanMountainDesktop/plugins/PluginMarketModels.cs b/LanMountainDesktop/plugins/PluginMarketModels.cs index bbdad7a..66e1cd2 100644 --- a/LanMountainDesktop/plugins/PluginMarketModels.cs +++ b/LanMountainDesktop/plugins/PluginMarketModels.cs @@ -305,7 +305,8 @@ internal sealed record AirAppMarketLoadResult( internal sealed record AirAppMarketInstallResult( bool Success, PluginManifest? Manifest, - string? ErrorMessage); + string? ErrorMessage, + bool RestartRequired = false); internal sealed class AirAppMarketIndexDocument { diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index c5cad4e..234ad04 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -773,11 +773,6 @@ public sealed class PluginRuntimeService : IDisposable private void ApplyPendingPluginDeletions() { var pendingPaths = ReadPendingPluginDeletions(); - if (pendingPaths.Count == 0) - { - return; - } - var remainingPaths = new List(); foreach (var path in pendingPaths) { @@ -788,6 +783,41 @@ public sealed class PluginRuntimeService : IDisposable } SavePendingPluginDeletions(remainingPaths); + CleanupPendingDeletionDirectory(); + } + + private void CleanupPendingDeletionDirectory() + { + var pendingDeletionDir = Path.Combine(PluginsDirectory, ".pending-deletions"); + if (!Directory.Exists(pendingDeletionDir)) + { + return; + } + + foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending")) + { + try + { + File.Delete(pendingFile); + } + catch + { + // Ignore cleanup failures for pending deletions. + } + } + + try + { + if (Directory.GetFiles(pendingDeletionDir).Length == 0 && + Directory.GetDirectories(pendingDeletionDir).Length == 0) + { + Directory.Delete(pendingDeletionDir); + } + } + catch + { + // Ignore directory cleanup failures. + } } private string ResolvePluginRemovalTargetPath(PluginCatalogEntry entry)