mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
refactor(launcher): converge plugin pending to Host via PluginPackaging
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<RootNamespace>LanMountainDesktop.PluginPackaging</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
216
LanMountainDesktop.PluginPackaging/PendingPluginUpgradeStore.cs
Normal file
216
LanMountainDesktop.PluginPackaging/PendingPluginUpgradeStore.cs
Normal file
@@ -0,0 +1,216 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public enum PendingPluginOperation
|
||||
{
|
||||
InstallOrUpgrade = 0
|
||||
}
|
||||
|
||||
public sealed record PendingPluginUpgrade(
|
||||
string PluginId,
|
||||
string SourcePackagePath,
|
||||
string TargetVersion,
|
||||
DateTimeOffset CreatedAt,
|
||||
PendingPluginOperation Operation = PendingPluginOperation.InstallOrUpgrade)
|
||||
{
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(PluginId) &&
|
||||
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
|
||||
!string.IsNullOrWhiteSpace(TargetVersion);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PendingPluginOperationApplySummary(
|
||||
int SuccessCount,
|
||||
int FailureCount,
|
||||
IReadOnlyList<PendingPluginOperationFailure> Failures);
|
||||
|
||||
public sealed record PendingPluginOperationFailure(
|
||||
string PluginId,
|
||||
PendingPluginOperation Operation,
|
||||
string ErrorMessage);
|
||||
|
||||
public sealed class PendingPluginUpgradeStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private readonly string _pluginsDirectory;
|
||||
private readonly string _pendingUpgradesFilePath;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public PendingPluginUpgradeStore(string pluginsDirectory)
|
||||
{
|
||||
_pluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
_pendingUpgradesFilePath = Path.Combine(_pluginsDirectory, PluginPackagingConstants.PendingUpgradesFileName);
|
||||
}
|
||||
|
||||
public IReadOnlyList<PendingPluginUpgrade> GetPendingUpgrades()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return ReadPendingUpgradesCore();
|
||||
}
|
||||
}
|
||||
|
||||
public void AddPendingInstallOrUpgrade(string pluginId, string sourcePackagePath, string targetVersion)
|
||||
{
|
||||
AddPendingOperation(pluginId, sourcePackagePath, targetVersion, PendingPluginOperation.InstallOrUpgrade);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearPendingUpgrades()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (File.Exists(_pendingUpgradesFilePath))
|
||||
{
|
||||
File.Delete(_pendingUpgradesFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasPendingUpgrades()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return ReadPendingUpgradesCore().Count > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public PendingPluginOperationApplySummary ApplyPendingOperations(
|
||||
PluginPackageInstaller installer,
|
||||
PluginPackageInstallOptions? options = null,
|
||||
Action<PluginPackageManifest>? prepareManifest = null)
|
||||
{
|
||||
options ??= PluginPackageInstallOptions.Default;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
var pending = ReadPendingUpgradesCore();
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return new PendingPluginOperationApplySummary(0, 0, []);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_pluginsDirectory);
|
||||
var succeeded = new List<PendingPluginUpgrade>();
|
||||
var failures = new List<PendingPluginOperationFailure>();
|
||||
|
||||
foreach (var operation in pending)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (operation.Operation != PendingPluginOperation.InstallOrUpgrade)
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported pending plugin operation '{operation.Operation}'.");
|
||||
}
|
||||
|
||||
installer.Install(operation.SourcePackagePath, _pluginsDirectory, options, prepareManifest);
|
||||
succeeded.Add(operation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(new PendingPluginOperationFailure(
|
||||
operation.PluginId,
|
||||
operation.Operation,
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
var remaining = pending.Except(succeeded).ToList();
|
||||
SavePendingUpgradesCore(remaining);
|
||||
return new PendingPluginOperationApplySummary(succeeded.Count, failures.Count, failures);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddPendingOperation(
|
||||
string pluginId,
|
||||
string sourcePackagePath,
|
||||
string targetVersion,
|
||||
PendingPluginOperation operation)
|
||||
{
|
||||
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,
|
||||
operation));
|
||||
|
||||
SavePendingUpgradesCore(upgrades);
|
||||
}
|
||||
}
|
||||
|
||||
private List<PendingPluginUpgrade> ReadPendingUpgradesCore()
|
||||
{
|
||||
if (!File.Exists(_pendingUpgradesFilePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_pendingUpgradesFilePath);
|
||||
var upgrades = JsonSerializer.Deserialize<List<PendingPluginUpgrade>>(json, SerializerOptions);
|
||||
return upgrades?.Where(u => u.IsValid()).ToList() ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void SavePendingUpgradesCore(List<PendingPluginUpgrade> upgrades)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_pendingUpgradesFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
if (upgrades.Count == 0)
|
||||
{
|
||||
if (File.Exists(_pendingUpgradesFilePath))
|
||||
{
|
||||
File.Delete(_pendingUpgradesFilePath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(upgrades, SerializerOptions);
|
||||
File.WriteAllText(_pendingUpgradesFilePath, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public sealed class PluginPackageInstallOptions
|
||||
{
|
||||
public bool IncludeLegacyPackages { get; init; }
|
||||
|
||||
public static PluginPackageInstallOptions Default { get; } = new();
|
||||
|
||||
public static PluginPackageInstallOptions WithLegacySupport { get; } = new() { IncludeLegacyPackages = true };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public sealed record PluginPackageInstallResult(string InstalledPackagePath, PluginPackageManifest Manifest);
|
||||
195
LanMountainDesktop.PluginPackaging/PluginPackageInstaller.cs
Normal file
195
LanMountainDesktop.PluginPackaging/PluginPackageInstaller.cs
Normal file
@@ -0,0 +1,195 @@
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public sealed class PluginPackageInstaller
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public PluginPackageInstallResult Install(
|
||||
string sourcePackagePath,
|
||||
string pluginsDirectory,
|
||||
PluginPackageInstallOptions? options = null,
|
||||
Action<PluginPackageManifest>? prepareManifest = null)
|
||||
{
|
||||
options ??= PluginPackageInstallOptions.Default;
|
||||
var fullSourcePath = Path.GetFullPath(sourcePackagePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = PluginPackageManifestReader.Read(fullSourcePath, options.IncludeLegacyPackages);
|
||||
prepareManifest?.Invoke(manifest);
|
||||
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath, options);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
return new PluginPackageInstallResult(destinationPath, manifest);
|
||||
}
|
||||
|
||||
private static void RemoveExistingPluginPackages(
|
||||
string pluginsDirectory,
|
||||
string pluginId,
|
||||
string destinationPath,
|
||||
string stagingPath,
|
||||
PluginPackageInstallOptions options)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(
|
||||
Path.Combine(Path.GetFullPath(pluginsDirectory), PluginPackagingConstants.RuntimeDirectoryName));
|
||||
var pendingDeletionDir = Path.Combine(pluginsDirectory, PluginPackagingConstants.PendingDeletionDirectoryName);
|
||||
Directory.CreateDirectory(pendingDeletionDir);
|
||||
|
||||
foreach (var existingPackagePath in EnumerateExistingPackages(pluginsDirectory, options)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = PluginPackageManifestReader.Read(existingPackagePath, options.IncludeLegacyPackages);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or malformed packages while replacing one plugin id.
|
||||
}
|
||||
}
|
||||
|
||||
CleanupPendingDeletions(pendingDeletionDir);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateExistingPackages(string pluginsDirectory, PluginPackageInstallOptions options)
|
||||
{
|
||||
if (options.IncludeLegacyPackages)
|
||||
{
|
||||
return Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*", SearchOption.AllDirectories)
|
||||
.Where(path =>
|
||||
path.EndsWith(PluginPackagingConstants.PackageFileExtension, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.EndsWith(PluginPackagingConstants.LegacyPackageFileExtension, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return Directory.EnumerateFiles(
|
||||
pluginsDirectory,
|
||||
$"*{PluginPackagingConstants.PackageFileExtension}",
|
||||
SearchOption.AllDirectories);
|
||||
}
|
||||
|
||||
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");
|
||||
File.Move(existingPackagePath, pendingPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupPendingDeletions(string pendingDeletionDir)
|
||||
{
|
||||
if (!Directory.Exists(pendingDeletionDir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(pendingFile);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||
{
|
||||
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||
}
|
||||
|
||||
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||
{
|
||||
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||
}
|
||||
|
||||
private static void DeleteFileWithRetry(string filePath)
|
||||
{
|
||||
Retry(() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginPackagingConstants.PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public sealed record PluginPackageManifest(string Id, string Name, string Version);
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public static class PluginPackageManifestReader
|
||||
{
|
||||
public static PluginPackageManifest Read(string packagePath, bool includeLegacyManifest = false)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = FindManifestEntries(archive, PluginPackagingConstants.ManifestFileName);
|
||||
if (entries.Length == 0 && includeLegacyManifest)
|
||||
{
|
||||
entries = FindManifestEntries(archive, PluginPackagingConstants.LegacyManifestFileName);
|
||||
}
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
var expected = includeLegacyManifest
|
||||
? $"'{PluginPackagingConstants.ManifestFileName}' or '{PluginPackagingConstants.LegacyManifestFileName}'"
|
||||
: $"'{PluginPackagingConstants.ManifestFileName}'";
|
||||
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain {expected}.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{PluginPackagingConstants.ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
var id = ReadRequiredString(root, "id");
|
||||
var name = ReadRequiredString(root, "name");
|
||||
var version = ReadOptionalString(root, "version") ?? string.Empty;
|
||||
return new PluginPackageManifest(id, name, version);
|
||||
}
|
||||
|
||||
private static ZipArchiveEntry[] FindManifestEntries(ZipArchive archive, string manifestFileName)
|
||||
{
|
||||
return archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, manifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string ReadRequiredString(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var value) ||
|
||||
value.ValueKind != JsonValueKind.String ||
|
||||
string.IsNullOrWhiteSpace(value.GetString()))
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin manifest is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return value.GetString()!;
|
||||
}
|
||||
|
||||
private static string? ReadOptionalString(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.GetString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginPackaging;
|
||||
|
||||
public static class PluginPackagingConstants
|
||||
{
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string LegacyManifestFileName = "manifest.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string LegacyPackageFileExtension = ".lmdp";
|
||||
public const string RuntimeDirectoryName = ".runtime";
|
||||
public const string PendingDeletionDirectoryName = ".pending-deletions";
|
||||
public const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
|
||||
}
|
||||
Reference in New Issue
Block a user