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)