refactor(launcher): converge plugin pending to Host via PluginPackaging

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lincube
2026-05-28 10:28:31 +08:00
parent 545dee85a7
commit 1ee6e68f33
32 changed files with 888 additions and 1924 deletions

View File

@@ -1,634 +0,0 @@
using System.Diagnostics;
using System.Text.Json;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 灵活的主程序定位器
/// </summary>
internal sealed class FlexibleHostLocator
{
private readonly HostDiscoveryOptions _options;
private readonly string _appRoot;
private readonly DeploymentLocator _deploymentLocator;
public FlexibleHostLocator(string appRoot, HostDiscoveryOptions? options = null)
{
_appRoot = appRoot;
_options = options ?? new HostDiscoveryOptions();
_deploymentLocator = new DeploymentLocator(appRoot);
}
/// <summary>
/// 解析主程序可执行文件路径
/// </summary>
public string? ResolveHostExecutablePath()
{
var executable = GetExecutableName();
var searchContext = new SearchContext
{
ExecutableName = executable,
AppRoot = _appRoot,
Options = _options
};
// ========== 第一阶段:标准路径查找(快速路径)==========
// 1. 检查环境变量指定的路径(最高优先级 - 用于调试和特殊场景)
var envPath = GetPathFromEnvironment();
if (!string.IsNullOrWhiteSpace(envPath))
{
var validated = ValidateAndReturn(envPath, "environment variable");
if (validated != null) return validated;
}
// 2. 使用 DeploymentLocatorClassIsland 风格的简洁查询 - 优先)
Console.WriteLine("[FlexibleHostLocator] Trying quick path: DeploymentLocator.FindCurrentDeploymentDirectory()");
var deploymentDir = _deploymentLocator.FindCurrentDeploymentDirectory();
if (!string.IsNullOrWhiteSpace(deploymentDir))
{
var deploymentExePath = Path.Combine(deploymentDir, executable);
if (File.Exists(deploymentExePath))
{
Console.WriteLine($"[FlexibleHostLocator] Quick path found: {deploymentExePath}");
return deploymentExePath;
}
Console.WriteLine($"[FlexibleHostLocator] Quick path found dir but no exe: {deploymentExePath}");
}
// 3. 快速路径失败,尝试旧的 SearchDeploymentDirectories 作为 fallback
Console.WriteLine("[FlexibleHostLocator] Quick path failed, falling back to SearchDeploymentDirectories");
var deploymentPath = SearchDeploymentDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(deploymentPath))
{
return deploymentPath;
}
// 4. 检查 Launcher 同级目录(便携模式)
var portablePath = SearchPortableLocation(searchContext);
if (!string.IsNullOrWhiteSpace(portablePath))
{
return portablePath;
}
// ========== 第二阶段:灵活查找(标准路径找不到时)==========
// 5. 检查配置文件中的路径 - 用户自定义配置
var configPath = GetPathFromConfigFile();
if (!string.IsNullOrWhiteSpace(configPath))
{
var validated = ValidateAndReturn(configPath, "config file");
if (validated != null) return validated;
}
// 5. 搜索附近目录(向上、向下各一层)
var nearbyPath = SearchNearbyDirectories(searchContext);
if (!string.IsNullOrWhiteSpace(nearbyPath))
{
return nearbyPath;
}
// 7. 开发模式:检查保存的自定义路径
if (_options.PreferDevModeConfig && Views.ErrorWindow.CheckDevModeEnabled())
{
var savedPath = Views.ErrorWindow.GetSavedCustomHostPath();
if (!string.IsNullOrWhiteSpace(savedPath))
{
var validated = ValidateAndReturn(savedPath, "saved dev mode path");
if (validated != null) return validated;
}
}
// 8. 搜索标准开发路径
var devPath = SearchDevelopmentPaths(searchContext);
if (!string.IsNullOrWhiteSpace(devPath))
{
return devPath;
}
// 9. 搜索额外的配置路径
var additionalPath = SearchAdditionalPaths(searchContext);
if (!string.IsNullOrWhiteSpace(additionalPath))
{
return additionalPath;
}
// 10. 递归搜索(如果启用)
if (_options.RecursiveSearch)
{
var recursivePath = SearchRecursively(searchContext);
if (!string.IsNullOrWhiteSpace(recursivePath))
{
return recursivePath;
}
}
return null;
}
/// <summary>
/// 从环境变量获取路径
/// </summary>
private string? GetPathFromEnvironment()
{
if (string.IsNullOrWhiteSpace(_options.CustomPathEnvVar))
{
return null;
}
var path = Environment.GetEnvironmentVariable(_options.CustomPathEnvVar);
return path;
}
/// <summary>
/// 从配置文件获取路径
/// </summary>
private string? GetPathFromConfigFile()
{
if (string.IsNullOrWhiteSpace(_options.ConfigFileName))
{
return null;
}
var configPath = Path.Combine(_appRoot, _options.ConfigFileName);
if (!File.Exists(configPath))
{
return null;
}
try
{
var json = File.ReadAllText(configPath);
var config = JsonSerializer.Deserialize(json, AppJsonContext.Default.HostDiscoveryConfig);
if (config?.HostPath != null && File.Exists(config.HostPath))
{
return config.HostPath;
}
}
catch
{
// 忽略配置文件读取错误
}
return null;
}
/// <summary>
/// 搜索部署目录
/// </summary>
private string? SearchDeploymentDirectories(SearchContext context)
{
if (!Directory.Exists(_appRoot))
{
return null;
}
try
{
// 查找 app-* 目录
var appDirs = Directory.GetDirectories(_appRoot, "app-*", SearchOption.TopDirectoryOnly)
.Where(dir => !File.Exists(Path.Combine(dir, ".destroy")))
.Where(dir => !File.Exists(Path.Combine(dir, ".partial")))
.ToList();
// 优先选择带 .current 标记的
var currentMarked = appDirs
.Where(dir => File.Exists(Path.Combine(dir, ".current")))
.Select(dir => Path.Combine(dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
if (currentMarked != null)
{
return currentMarked;
}
// 选择版本号最高的
var latest = appDirs
.Select(dir => new
{
Dir = dir,
Version = ParseVersionFromDirectoryName(dir)
})
.OrderByDescending(x => x.Version)
.Select(x => Path.Combine(x.Dir, context.ExecutableName))
.FirstOrDefault(File.Exists);
return latest;
}
catch
{
return null;
}
}
/// <summary>
/// 搜索便携模式位置Launcher 同级目录)
/// </summary>
private string? SearchPortableLocation(SearchContext context)
{
try
{
var launcherDir = AppContext.BaseDirectory;
var portablePath = Path.Combine(launcherDir, context.ExecutableName);
if (File.Exists(portablePath))
{
return portablePath;
}
}
catch
{
// 忽略错误
}
return null;
}
/// <summary>
/// 搜索附近目录(灵活查找,适用于各种部署场景)
/// </summary>
private string? SearchNearbyDirectories(SearchContext context)
{
try
{
var searchDirs = new List<string>();
// Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
searchDirs.Add(launcherDir);
// 上级目录
var parentDir = Path.GetFullPath(Path.Combine(launcherDir, ".."));
if (Directory.Exists(parentDir))
{
searchDirs.Add(parentDir);
}
// 上上级目录
var grandparentDir = Path.GetFullPath(Path.Combine(launcherDir, "..", ".."));
if (Directory.Exists(grandparentDir))
{
searchDirs.Add(grandparentDir);
}
// AppRoot 及其上级
if (!string.IsNullOrWhiteSpace(_appRoot) && Directory.Exists(_appRoot))
{
searchDirs.Add(_appRoot);
var appParent = Path.GetFullPath(Path.Combine(_appRoot, ".."));
if (Directory.Exists(appParent))
{
searchDirs.Add(appParent);
}
}
// 去重后搜索
foreach (var dir in searchDirs.Distinct(StringComparer.OrdinalIgnoreCase))
{
// 直接搜索
var directPath = Path.Combine(dir, context.ExecutableName);
if (File.Exists(directPath))
{
return directPath;
}
// 搜索子目录(一层)
if (Directory.Exists(dir))
{
foreach (var subDir in Directory.GetDirectories(dir))
{
var subPath = Path.Combine(subDir, context.ExecutableName);
if (File.Exists(subPath))
{
return subPath;
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return null;
}
/// <summary>
/// 搜索开发路径
/// </summary>
private string? SearchDevelopmentPaths(SearchContext context)
{
// 获取 Launcher 所在目录
var launcherDir = AppContext.BaseDirectory;
// 动态构建可能的开发路径(支持不同的项目结构)
var possiblePaths = new List<string>();
// 从解决方案根目录搜索(支持不同的解决方案结构)
var solutionRoot = FindSolutionRoot(launcherDir);
if (!string.IsNullOrWhiteSpace(solutionRoot))
{
// 搜索所有可能的 bin 目录
possiblePaths.AddRange(SearchBinDirectories(solutionRoot, context.ExecutableName));
}
// 添加硬编码的备用路径
possiblePaths.AddRange(new[]
{
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Debug", "net10.0", context.ExecutableName),
Path.Combine(launcherDir, "..", "LanMountainDesktop", "bin", "Release", "net10.0", context.ExecutableName),
});
foreach (var path in possiblePaths.Select(Path.GetFullPath).Distinct())
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
/// <summary>
/// 搜索额外的配置路径
/// </summary>
private string? SearchAdditionalPaths(SearchContext context)
{
if (_options.AdditionalSearchPaths == null || !_options.AdditionalSearchPaths.Any())
{
return null;
}
foreach (var pattern in _options.AdditionalSearchPaths)
{
try
{
// 替换变量
var expandedPattern = ExpandVariables(pattern);
// 支持通配符
if (expandedPattern.Contains('*') || expandedPattern.Contains('?'))
{
var dir = Path.GetDirectoryName(expandedPattern) ?? _appRoot;
var filePattern = Path.GetFileName(expandedPattern);
if (Directory.Exists(dir))
{
var matches = Directory.GetFiles(dir, filePattern, SearchOption.TopDirectoryOnly);
var validMatch = matches.FirstOrDefault(File.Exists);
if (validMatch != null)
{
return validMatch;
}
}
}
else if (File.Exists(expandedPattern))
{
return expandedPattern;
}
}
catch
{
// 忽略搜索错误
}
}
return null;
}
/// <summary>
/// 递归搜索
/// </summary>
private string? SearchRecursively(SearchContext context)
{
try
{
var searchDirs = new[] { _appRoot, Path.GetFullPath(Path.Combine(_appRoot, "..")) };
foreach (var searchDir in searchDirs.Where(Directory.Exists))
{
var result = SearchDirectoryRecursively(searchDir, context.ExecutableName, 0);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略递归搜索错误
}
return null;
}
/// <summary>
/// 递归搜索目录
/// </summary>
private string? SearchDirectoryRecursively(string dir, string executableName, int depth)
{
if (depth > _options.MaxRecursionDepth)
{
return null;
}
try
{
// 检查当前目录
var directPath = Path.Combine(dir, executableName);
if (File.Exists(directPath))
{
return directPath;
}
// 检查子目录
foreach (var subDir in Directory.GetDirectories(dir))
{
// 跳过某些目录
var dirName = Path.GetFileName(subDir).ToLowerInvariant();
if (dirName is ".git" or "node_modules" or ".vs" or "obj" or ".launcher")
{
continue;
}
var result = SearchDirectoryRecursively(subDir, executableName, depth + 1);
if (result != null)
{
return result;
}
}
}
catch
{
// 忽略访问错误
}
return null;
}
/// <summary>
/// 查找解决方案根目录
/// </summary>
private string? FindSolutionRoot(string startDir)
{
var current = new DirectoryInfo(startDir);
while (current != null)
{
// 查找 .sln 文件
if (current.GetFiles("*.sln").Any())
{
return current.FullName;
}
// 查找 .git 目录作为备选
if (current.GetDirectories(".git").Any())
{
return current.FullName;
}
current = current.Parent;
}
return null;
}
/// <summary>
/// 搜索 bin 目录
/// </summary>
private IEnumerable<string> SearchBinDirectories(string root, string executableName)
{
var results = new List<string>();
try
{
// 查找所有 bin 目录
var binDirs = Directory.GetDirectories(root, "bin", SearchOption.AllDirectories);
foreach (var binDir in binDirs)
{
// 检查 Debug 和 Release 子目录
var configDirs = new[] { "Debug", "Release" };
foreach (var config in configDirs)
{
var configPath = Path.Combine(binDir, config);
if (Directory.Exists(configPath))
{
// 检查所有 net* 子目录
var frameworkDirs = Directory.GetDirectories(configPath, "net*");
foreach (var fwDir in frameworkDirs)
{
var exePath = Path.Combine(fwDir, executableName);
if (File.Exists(exePath))
{
results.Add(exePath);
}
}
}
}
}
}
catch
{
// 忽略搜索错误
}
return results;
}
/// <summary>
/// 验证路径并返回
/// </summary>
private string? ValidateAndReturn(string path, string source)
{
if (File.Exists(path))
{
Debug.WriteLine($"Found host executable from {source}: {path}");
return path;
}
// 尝试添加 .exeWindows
if (OperatingSystem.IsWindows() && !path.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
var withExe = path + ".exe";
if (File.Exists(withExe))
{
Debug.WriteLine($"Found host executable from {source}: {withExe}");
return withExe;
}
}
if (string.Equals(source, "saved dev mode path", StringComparison.OrdinalIgnoreCase))
{
Logger.Warn($"Saved launcher debug host path is invalid; continuing host discovery. Path='{path}'.");
}
return null;
}
/// <summary>
/// 获取可执行文件名
/// </summary>
private string GetExecutableName()
{
var name = _options.ExecutableName;
if (OperatingSystem.IsWindows() && !name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
{
name += ".exe";
}
return name;
}
/// <summary>
/// 展开路径变量
/// </summary>
private string ExpandVariables(string path)
{
return path
.Replace("${AppRoot}", _appRoot)
.Replace("${BaseDirectory}", AppContext.BaseDirectory)
.Replace("${UserProfile}", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile))
.Replace("${LocalAppData}", Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData));
}
/// <summary>
/// 从目录名解析版本
/// </summary>
private static Version ParseVersionFromDirectoryName(string path)
{
var fileName = Path.GetFileName(path);
if (string.IsNullOrWhiteSpace(fileName))
{
return new Version(0, 0, 0);
}
var segments = fileName.Split('-');
if (segments.Length < 2)
{
return new Version(0, 0, 0);
}
return Version.TryParse(segments[1], out var version) ? version : new Version(0, 0, 0);
}
/// <summary>
/// 搜索上下文
/// </summary>
private class SearchContext
{
public required string ExecutableName { get; set; }
public required string AppRoot { get; set; }
public required HostDiscoveryOptions Options { get; set; }
}
}
/// <summary>
/// 发现配置文件
/// </summary>
internal class HostDiscoveryConfig
{
public string? HostPath { get; set; }
public List<string>? AdditionalPaths { get; set; }
}

View File

@@ -1,161 +0,0 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using LanMountainDesktop.Launcher.Models;
namespace LanMountainDesktop.Launcher.Services;
/// <summary>
/// 更新检查服务 - 基于 GitHub Release API
/// </summary>
internal sealed class UpdateCheckService
{
private const string GitHubApiBase = "https://api.github.com";
private readonly string _repoOwner;
private readonly string _repoName;
private readonly HttpClient _httpClient;
public UpdateCheckService(string repoOwner, string repoName)
{
_repoOwner = repoOwner;
_repoName = repoName;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "LanMountainDesktop-Launcher");
_httpClient.DefaultRequestHeaders.Add("Accept", "application/vnd.github+json");
}
/// <summary>
/// 检查更新
/// </summary>
public async Task<UpdateCheckResult> CheckForUpdateAsync(
string currentVersion,
UpdateChannel channel,
CancellationToken cancellationToken = default)
{
try
{
var releases = await FetchReleasesAsync(cancellationToken);
// 根据频道过滤版本
var filteredReleases = channel == UpdateChannel.Stable
? releases.Where(r => !r.Prerelease).ToList()
: releases;
// 找到最新版本
var latestRelease = filteredReleases
.OrderByDescending(r => ParseVersion(r.TagName))
.FirstOrDefault();
if (latestRelease == null)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = "No releases found"
};
}
var latestVersion = ParseVersionString(latestRelease.TagName);
var current = ParseVersion(currentVersion);
var latest = ParseVersion(latestVersion);
return new UpdateCheckResult
{
HasUpdate = latest > current,
LatestVersion = latestVersion,
CurrentVersion = currentVersion,
Release = latestRelease
};
}
catch (Exception ex)
{
return new UpdateCheckResult
{
HasUpdate = false,
CurrentVersion = currentVersion,
ErrorMessage = ex.Message
};
}
}
/// <summary>
/// 获取所有 Release
/// </summary>
private async Task<List<ReleaseInfo>> FetchReleasesAsync(CancellationToken cancellationToken)
{
var url = $"{GitHubApiBase}/repos/{_repoOwner}/{_repoName}/releases";
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var releases = JsonSerializer.Deserialize(json, AppJsonContext.Default.ListGitHubRelease);
return releases?.Select(r => new ReleaseInfo
{
TagName = r.TagName ?? "",
Name = r.Name ?? "",
Prerelease = r.Prerelease,
PublishedAt = r.PublishedAt,
Body = r.Body,
Assets = r.Assets?.Select(a => new ReleaseAsset
{
Name = a.Name ?? "",
BrowserDownloadUrl = a.BrowserDownloadUrl ?? "",
Size = a.Size
}).ToList() ?? []
}).ToList() ?? [];
}
/// <summary>
/// 从 tag 解析版本号 (例如: v1.0.0 -> 1.0.0)
/// </summary>
private static string ParseVersionString(string tag)
{
return tag.TrimStart('v', 'V');
}
/// <summary>
/// 解析版本号
/// </summary>
private static Version ParseVersion(string versionString)
{
var cleaned = ParseVersionString(versionString);
return Version.TryParse(cleaned, out var version) ? version : new Version(0, 0, 0);
}
}
// GitHub API 响应模型
internal sealed class GitHubRelease
{
[JsonPropertyName("tag_name")]
public string? TagName { get; set; }
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("prerelease")]
public bool Prerelease { get; set; }
[JsonPropertyName("published_at")]
public DateTime PublishedAt { get; set; }
[JsonPropertyName("body")]
public string? Body { get; set; }
[JsonPropertyName("assets")]
public List<GitHubAsset>? Assets { get; set; }
}
internal sealed class GitHubAsset
{
[JsonPropertyName("name")]
public string? Name { get; set; }
[JsonPropertyName("browser_download_url")]
public string? BrowserDownloadUrl { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
}

View File

@@ -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>

View 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);
}
}

View File

@@ -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 };
}

View File

@@ -0,0 +1,3 @@
namespace LanMountainDesktop.PluginPackaging;
public sealed record PluginPackageInstallResult(string InstalledPackagePath, PluginPackageManifest Manifest);

View 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;
}
}

View File

@@ -0,0 +1,3 @@
namespace LanMountainDesktop.PluginPackaging;
public sealed record PluginPackageManifest(string Id, string Name, string Version);

View File

@@ -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();
}
}

View File

@@ -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";
}

View File

@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>LanMountainDesktop.PluginUpgradeHelper</AssemblyName>
<RootNamespace>LanMountainDesktop.PluginUpgradeHelper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,372 +0,0 @@
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<PendingUpgrade>? pendingUpgrades;
try
{
var json = File.ReadAllText(pendingUpgradesPath);
pendingUpgrades = JsonSerializer.Deserialize<List<PendingUpgrade>>(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<string, string> 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<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(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);
}

View File

@@ -0,0 +1,159 @@
using System.IO.Compression;
using LanMountainDesktop.PluginPackaging;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class PendingPluginUpgradeServiceTests : IDisposable
{
private readonly string _tempRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(PendingPluginUpgradeServiceTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void AddPendingInstallOrUpgrade_ReplacesExistingOperationForSamePlugin()
{
var pluginsDirectory = CreatePluginsDirectory();
var firstPackage = CreatePluginPackage("first.laapp", "plugin.queue.sample", "Sample Plugin", "1.0.0");
var secondPackage = CreatePluginPackage("second.laapp", "plugin.queue.sample", "Sample Plugin", "2.0.0");
var service = new PendingPluginUpgradeService(pluginsDirectory);
service.AddPendingInstallOrUpgrade("plugin.queue.sample", firstPackage, "1.0.0");
service.AddPendingInstallOrUpgrade("plugin.queue.sample", secondPackage, "2.0.0");
var pending = service.GetPendingUpgrades();
var operation = Assert.Single(pending);
Assert.Equal("plugin.queue.sample", operation.PluginId);
Assert.Equal("2.0.0", operation.TargetVersion);
Assert.Equal(PendingPluginOperation.InstallOrUpgrade, operation.Operation);
Assert.Equal(Path.GetFullPath(secondPackage), operation.SourcePackagePath);
}
[Fact]
public void ApplyPendingOperations_InstallsPackageAndClearsSuccessfulOperation()
{
var pluginsDirectory = CreatePluginsDirectory();
var packagePath = CreatePluginPackage("sample.laapp", "plugin.install.queue", "Queued Plugin", "1.0.0");
var service = new PendingPluginUpgradeService(pluginsDirectory);
service.AddPendingInstallOrUpgrade("plugin.install.queue", packagePath, "1.0.0");
var result = service.ApplyPendingOperations();
Assert.Equal(1, result.SuccessCount);
Assert.Equal(0, result.FailureCount);
Assert.True(File.Exists(Path.Combine(pluginsDirectory, "plugin.install.queue.laapp")));
Assert.Empty(service.GetPendingUpgrades());
}
[Fact]
public void ApplyPendingOperations_ReplacesExistingPackageWithSamePluginId()
{
var pluginsDirectory = CreatePluginsDirectory();
var firstPackage = CreatePluginPackage("first.laapp", "plugin.replace.queue", "Old Plugin", "1.0.0");
var secondPackage = CreatePluginPackage("second.laapp", "plugin.replace.queue", "New Plugin", "2.0.0");
File.Copy(firstPackage, Path.Combine(pluginsDirectory, "plugin.replace.queue.laapp"));
var service = new PendingPluginUpgradeService(pluginsDirectory);
service.AddPendingInstallOrUpgrade("plugin.replace.queue", secondPackage, "2.0.0");
var result = service.ApplyPendingOperations();
Assert.Equal(1, result.SuccessCount);
var installedPackages = Directory.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.TopDirectoryOnly).ToArray();
var installedPackage = Assert.Single(installedPackages);
var manifest = ReadManifestFromPackage(installedPackage);
Assert.Equal("plugin.replace.queue", manifest.Id);
Assert.Equal("New Plugin", manifest.Name);
Assert.Equal("2.0.0", manifest.Version);
}
[Fact]
public void ApplyPendingOperations_KeepsFailedOperationQueued()
{
var pluginsDirectory = CreatePluginsDirectory();
var invalidPackage = Path.Combine(_tempRoot, "invalid.laapp");
Directory.CreateDirectory(_tempRoot);
using (ZipFile.Open(invalidPackage, ZipArchiveMode.Create))
{
}
var service = new PendingPluginUpgradeService(pluginsDirectory);
service.AddPendingInstallOrUpgrade("plugin.invalid.queue", invalidPackage, "1.0.0");
var result = service.ApplyPendingOperations();
Assert.Equal(0, result.SuccessCount);
Assert.Equal(1, result.FailureCount);
Assert.Single(service.GetPendingUpgrades());
}
[Fact]
public void ApplyPendingOperations_KeepsMissingPackageOperationQueued()
{
var pluginsDirectory = CreatePluginsDirectory();
var missingPackage = Path.Combine(_tempRoot, "missing.laapp");
var service = new PendingPluginUpgradeService(pluginsDirectory);
service.AddPendingInstallOrUpgrade("plugin.missing.queue", missingPackage, "1.0.0");
var result = service.ApplyPendingOperations();
Assert.Equal(0, result.SuccessCount);
Assert.Equal(1, result.FailureCount);
Assert.Single(service.GetPendingUpgrades());
}
private string CreatePluginsDirectory()
{
var directory = Path.Combine(_tempRoot, "Extensions", "Plugins");
Directory.CreateDirectory(directory);
return directory;
}
private string CreatePluginPackage(string fileName, string pluginId, string pluginName, string version)
{
Directory.CreateDirectory(_tempRoot);
var packagePath = Path.Combine(_tempRoot, fileName);
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
var entry = archive.CreateEntry(PluginSdkInfo.ManifestFileName);
using var stream = entry.Open();
using var writer = new StreamWriter(stream);
writer.Write(
$$"""
{
"id": "{{pluginId}}",
"name": "{{pluginName}}",
"version": "{{version}}",
"apiVersion": "5.0.0",
"entranceAssembly": "{{pluginId}}.dll"
}
""");
return packagePath;
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entry = archive.GetEntry(PluginSdkInfo.ManifestFileName)
?? throw new InvalidOperationException("Missing plugin manifest.");
using var stream = entry.Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entry.FullName}");
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
}
}
}

View File

@@ -8,11 +8,11 @@
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" /> <Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
<Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" /> <Project Path="LanMountainDesktop.PluginIsolation.Contracts/LanMountainDesktop.PluginIsolation.Contracts.csproj" />
<Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" /> <Project Path="LanMountainDesktop.PluginIsolation.Ipc/LanMountainDesktop.PluginIsolation.Ipc.csproj" />
<Project Path="LanMountainDesktop.PluginPackaging/LanMountainDesktop.PluginPackaging.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" /> <Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" /> <Project Path="LanMountainDesktop.AirAppHost/LanMountainDesktop.AirAppHost.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" /> <Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" /> <Project Path="LanMountainDesktop.Launcher/LanMountainDesktop.Launcher.csproj" />
<Project Path="LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj" />
<Project Path="ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj" /> <Project Path="ThirdParty/DotNetCampus.InkCanvas/DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" /> <Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" /> <Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />

View File

@@ -37,6 +37,7 @@
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" /> <ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" /> <ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" /> <ProjectReference Include="..\LanMountainDesktop.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginPackaging\LanMountainDesktop.PluginPackaging.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" /> <ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<!-- Launcher 引用已移除 - Launcher 现在是独立应用 --> <!-- Launcher 引用已移除 - Launcher 现在是独立应用 -->
</ItemGroup> </ItemGroup>

View File

@@ -1018,6 +1018,7 @@
"market.detail.state.not_installed": "Not installed", "market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available", "market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed", "market.detail.state.installed": "Installed",
"market.detail.state.restart_required": "Restart required",
"market.detail.unknown": "Unknown", "market.detail.unknown": "Unknown",
"market.button.install": "Install", "market.button.install": "Install",
"market.button.update": "Update", "market.button.update": "Update",
@@ -1455,7 +1456,7 @@
"single_instance.notice.title": "App already running", "single_instance.notice.title": "App already running",
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.", "single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK", "single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "Plugin '{0}' installed successfully! Please restart the application to activate it.", "market.status.install_success_restart_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?", "market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?",
"zhijiaohub.settings.source": "Image Source", "zhijiaohub.settings.source": "Image Source",
"zhijiaohub.settings.classisland": "ClassIsland Gallery", "zhijiaohub.settings.classisland": "ClassIsland Gallery",

View File

@@ -740,6 +740,7 @@
"market.detail.state.not_installed": "未インストール", "market.detail.state.not_installed": "未インストール",
"market.detail.state.update_available": "アップデートあり", "market.detail.state.update_available": "アップデートあり",
"market.detail.state.installed": "インストール済み", "market.detail.state.installed": "インストール済み",
"market.detail.state.restart_required": "再起動が必要",
"market.detail.unknown": "不明", "market.detail.unknown": "不明",
"market.button.install": "インストール", "market.button.install": "インストール",
"market.button.update": "アップデート", "market.button.update": "アップデート",
@@ -1163,7 +1164,7 @@
"single_instance.notice.title": "アプリは既に実行中", "single_instance.notice.title": "アプリは既に実行中",
"single_instance.notice.description": "アプリは既に実行中です。複数回クリックして開く必要はありません。", "single_instance.notice.description": "アプリは既に実行中です。複数回クリックして開く必要はありません。",
"single_instance.notice.button": "OK", "single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "プラグイン「{0}」が正常にインストールされました!有効にするにはアプリケーションを再起動してください。", "market.status.install_success_restart_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
"market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか", "market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか",
"component.settings.color_scheme": "カラースキーム", "component.settings.color_scheme": "カラースキーム",
"settings.weather.visual_style_header": "天気ビジュアルスタイル", "settings.weather.visual_style_header": "天気ビジュアルスタイル",

View File

@@ -787,6 +787,7 @@
"market.detail.state.not_installed": "미설치", "market.detail.state.not_installed": "미설치",
"market.detail.state.update_available": "업데이트 가능", "market.detail.state.update_available": "업데이트 가능",
"market.detail.state.installed": "설치됨", "market.detail.state.installed": "설치됨",
"market.detail.state.restart_required": "재시작 필요",
"market.detail.unknown": "알 수 없음", "market.detail.unknown": "알 수 없음",
"market.button.install": "설치", "market.button.install": "설치",
"market.button.update": "업데이트", "market.button.update": "업데이트",
@@ -1210,7 +1211,7 @@
"single_instance.notice.title": "앱이 이미 실행 중입니다", "single_instance.notice.title": "앱이 이미 실행 중입니다",
"single_instance.notice.description": "앱이 이미 실행 중이므로 여러 번 클릭하여 열 필요가 없습니다.", "single_instance.notice.description": "앱이 이미 실행 중이므로 여러 번 클릭하여 열 필요가 없습니다.",
"single_instance.notice.button": "확인", "single_instance.notice.button": "확인",
"market.status.install_success_restart_format": "플러그인 '{0}' 설치 성공! 활성화하려면 재시작하세요.", "market.status.install_success_restart_format": "플러그인 \"{0}\" 스테이징 완료. 앱 재시작 후 적용됩니다.",
"market.dialog.restart_message_format": "플러그인 '{0}'이(가) 성공적으로 설치되었습니다.\n\n이 플러그인을 사용하려면 앱을 즉시 재시작해야 합니다.\n\n지금 재시작하시겠습니까?", "market.dialog.restart_message_format": "플러그인 '{0}'이(가) 성공적으로 설치되었습니다.\n\n이 플러그인을 사용하려면 앱을 즉시 재시작해야 합니다.\n\n지금 재시작하시겠습니까?",
"settings.weather.visual_style_header": "날씨 비주얼 스타일", "settings.weather.visual_style_header": "날씨 비주얼 스타일",
"settings.weather.visual_style_desc": "데스크톱 날씨 위젯에 사용할 아이콘과 스타일을 선택합니다.", "settings.weather.visual_style_desc": "데스크톱 날씨 위젯에 사용할 아이콘과 스타일을 선택합니다.",

View File

@@ -949,6 +949,7 @@
"market.detail.state.not_installed": "未安装", "market.detail.state.not_installed": "未安装",
"market.detail.state.update_available": "可更新", "market.detail.state.update_available": "可更新",
"market.detail.state.installed": "已安装", "market.detail.state.installed": "已安装",
"market.detail.state.restart_required": "需要重启",
"market.detail.unknown": "未知", "market.detail.unknown": "未知",
"market.button.install": "安装", "market.button.install": "安装",
"market.button.update": "更新", "market.button.update": "更新",
@@ -1386,7 +1387,7 @@
"single_instance.notice.title": "应用已经运行", "single_instance.notice.title": "应用已经运行",
"single_instance.notice.description": "应用已经运行,无需多次点击打开。", "single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定", "single_instance.notice.button": "确定",
"market.status.install_success_restart_format": "插件'{0}'安装成功!请重启应用以激活它。", "market.status.install_success_restart_format": "插件{0}”已暂存完成。重启应用后生效。",
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启", "market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启",
"zhijiaohub.settings.source": "图片源", "zhijiaohub.settings.source": "图片源",
"zhijiaohub.settings.classisland": "ClassIsland 图库", "zhijiaohub.settings.classisland": "ClassIsland 图库",

View File

@@ -1,210 +0,0 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
internal sealed class LauncherClient
{
private const int UserCanceledUacErrorCode = 1223;
public async Task<LauncherInstallResult> InstallPackageAsync(
string packagePath,
string pluginsDirectory,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
if (!OperatingSystem.IsWindows())
{
return new LauncherInstallResult(
false,
null,
"Elevated helper install is only supported on Windows.",
"failed");
}
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new LauncherInstallResult(
false,
null,
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).",
"failed");
}
var resultPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop",
"PluginInstallResults",
$"{Guid.NewGuid():N}.json");
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
try
{
using var process = StartLauncherProcess(launcherPath, packagePath, pluginsDirectory, resultPath);
if (process is null)
{
return new LauncherInstallResult(false, null, "Failed to start launcher process.", "failed");
}
await process.WaitForExitAsync(cancellationToken);
var result = await ReadResultAsync(resultPath, cancellationToken);
if (result is not null)
{
return new LauncherInstallResult(
result.Success,
result.InstalledPackagePath,
result.ErrorMessage ?? result.Message,
MapResultCode(result.Code));
}
if (process.ExitCode == 0)
{
return new LauncherInstallResult(
false,
null,
"Launcher exited without producing a result file.",
"failed");
}
return new LauncherInstallResult(
false,
null,
string.Format(
CultureInfo.InvariantCulture,
"Launcher exited with code {0}.",
process.ExitCode),
"failed");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
{
return new LauncherInstallResult(false, null, "Administrator permission request was canceled.", "elevation_cancelled");
}
finally
{
TryDeleteFile(resultPath);
}
}
private static Process? StartLauncherProcess(
string launcherPath,
string packagePath,
string pluginsDirectory,
string resultPath)
{
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory,
Arguments = string.Create(
CultureInfo.InvariantCulture,
$"plugin install --source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))} --launch-source plugin-install")
};
return Process.Start(startInfo);
}
private static async Task<HelperResultFile?> ReadResultAsync(string resultPath, CancellationToken cancellationToken)
{
if (!File.Exists(resultPath))
{
return null;
}
await using var stream = File.OpenRead(resultPath);
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Ignore temp file cleanup failures.
}
}
private static string MapResultCode(string? launcherCode)
{
return launcherCode switch
{
"plugin_elevation_required" => "requires_elevation",
"elevation_cancelled" => "elevation_cancelled",
"ok" => "ok",
_ => "failed"
};
}
private sealed class HelperResultFile
{
[JsonPropertyName("success")]
public bool Success { get; init; }
[JsonPropertyName("code")]
public string? Code { get; init; }
[JsonPropertyName("message")]
public string? Message { get; init; }
[JsonPropertyName("installedPackagePath")]
public string? InstalledPackagePath { get; init; }
[JsonPropertyName("errorMessage")]
public string? ErrorMessage { get; init; }
}
}
internal sealed record LauncherInstallResult(
bool Success,
string? InstalledPackagePath,
string? ErrorMessage,
string Code);

View File

@@ -1,160 +1,124 @@
using System; using System.IO.Compression;
using System.Collections.Generic; using LanMountainDesktop.PluginPackaging;
using System.IO; using LanMountainDesktop.PluginSdk;
using System.Linq;
using System.Text.Json;
using System.Threading;
namespace LanMountainDesktop.Services; namespace LanMountainDesktop.Services;
public sealed class PendingPluginUpgradeService public sealed class PendingPluginUpgradeService
{ {
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json"; private readonly string _pluginsDirectory;
private static readonly Lock Gate = new(); private readonly PendingPluginUpgradeStore _store;
private readonly string _pendingUpgradesFilePath; private readonly PluginPackageInstaller _installer = new();
public PendingPluginUpgradeService(string pluginsDirectory) public PendingPluginUpgradeService(string pluginsDirectory)
{ {
_pendingUpgradesFilePath = Path.Combine(pluginsDirectory, PendingUpgradesFileName); _pluginsDirectory = Path.GetFullPath(pluginsDirectory);
_store = new PendingPluginUpgradeStore(_pluginsDirectory);
} }
public IReadOnlyList<PendingPluginUpgrade> GetPendingUpgrades() public IReadOnlyList<PendingPluginUpgrade> GetPendingUpgrades() => _store.GetPendingUpgrades();
{
lock (Gate)
{
return ReadPendingUpgradesCore();
}
}
public void AddPendingUpgrade(string pluginId, string sourcePackagePath, string targetVersion) public void AddPendingUpgrade(string pluginId, string sourcePackagePath, string targetVersion)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); AddPendingInstallOrUpgrade(pluginId, sourcePackagePath, targetVersion);
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath); }
ArgumentException.ThrowIfNullOrWhiteSpace(targetVersion);
lock (Gate) public void AddPendingInstallOrUpgrade(string pluginId, string sourcePackagePath, string targetVersion)
{ {
var upgrades = ReadPendingUpgradesCore().ToList(); _store.AddPendingInstallOrUpgrade(pluginId, sourcePackagePath, targetVersion);
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( AppLogger.Info(
"PendingPluginUpgrade", "PendingPluginUpgrade",
$"Added pending upgrade. PluginId='{pluginId}'; TargetVersion='{targetVersion}'; SourcePath='{sourcePackagePath}'."); $"Added pending plugin operation. PluginId='{pluginId}'; TargetVersion='{targetVersion}'; Operation='{PendingPluginOperation.InstallOrUpgrade}'; SourcePath='{sourcePackagePath}'.");
}
} }
public void RemovePendingUpgrade(string pluginId) public void RemovePendingUpgrade(string pluginId)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId); var hadPending = _store.GetPendingUpgrades()
.Any(u => string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
lock (Gate) _store.RemovePendingUpgrade(pluginId);
if (hadPending)
{ {
var upgrades = ReadPendingUpgradesCore().ToList(); AppLogger.Info("PendingPluginUpgrade", $"Removed pending upgrade. PluginId='{pluginId}'.");
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() public void ClearPendingUpgrades()
{ {
lock (Gate) _store.ClearPendingUpgrades();
{
if (File.Exists(_pendingUpgradesFilePath))
{
File.Delete(_pendingUpgradesFilePath);
AppLogger.Info("PendingPluginUpgrade", "Cleared all pending upgrades."); AppLogger.Info("PendingPluginUpgrade", "Cleared all pending upgrades.");
} }
}
public bool HasPendingUpgrades() => _store.HasPendingUpgrades();
public PendingPluginOperationApplySummary ApplyPendingOperations(
Action<PluginManifest>? prepareManifest = null)
{
var pending = _store.GetPendingUpgrades();
if (pending.Count == 0)
{
return new PendingPluginOperationApplySummary(0, 0, []);
} }
public bool HasPendingUpgrades() Directory.CreateDirectory(_pluginsDirectory);
{ var succeeded = new List<PendingPluginUpgrade>();
lock (Gate) var failures = new List<PendingPluginOperationFailure>();
{
return ReadPendingUpgradesCore().Count > 0;
}
}
private List<PendingPluginUpgrade> ReadPendingUpgradesCore() foreach (var operation in pending)
{ {
if (!File.Exists(_pendingUpgradesFilePath))
{
return [];
}
try try
{ {
var json = File.ReadAllText(_pendingUpgradesFilePath); if (operation.Operation != PendingPluginOperation.InstallOrUpgrade)
var upgrades = JsonSerializer.Deserialize<List<PendingPluginUpgrade>>(json); {
return upgrades?.Where(u => u.IsValid()).ToList() ?? []; throw new InvalidOperationException($"Unsupported pending plugin operation '{operation.Operation}'.");
}
var manifest = ReadManifestFromPackage(operation.SourcePackagePath);
prepareManifest?.Invoke(manifest);
_installer.Install(operation.SourcePackagePath, _pluginsDirectory);
succeeded.Add(operation);
AppLogger.Info(
"PendingPluginUpgrade",
$"Applied pending plugin operation. PluginId='{operation.PluginId}'; TargetVersion='{operation.TargetVersion}'; Operation='{operation.Operation}'.");
} }
catch (Exception ex) catch (Exception ex)
{ {
failures.Add(new PendingPluginOperationFailure(
operation.PluginId,
operation.Operation,
ex.Message));
AppLogger.Warn( AppLogger.Warn(
"PendingPluginUpgrade", "PendingPluginUpgrade",
$"Failed to read pending upgrades from '{_pendingUpgradesFilePath}'.", $"Failed to apply pending plugin operation. PluginId='{operation.PluginId}'; TargetVersion='{operation.TargetVersion}'; Operation='{operation.Operation}'.",
ex); ex);
return [];
} }
} }
private void SavePendingUpgradesCore(List<PendingPluginUpgrade> upgrades) foreach (var operation in succeeded)
{ {
try _store.RemovePendingUpgrade(operation.PluginId);
{
var directory = Path.GetDirectoryName(_pendingUpgradesFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
} }
var json = JsonSerializer.Serialize(upgrades, new JsonSerializerOptions return new PendingPluginOperationApplySummary(succeeded.Count, failures.Count, failures);
{ }
WriteIndented = true
});
File.WriteAllText(_pendingUpgradesFilePath, json); private static PluginManifest ReadManifestFromPackage(string packagePath)
}
catch (Exception ex)
{ {
AppLogger.Error( using var archive = ZipFile.OpenRead(packagePath);
"PendingPluginUpgrade", var entries = archive.Entries
$"Failed to save pending upgrades to '{_pendingUpgradesFilePath}'.", .Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
ex); .ToArray();
throw;
if (entries.Length == 0)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
} }
}
} if (entries.Length > 1)
{
public sealed record PendingPluginUpgrade( throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
string PluginId, }
string SourcePackagePath,
string TargetVersion, using var stream = entries[0].Open();
DateTimeOffset CreatedAt) return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
} }
} }

View File

@@ -1,48 +0,0 @@
using System.Diagnostics;
using System.IO;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class CliLauncherUpdateBridge : ILauncherUpdateBridge
{
public Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return Task.FromResult(new LaunchResult(false, "Launcher executable not found.", null));
}
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
UseShellExecute = false,
WorkingDirectory = resolvedLauncherRoot
};
var process = Process.Start(startInfo);
if (process is null)
{
return Task.FromResult(new LaunchResult(false, "Failed to start Launcher process.", null));
}
return Task.FromResult(new LaunchResult(true, null, process.Id));
}
catch (Exception ex)
{
return Task.FromResult(new LaunchResult(false, ex.Message, null));
}
}
public IObservable<InstallProgressReport> ProgressStream => ObservableHelper<InstallProgressReport>.Empty;
public Task<bool> SupportsIpcAsync() => Task.FromResult(false);
}

View File

@@ -1,171 +0,0 @@
using System.Buffers;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Shared.Contracts.Update;
namespace LanMountainDesktop.Services.Update;
internal sealed class IpcLauncherUpdateBridge : ILauncherUpdateBridge, IDisposable
{
private const int LengthPrefixSize = 4;
private const int MaxPayloadLength = 1024 * 1024;
private static readonly TimeSpan PipeConnectTimeout = TimeSpan.FromSeconds(5);
private readonly UpdateProgressSubject _progressSubject = new();
private readonly CancellationTokenSource _cts = new();
private int? _launcherPid;
public IObservable<InstallProgressReport> ProgressStream => _progressSubject;
public async Task<LaunchResult> LaunchInstallerAsync(InstallRequest request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
try
{
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath();
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new LaunchResult(false, "Launcher executable not found.", null);
}
var resolvedLauncherRoot = Path.GetDirectoryName(launcherPath)!;
var startInfo = new ProcessStartInfo
{
FileName = launcherPath,
Arguments = $"apply-update --app-root \"{resolvedLauncherRoot}\" --launch-source {request.LaunchSource ?? "apply-update"}",
UseShellExecute = false,
WorkingDirectory = resolvedLauncherRoot
};
var process = Process.Start(startInfo);
if (process is null)
{
return new LaunchResult(false, "Failed to start Launcher process.", null);
}
_launcherPid = process.Id;
_ = Task.Run(() => ConnectAndReadProgressAsync(process.Id, ct), ct);
return new LaunchResult(true, null, process.Id);
}
catch (Exception ex)
{
return new LaunchResult(false, ex.Message, null);
}
}
public Task<bool> SupportsIpcAsync()
{
return Task.FromResult(true);
}
private async Task ConnectAndReadProgressAsync(int launcherPid, CancellationToken ct)
{
var pipeName = $"LanMountainDesktop_Update_{launcherPid}";
try
{
using var pipe = new NamedPipeClientStream(
".",
pipeName,
PipeDirection.In,
PipeOptions.Asynchronous);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _cts.Token);
using var timeoutCts = new CancellationTokenSource(PipeConnectTimeout);
using var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(linkedCts.Token, timeoutCts.Token);
await pipe.ConnectAsync(combinedCts.Token).ConfigureAwait(false);
await ReadProgressFromPipeAsync(pipe, linkedCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
catch (TimeoutException)
{
}
catch (IOException)
{
}
catch (Exception ex)
{
AppLogger.Warn("IpcLauncherUpdateBridge", $"Progress pipe connection failed (fire-and-forget): {ex.Message}");
}
}
private async Task ReadProgressFromPipeAsync(NamedPipeClientStream pipe, CancellationToken ct)
{
var lengthBuffer = ArrayPool<byte>.Shared.Rent(LengthPrefixSize);
try
{
while (pipe.IsConnected && !ct.IsCancellationRequested)
{
var totalRead = 0;
while (totalRead < LengthPrefixSize)
{
var read = await pipe.ReadAsync(lengthBuffer.AsMemory(totalRead, LengthPrefixSize - totalRead), ct).ConfigureAwait(false);
if (read == 0)
{
return;
}
totalRead += read;
}
var payloadLength = BitConverter.ToInt32(lengthBuffer, 0);
if (payloadLength <= 0 || payloadLength > MaxPayloadLength)
{
return;
}
var payloadBuffer = ArrayPool<byte>.Shared.Rent(payloadLength);
try
{
totalRead = 0;
while (totalRead < payloadLength)
{
var read = await pipe.ReadAsync(payloadBuffer.AsMemory(totalRead, payloadLength - totalRead), ct).ConfigureAwait(false);
if (read == 0)
{
return;
}
totalRead += read;
}
var json = Encoding.UTF8.GetString(payloadBuffer, 0, payloadLength);
var report = JsonSerializer.Deserialize(json, UpdateJsonContext.Default.InstallProgressReport);
if (report is not null)
{
_progressSubject.OnNext(report);
}
}
catch (JsonException)
{
}
finally
{
ArrayPool<byte>.Shared.Return(payloadBuffer);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(lengthBuffer);
}
}
public void Dispose()
{
_cts.Cancel();
_progressSubject.OnCompleted();
_cts.Dispose();
}
}

View File

@@ -139,7 +139,7 @@ public sealed partial class PluginCatalogItemViewModel : ViewModelBase
RefreshActionPresentation(); RefreshActionPresentation();
} }
public void ApplyInstallState(InstalledPluginInfo? installedPlugin, Version? hostVersion) public void ApplyInstallState(InstalledPluginInfo? installedPlugin, Version? hostVersion, bool hasPendingRestart = false)
{ {
var isCompatible = hostVersion is null var isCompatible = hostVersion is null
|| !System.Version.TryParse(MinHostVersion, out var minHostVersion) || !System.Version.TryParse(MinHostVersion, out var minHostVersion)
@@ -147,10 +147,11 @@ public sealed partial class PluginCatalogItemViewModel : ViewModelBase
var isInstalled = installedPlugin is not null; var isInstalled = installedPlugin is not null;
var isUpdateAvailable = installedPlugin is not null && CompareVersions(Version, installedPlugin.Manifest.Version) > 0; var isUpdateAvailable = installedPlugin is not null && CompareVersions(Version, installedPlugin.Manifest.Version) > 0;
var requiresRestart = installedPlugin is not null && var requiresRestart = hasPendingRestart ||
(installedPlugin is not null &&
installedPlugin.IsEnabled && installedPlugin.IsEnabled &&
!installedPlugin.IsLoaded && !installedPlugin.IsLoaded &&
string.IsNullOrWhiteSpace(installedPlugin.ErrorMessage); string.IsNullOrWhiteSpace(installedPlugin.ErrorMessage));
IsCompatibleWithHost = isCompatible; IsCompatibleWithHost = isCompatible;
IsInstalled = isInstalled; IsInstalled = isInstalled;
@@ -384,6 +385,7 @@ public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
private readonly AirAppMarketReadmeService _readmeService; private readonly AirAppMarketReadmeService _readmeService;
private readonly string _languageCode; private readonly string _languageCode;
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _pendingRestartPluginIds = new(StringComparer.OrdinalIgnoreCase);
private readonly Version? _hostVersion; private readonly Version? _hostVersion;
private bool _isInitialized; private bool _isInitialized;
private bool _hasLoadedCatalog; private bool _hasLoadedCatalog;
@@ -572,6 +574,7 @@ public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
var result = await _pluginCatalog.InstallAsync(item.PluginId); var result = await _pluginCatalog.InstallAsync(item.PluginId);
if (result.Success) if (result.Success)
{ {
_pendingRestartPluginIds.Add(result.PluginId ?? item.PluginId);
RefreshInstalledSnapshot(); RefreshInstalledSnapshot();
RefreshItemStates(); RefreshItemStates();
@@ -579,7 +582,7 @@ public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
var pluginName = result.PluginName ?? item.Name; var pluginName = result.PluginName ?? item.Name;
StatusMessage = string.Format( StatusMessage = string.Format(
CultureInfo.CurrentCulture, CultureInfo.CurrentCulture,
L("market.status.install_success_restart_format", "Plugin '{0}' installed successfully! Please restart the application to activate it."), L("market.status.install_success_restart_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
pluginName); pluginName);
// 触发重启提醒 // 触发重启提醒
@@ -616,7 +619,10 @@ public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
{ {
foreach (var item in CatalogPlugins) foreach (var item in CatalogPlugins)
{ {
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion); item.ApplyInstallState(
ResolveInstalledPlugin(item.PluginId),
_hostVersion,
_pendingRestartPluginIds.Contains(item.PluginId));
} }
ApplyFilter(); ApplyFilter();

View File

@@ -50,6 +50,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
private AirAppMarketIndexDocument? _document; private AirAppMarketIndexDocument? _document;
private AirAppMarketPluginEntry? _selectedPlugin; private AirAppMarketPluginEntry? _selectedPlugin;
private Dictionary<string, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase); private Dictionary<string, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _pendingRestartPluginIds = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _readmeContents = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, string> _readmeContents = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _readmeErrors = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, string> _readmeErrors = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Bitmap?> _iconBitmaps = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, Bitmap?> _iconBitmaps = new(StringComparer.OrdinalIgnoreCase);
@@ -717,7 +718,9 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
Content = isThisInstalling Content = isThisInstalling
? T("market.button.installing", "Installing...") ? T("market.button.installing", "Installing...")
: T(ButtonKey(installState), ButtonFallback(installState)), : T(ButtonKey(installState), ButtonFallback(installState)),
IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed, IsEnabled = !_isInstalling &&
isCompatible &&
installState is not AirAppMarketInstallState.Installed and not AirAppMarketInstallState.RestartRequired,
MinWidth = minWidth, MinWidth = minWidth,
HorizontalAlignment = HorizontalAlignment.Right HorizontalAlignment = HorizontalAlignment.Right
}; };
@@ -787,12 +790,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
if (result.RestartRequired) if (result.RestartRequired)
{ {
_pendingRestartPluginIds.Add(result.Manifest.Id);
SetStatus( SetStatus(
F( F(
"market.status.upgrade_staged_format", "market.status.install_success_format",
"Plugin '{0}' v{1} has been downloaded. Restart to complete the upgrade.", "Plugin '{0}' has been staged. Restart the app to apply it.",
result.Manifest.Name, result.Manifest.Name),
result.Manifest.Version),
WarningBrush); WarningBrush);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true); PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
} }
@@ -1001,6 +1004,12 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
AirAppMarketPluginEntry plugin, AirAppMarketPluginEntry plugin,
out PluginCatalogEntry? installedPlugin) out PluginCatalogEntry? installedPlugin)
{ {
if (_pendingRestartPluginIds.Contains(plugin.Id))
{
installedPlugin = null;
return AirAppMarketInstallState.RestartRequired;
}
if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin)) if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin))
{ {
return AirAppMarketInstallState.NotInstalled; return AirAppMarketInstallState.NotInstalled;
@@ -1268,6 +1277,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
{ {
return state switch return state switch
{ {
AirAppMarketInstallState.RestartRequired => "market.detail.state.restart_required",
AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available", AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available",
AirAppMarketInstallState.Installed => "market.detail.state.installed", AirAppMarketInstallState.Installed => "market.detail.state.installed",
_ => "market.detail.state.not_installed" _ => "market.detail.state.not_installed"
@@ -1278,6 +1288,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
{ {
return state switch return state switch
{ {
AirAppMarketInstallState.RestartRequired => "Restart required",
AirAppMarketInstallState.UpdateAvailable => "Update available", AirAppMarketInstallState.UpdateAvailable => "Update available",
AirAppMarketInstallState.Installed => "Installed", AirAppMarketInstallState.Installed => "Installed",
_ => "Not installed" _ => "Not installed"
@@ -1288,6 +1299,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
{ {
return state switch return state switch
{ {
AirAppMarketInstallState.RestartRequired => "market.button.restart",
AirAppMarketInstallState.UpdateAvailable => "market.button.update", AirAppMarketInstallState.UpdateAvailable => "market.button.update",
AirAppMarketInstallState.Installed => "market.button.installed", AirAppMarketInstallState.Installed => "market.button.installed",
_ => "market.button.install" _ => "market.button.install"
@@ -1298,6 +1310,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
{ {
return state switch return state switch
{ {
AirAppMarketInstallState.RestartRequired => "Restart to apply",
AirAppMarketInstallState.UpdateAvailable => "Update", AirAppMarketInstallState.UpdateAvailable => "Update",
AirAppMarketInstallState.Installed => "Installed", AirAppMarketInstallState.Installed => "Installed",
_ => "Install" _ => "Install"

View File

@@ -15,7 +15,6 @@ namespace LanMountainDesktop.Services.PluginMarket;
internal sealed class AirAppMarketInstallService : IDisposable internal sealed class AirAppMarketInstallService : IDisposable
{ {
private readonly PluginRuntimeService _runtime; private readonly PluginRuntimeService _runtime;
private readonly LauncherClient _launcherClient = new();
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService; private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService; private readonly AirAppMarketReleaseResolverService _releaseResolverService;
@@ -65,87 +64,64 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new AirAppMarketInstallResult(false, null, compatibilityError); return new AirAppMarketInstallResult(false, null, compatibilityError);
} }
var isUpgrade = IsPluginInstalled(plugin.Id); return await StageInstallOrUpgradeAsync(
if (isUpgrade) plugin,
{ sources,
return await InstallUpgradeAsync(plugin, sources, cancellationToken).ConfigureAwait(false); IsPluginInstalled(plugin.Id),
cancellationToken).ConfigureAwait(false);
} }
return await InstallNewAsync(plugin, sources, cancellationToken).ConfigureAwait(false); private async Task<AirAppMarketInstallResult> StageInstallOrUpgradeAsync(
}
private async Task<AirAppMarketInstallResult> InstallNewAsync(
AirAppMarketPluginEntry plugin, AirAppMarketPluginEntry plugin,
IReadOnlyList<AirAppMarketPluginPackageSourceEntry> sources, IReadOnlyList<AirAppMarketPluginPackageSourceEntry> sources,
bool isUpgrade,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (OperatingSystem.IsWindows()) AppLogger.Info(
{ "PluginMarket",
var launcherPath = LauncherPathResolver.ResolveLauncherExecutablePath(); $"Detected {(isUpgrade ? "upgrade" : "new install")} scenario. Downloading package for deferred install. PluginId='{plugin.Id}'.");
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
{
return new AirAppMarketInstallResult(
false,
null,
"Launcher executable was not found. Expected it to be located in the application root directory (sibling to the app-* deployment folder).");
}
}
var sourceErrors = new List<string>(); var sourceErrors = new List<string>();
foreach (var source in sources) foreach (var source in sources)
{ {
var attemptResult = await TryInstallFromSourceAsync(plugin, source, cancellationToken).ConfigureAwait(false); var downloadResult = await DownloadPackageAsync(plugin, source, cancellationToken).ConfigureAwait(false);
if (attemptResult.Success) if (!downloadResult.Success || string.IsNullOrWhiteSpace(downloadResult.PackagePath))
{ {
return new AirAppMarketInstallResult(true, attemptResult.Manifest, null); if (!string.IsNullOrWhiteSpace(downloadResult.ErrorMessage))
{
sourceErrors.Add($"{source.SourceKind}: {downloadResult.ErrorMessage}");
} }
if (attemptResult.Fatal) continue;
{
return new AirAppMarketInstallResult(false, null, attemptResult.ErrorMessage);
} }
if (!string.IsNullOrWhiteSpace(attemptResult.ErrorMessage)) try
{ {
sourceErrors.Add($"{source.SourceKind}: {attemptResult.ErrorMessage}"); var manifest = ReadManifestFromPackage(downloadResult.PackagePath);
_pendingUpgradeService.AddPendingInstallOrUpgrade(
manifest.Id,
downloadResult.PackagePath,
manifest.Version ?? plugin.Version);
AppLogger.Info(
"PluginMarket",
$"Plugin package queued for next restart. PluginId='{manifest.Id}'; Version='{manifest.Version ?? plugin.Version}'; PackagePath='{downloadResult.PackagePath}'; IsUpgrade={isUpgrade}.");
return new AirAppMarketInstallResult(true, manifest, null, RestartRequired: true);
}
catch (Exception ex)
{
TryDeleteFile(downloadResult.PackagePath);
sourceErrors.Add($"{source.SourceKind}: {ex.Message}");
} }
} }
var combinedMessage = sourceErrors.Count == 0 var combinedMessage = sourceErrors.Count == 0
? $"Failed to install plugin '{plugin.Id}' from all available package sources." ? $"Failed to stage plugin '{plugin.Id}' from all available package sources."
: $"Failed to install plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}"; : $"Failed to stage plugin '{plugin.Id}' from all available package sources. {string.Join(" ", sourceErrors)}";
return new AirAppMarketInstallResult(false, null, combinedMessage); return new AirAppMarketInstallResult(false, null, combinedMessage);
} }
private async Task<AirAppMarketInstallResult> InstallUpgradeAsync(
AirAppMarketPluginEntry plugin,
IReadOnlyList<AirAppMarketPluginPackageSourceEntry> 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) private bool IsPluginInstalled(string pluginId)
{ {
return _runtime.Catalog.Any(entry => return _runtime.Catalog.Any(entry =>
@@ -199,83 +175,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
return null; return null;
} }
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken = default)
{
var attemptPath = 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.Warn(
"PluginMarket",
$"Resolved package source for '{plugin.Id}' to '{resolvedDownloadUrl}' using '{source.SourceKind}'.");
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, attemptPath, cancellationToken).ConfigureAwait(false);
if (!acquireResult.Success)
{
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, acquireResult.ErrorMessage);
}
var verificationResult = await VerifyPackageAsync(plugin, attemptPath, cancellationToken).ConfigureAwait(false);
if (!verificationResult.Success)
{
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, verificationResult.ErrorMessage);
}
PluginManifest manifest;
if (OperatingSystem.IsWindows())
{
var helperResult = await _launcherClient.InstallPackageAsync(
attemptPath,
_runtime.PluginsDirectory,
cancellationToken).ConfigureAwait(false);
if (!helperResult.Success || string.IsNullOrWhiteSpace(helperResult.InstalledPackagePath))
{
var helperMessage = helperResult.ErrorMessage ?? "Launcher plugin install failed.";
AppLogger.Error(
"PluginMarket",
$"Windows launcher install failed for plugin '{plugin.Id}' from source '{source.SourceKind}'. " +
$"Code='{helperResult.Code}'; Message='{helperMessage}'.");
return new AirAppMarketInstallAttemptResult(false, true, null, helperMessage);
}
manifest = _runtime.RegisterInstalledPluginPackage(helperResult.InstalledPackagePath);
}
else
{
manifest = _runtime.InstallPluginPackage(attemptPath);
}
AppLogger.Info(
"PluginMarket",
$"Install staged successfully. PluginId='{manifest.Id}'; InstalledName='{manifest.Name}'; PackagePath='{attemptPath}'; SourceKind='{source.SourceKind}'.");
return new AirAppMarketInstallAttemptResult(true, true, manifest, null);
}
catch (OperationCanceledException)
{
AppLogger.Warn(
"PluginMarket",
$"Install canceled. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.");
throw;
}
catch (Exception ex)
{
AppLogger.Error(
"PluginMarket",
$"Install attempt failed. PluginId='{plugin.Id}'; Version='{plugin.Version}'; SourceKind='{source.SourceKind}'; DownloadPath='{attemptPath}'.",
ex);
TryDeleteFile(attemptPath);
return new AirAppMarketInstallAttemptResult(false, false, null, ex.Message);
}
}
private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync( private async Task<AirAppMarketAcquisitionResult> AcquirePackageAsync(
AirAppMarketPluginEntry plugin, AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source, AirAppMarketPluginPackageSourceEntry source,
@@ -391,7 +290,7 @@ internal sealed class AirAppMarketInstallService : IDisposable
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false); var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
AppLogger.Info( AppLogger.Info(
"PluginMarket", "PluginMarket",
$"Downloading upgrade package for '{plugin.Id}' from '{resolvedDownloadUrl}'."); $"Downloading package for deferred plugin install. PluginId='{plugin.Id}'; Source='{resolvedDownloadUrl}'.");
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, packagePath, cancellationToken).ConfigureAwait(false); var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, packagePath, cancellationToken).ConfigureAwait(false);
if (!acquireResult.Success) if (!acquireResult.Success)
@@ -453,12 +352,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
} }
private sealed record AirAppMarketInstallAttemptResult(
bool Success,
bool Fatal,
PluginManifest? Manifest,
string? ErrorMessage);
private sealed record AirAppMarketAcquisitionResult( private sealed record AirAppMarketAcquisitionResult(
bool Success, bool Success,
string? ErrorMessage); string? ErrorMessage);

View File

@@ -291,7 +291,8 @@ internal enum AirAppMarketInstallState
{ {
NotInstalled = 0, NotInstalled = 0,
UpdateAvailable = 1, UpdateAvailable = 1,
Installed = 2 Installed = 2,
RestartRequired = 3
} }
internal sealed record AirAppMarketLoadResult( internal sealed record AirAppMarketLoadResult(

View File

@@ -92,8 +92,9 @@ public sealed class PluginRuntimeService : IDisposable
public void LoadInstalledPlugins() public void LoadInstalledPlugins()
{ {
Directory.CreateDirectory(PluginsDirectory); Directory.CreateDirectory(PluginsDirectory);
ApplyPendingPluginDeletions();
UnloadInstalledPlugins(); UnloadInstalledPlugins();
ApplyPendingPluginDeletions();
ApplyPendingPluginOperations();
MergeDevSettingsFromSnapshot(); MergeDevSettingsFromSnapshot();
AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'."); AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'.");
@@ -841,6 +842,27 @@ public sealed class PluginRuntimeService : IDisposable
CleanupPendingDeletionDirectory(); CleanupPendingDeletionDirectory();
} }
private void ApplyPendingPluginOperations()
{
var pendingService = new PendingPluginUpgradeService(PluginsDirectory);
var result = pendingService.ApplyPendingOperations(manifest => _sharedContractManager.EnsureInstalled(manifest));
if (result.SuccessCount == 0 && result.FailureCount == 0)
{
return;
}
AppLogger.Info(
"PluginRuntime",
$"Pending plugin operations applied before discovery. Success={result.SuccessCount}; Failure={result.FailureCount}; PluginsDirectory='{PluginsDirectory}'.");
foreach (var failure in result.Failures)
{
AppLogger.Warn(
"PluginRuntime",
$"Pending plugin operation failed and will remain queued. PluginId='{failure.PluginId}'; Operation='{failure.Operation}'; Error='{failure.ErrorMessage}'.");
}
}
private void CleanupPendingDeletionDirectory() private void CleanupPendingDeletionDirectory()
{ {
var pendingDeletionDir = Path.Combine(PluginsDirectory, ".pending-deletions"); var pendingDeletionDir = Path.Combine(PluginsDirectory, ".pending-deletions");

View File

@@ -27,9 +27,8 @@
3. 首次启动显示 OOBE 引导 (`OobeWindow`) 3. 首次启动显示 OOBE 引导 (`OobeWindow`)
4. 显示 Splash 启动动画 (`SplashWindow`) 4. 显示 Splash 启动动画 (`SplashWindow`)
5. 检查并应用待处理的更新 (`UpdateEngineService.ApplyPendingUpdate`) 5. 检查并应用待处理的更新 (`UpdateEngineService.ApplyPendingUpdate`)
6. 处理插件升级队列 (`PluginUpgradeQueueService`) 6. 启动主程序 `app-{version}/LanMountainDesktop.exe`(待处理插件安装/升级由 Host 在 `PluginRuntimeService.ApplyPendingPluginOperations()` 中应用,而非 Launcher 启动流程)
7. 启动主程序 `app-{version}/LanMountainDesktop.exe` 7. 清理标记为 `.destroy` 的旧版本
8. 清理标记为 `.destroy` 的旧版本
**主程序启动流程 (LanMountainDesktop.exe):** **主程序启动流程 (LanMountainDesktop.exe):**
@@ -98,12 +97,11 @@
| 服务 | 职责 | | 服务 | 职责 |
|------|------| |------|------|
| `DeploymentLocator` | 扫描和定位 `app-*` 版本目录,选择最佳版本 | | `DeploymentLocator` | 扫描和定位 `app-*` 版本目录,选择最佳版本 |
| `UpdateCheckService` | 调用 GitHub Release API 检查更新,支持 Stable/Preview 频道 |
| `UpdateEngineService` | 下载、验证、应用增量更新,支持原子化更新和回滚 | | `UpdateEngineService` | 下载、验证、应用增量更新,支持原子化更新和回滚 |
| `LauncherFlowCoordinator` | 协调 OOBE → Splash → 更新 → 插件 → 启动主程序的完整流程 | | `LauncherFlowCoordinator` | 协调 OOBE → Splash → 更新 → 启动主程序的完整流程 |
| `OobeStateService` | 管理首次运行状态 | | `OobeStateService` | 管理首次运行状态 |
| `PluginInstallerService` | 处理 `.laapp` 插件包安装 | | `PluginInstallerService` | CLI 维护:`plugin install` 直接安装 `.laapp` |
| `PluginUpgradeQueueService` | 批量处理插件升级队列 | | `PluginUpgradeQueueService` | CLI 维护:`plugin update` 应用待处理队列(正常市场安装/升级由 Host 处理) |
#### 版本管理机制 #### 版本管理机制
@@ -191,7 +189,7 @@ GitHub Release Assets:
This repository is organized around a desktop host app plus a host-side plugin ecosystem. `LanMountainDesktop/` contains the application entry points, UI, services, component system, and plugin runtime integration. The surrounding projects provide the public SDK, shared contracts, appearance infrastructure, settings primitives, host abstractions, runtime support, and tests. This repository is organized around a desktop host app plus a host-side plugin ecosystem. `LanMountainDesktop/` contains the application entry points, UI, services, component system, and plugin runtime integration. The surrounding projects provide the public SDK, shared contracts, appearance infrastructure, settings primitives, host abstractions, runtime support, and tests.
**Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, incremental updates, and plugin installation. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation. **Launcher Architecture**: `LanMountainDesktop.Launcher/` serves as the single entry point, managing OOBE, splash screen, multi-version deployment, and incremental updates. In-app plugin market installation is Host-owned: packages are downloaded into the current user's pending plugin queue and applied by the Host before plugin discovery on the next startup. The Launcher still keeps plugin CLI commands as maintenance compatibility entry points. It uses a version directory structure (`app-{version}/`) with marker files (`.current`, `.partial`, `.destroy`) to enable atomic updates and rollback capabilities. See the Chinese section above for detailed architecture documentation.
The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence. The runtime flow starts with the Launcher selecting the best version, then proceeds into `Program.cs`, into `App.axaml.cs`, initializes settings/theme/localization services, then boots the desktop shell, tray, windows, and plugin runtime. The most important behavior boundaries are component registration, plugin activation, appearance resources, and settings persistence.
@@ -272,3 +270,4 @@ See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-open OOBE. - `apply-update`, `plugin-install`, and `debug-preview` must not auto-open OOBE.
- Elevation is allowed only for the installer, full installer update application, and user-confirmed legacy uninstall. - Elevation is allowed only for the installer, full installer update application, and user-confirmed legacy uninstall.
- Default plugin install should stay inside the user's LocalAppData scope and should not ask for UAC. - Default plugin install should stay inside the user's LocalAppData scope and should not ask for UAC.
- Marketplace plugin installs are queued under the user's data root and take effect after restart; they do not use Launcher elevation.

View File

@@ -165,6 +165,8 @@ Use `LanMountainDesktop.slnx` as the workspace entry point. The standard loop is
For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`. For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generation or local feed workflows, use `scripts/Pack-PluginPackages.ps1`.
In-app marketplace plugin installs use a per-user pending plugin queue. The package is downloaded and verified immediately, then applied on the next Host startup before plugin discovery. `LanMountainDesktop.Launcher.exe plugin install` remains only as a maintenance compatibility command.
**Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation. **Launcher Architecture**: LanMountainDesktop uses a Launcher as the single entry point, responsible for version management, updates, and launching the main application. See the Chinese section above for detailed architecture documentation.
## VeloPack Release Assets ## VeloPack Release Assets
@@ -172,4 +174,3 @@ For packaging, see `LanMountainDesktop/PACKAGING.md`. For plugin package generat
- Windows incremental release packaging now uses VeloPack native outputs ( - Windows incremental release packaging now uses VeloPack native outputs (
eleases.win.json, *.nupkg). eleases.win.json, *.nupkg).
- Launcher still performs update apply/rollback; VeloPack is used for package generation. - Launcher still performs update apply/rollback; VeloPack is used for package generation.
- Legacy delta script flow is retained behind a disabled fallback switch in CI.

View File

@@ -191,23 +191,20 @@ void MarkCompleted()
``` ```
### PluginInstallerService ### PluginInstallerService
**职责**: 处理插件安装 **职责**: CLI 维护命令下的插件安装`plugin install`)。应用内插件市场安装由 Host 在启动时应用 pending 队列,不经过 Launcher 正常启动流程。
**关键方法**: **关键方法**:
```csharp ```csharp
// 安装插件包 // 安装插件包CLI 维护)
Task<PluginInstallResult> InstallAsync( LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
string packagePath,
string targetDirectory,
CancellationToken cancellationToken = default)
``` ```
### PluginUpgradeQueueService ### PluginUpgradeQueueService
**职责**: 批量处理插件升级队列 **职责**: CLI 维护命令下的待处理插件升级(`plugin update`。Launcher 正常 GUI 启动流程不再应用 pending 队列Host 在 `PluginRuntimeService.ApplyPendingPluginOperations()` 中统一处理。
**关键方法**: **关键方法**:
```csharp ```csharp
// 应用待处理的插件升级 // 应用待处理的插件升级CLI 维护)
LauncherResult ApplyPendingUpgrades(string pluginsDirectory) LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
``` ```
@@ -381,19 +378,12 @@ public async Task<LauncherResult> RunAsync()
try try
{ {
// 4. 应用更新 // 4. 应用更新
var updateResult = _updateEngine.ApplyPendingUpdate(); var updateResult = await _updateEngine.ApplyPendingUpdateAsync();
if (!updateResult.Success) if (!updateResult.Success)
return updateResult; Logger.Warn("Update apply failed, will try to launch existing version.");
// 5. 插件升级 // 5. 启动主程序(插件 pending 由 Host 应用,不在 Launcher 启动步骤处理)
var pluginsDir = Path.Combine(_deploymentLocator.GetAppRoot(), "plugins"); var hostResult = await LaunchHostWithIpcAsync();
var queueResult = new PluginUpgradeQueueService(_pluginInstallerService)
.ApplyPendingUpgrades(pluginsDir);
if (!queueResult.Success)
return queueResult;
// 6. 启动主程序
var hostResult = LaunchHost();
if (!hostResult.Success) if (!hostResult.Success)
return hostResult; return hostResult;
@@ -454,7 +444,7 @@ LanMountainDesktop.Launcher.exe update rollback
LanMountainDesktop.Launcher.exe plugin install <path-to-plugin.laapp> LanMountainDesktop.Launcher.exe plugin install <path-to-plugin.laapp>
``` ```
安装 `.laapp` 插件包。 维护兼容入口:直接把 `.laapp` 插件包写入指定插件目录。应用内插件市场不再使用 Launcher 做普通插件安装;市场安装会先把包下载到当前用户的 pending 队列,并在下一次 Host 启动、插件发现前应用
## 开发指南 ## 开发指南
@@ -561,6 +551,7 @@ var updateCheckService = new UpdateCheckService(
- `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE. - `apply-update`, `plugin-install`, and `debug-preview` must not auto-enter OOBE.
- Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall. - Allowed elevation paths are limited to the installer itself, full installer update application, and user-confirmed legacy uninstall.
- Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default. - Default plugin installation targets the current user's LocalAppData scope and must not request elevation by default.
- In-app market installs are deferred Host-side operations: download and verify now, apply from the per-user pending queue on the next Host startup.
## Public IPC Baseline ## Public IPC Baseline

View File

@@ -524,6 +524,8 @@ LanMountainDesktop.Launcher.exe plugin install MyAwesomePlugin-1.0.0.laapp
LanMountainDesktop.Launcher.exe launch LanMountainDesktop.Launcher.exe launch
``` ```
应用内插件市场不会调用 Launcher 安装插件。市场安装会把 `.laapp` 下载到当前用户的 pending 队列,并在下一次 Host 启动、插件发现前应用;上面的 Launcher 命令仅作为本地维护/兼容入口保留。
### 4. 发布插件 ### 4. 发布插件
**选项 1: GitHub Release** **选项 1: GitHub Release**