mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
fix. 插件安装修复
This commit is contained in:
@@ -14,7 +14,7 @@ internal static class Commands
|
||||
{
|
||||
var source = context.GetOption("source") ?? string.Empty;
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? string.Empty;
|
||||
result = installer.InstallPackage(source, pluginsDir);
|
||||
result = installer.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -91,12 +91,12 @@ internal static class Commands
|
||||
{
|
||||
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir);
|
||||
return pluginInstaller.InstallPackage(source, pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
case "update":
|
||||
{
|
||||
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir);
|
||||
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
|
||||
}
|
||||
default:
|
||||
return new LauncherResult
|
||||
|
||||
@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
|
||||
|
||||
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
|
||||
{
|
||||
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath)
|
||||
? Path.GetFullPath(customPath)
|
||||
var targetDataRoot = mode == DataLocationMode.Portable
|
||||
? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
|
||||
? customPath
|
||||
: DefaultPortableDataPath)
|
||||
: _defaultSystemDataPath;
|
||||
|
||||
var config = new DataLocationConfig
|
||||
|
||||
@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory)
|
||||
public LauncherResult InstallPackage(string sourcePath, string pluginsDirectory, string? appRoot = null)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult)
|
||||
if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
|
||||
{
|
||||
return elevationRequiredResult;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ internal sealed class PluginInstallerService
|
||||
};
|
||||
}
|
||||
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory)
|
||||
private static LauncherResult? TryBuildElevationRequiredResult(string pluginsDirectory, string? appRoot)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
|
||||
string? allowedRoot = null;
|
||||
try
|
||||
{
|
||||
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(appRoot);
|
||||
var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
|
||||
? Path.GetFullPath(appRoot)
|
||||
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
|
||||
var resolver = new DataLocationResolver(resolvedAppRoot);
|
||||
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
_installerService = installerService;
|
||||
}
|
||||
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory)
|
||||
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
|
||||
{
|
||||
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
|
||||
if (!File.Exists(pendingPath))
|
||||
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
|
||||
|
||||
try
|
||||
{
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory);
|
||||
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
|
||||
succeeded.Add(item);
|
||||
}
|
||||
catch
|
||||
|
||||
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal file
35
LanMountainDesktop.Tests/DataLocationResolverTests.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DataLocationResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _appRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.Tests",
|
||||
nameof(DataLocationResolverTests),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void ApplyLocationChoice_PortableWithoutCustomPath_UsesAppRootDesktopDirectory()
|
||||
{
|
||||
Directory.CreateDirectory(_appRoot);
|
||||
var resolver = new DataLocationResolver(_appRoot);
|
||||
|
||||
var applied = resolver.ApplyLocationChoice(DataLocationMode.Portable);
|
||||
|
||||
Assert.True(applied);
|
||||
Assert.Equal(
|
||||
Path.Combine(Path.GetFullPath(_appRoot), "Desktop"),
|
||||
resolver.ResolveDataRoot());
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_appRoot))
|
||||
{
|
||||
Directory.Delete(_appRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using LanMountainDesktop.Launcher.Plugins;
|
||||
using System.IO.Compression;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_AllowsConfiguredPortableDataRootOutsideUserScope()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
var appRoot = Path.Combine(_tempRoot, "PackageRoot");
|
||||
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||
Directory.CreateDirectory(launcherDataRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
DataLocationMode = "Portable",
|
||||
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||
PortableDataPath = portableDataRoot
|
||||
}));
|
||||
|
||||
var packagePath = Path.Combine(_tempRoot, "portable.laapp");
|
||||
CreatePluginPackage(packagePath, "plugin.json", "plugin.portable.sample", "Portable Plugin");
|
||||
|
||||
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("ok", result.Code);
|
||||
Assert.True(File.Exists(result.InstalledPackagePath));
|
||||
Assert.StartsWith(Path.GetFullPath(portableDataRoot), Path.GetFullPath(result.InstalledPackagePath!), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
||||
{
|
||||
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
||||
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
||||
var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
|
||||
var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(first.Success);
|
||||
Assert.True(second.Success);
|
||||
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
Directory.CreateDirectory(_tempRoot);
|
||||
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
||||
|
||||
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
||||
var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
|
||||
var service = new PluginInstallerService();
|
||||
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
||||
var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
||||
@@ -103,18 +140,24 @@ public sealed class PluginInstallerServiceTests : IDisposable
|
||||
""");
|
||||
}
|
||||
|
||||
private static string CreateUserScopedPluginsDirectory()
|
||||
private string CreateConfiguredPortablePluginsDirectory(out string appRoot)
|
||||
{
|
||||
var root = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Tests",
|
||||
nameof(PluginInstallerServiceTests),
|
||||
Guid.NewGuid().ToString("N"),
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
Directory.CreateDirectory(root);
|
||||
return root;
|
||||
appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
|
||||
var portableDataRoot = Path.Combine(appRoot, "Desktop");
|
||||
var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
|
||||
Directory.CreateDirectory(launcherDataRoot);
|
||||
File.WriteAllText(
|
||||
Path.Combine(launcherDataRoot, "data-location.config.json"),
|
||||
JsonSerializer.Serialize(new
|
||||
{
|
||||
DataLocationMode = "Portable",
|
||||
SystemDataPath = Path.Combine(_tempRoot, "System"),
|
||||
PortableDataPath = portableDataRoot
|
||||
}));
|
||||
|
||||
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
|
||||
Directory.CreateDirectory(pluginsDirectory);
|
||||
return pluginsDirectory;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal file
40
LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginRuntimeDataPathTests : IDisposable
|
||||
{
|
||||
private readonly string _dataRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.Tests",
|
||||
nameof(PluginRuntimeDataPathTests),
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
[Fact]
|
||||
public void PluginRuntime_UsesHostDataRootForPluginsAndMarketData()
|
||||
{
|
||||
AppDataPathProvider.Initialize(["--data-root", _dataRoot]);
|
||||
|
||||
using var runtime = new PluginRuntimeService();
|
||||
|
||||
Assert.Equal(
|
||||
Path.Combine(Path.GetFullPath(_dataRoot), "Extensions", "Plugins"),
|
||||
runtime.PluginsDirectory);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AppDataPathProvider.ResetForTests();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ public static class AppDataPathProvider
|
||||
return Path.Combine(GetDataRoot(), "Wallpapers");
|
||||
}
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
_overriddenDataRoot = null;
|
||||
}
|
||||
|
||||
private static string? ResolveDataRootFromArgs(string[] args)
|
||||
{
|
||||
const string prefix = "--data-root=";
|
||||
|
||||
237
LanMountainDesktop/Services/ElevatedPluginInstallService.cs
Normal file
237
LanMountainDesktop/Services/ElevatedPluginInstallService.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed record ElevatedPluginInstallResult(
|
||||
bool Success,
|
||||
string? Code,
|
||||
string? Message,
|
||||
string? ErrorMessage,
|
||||
string? InstalledPackagePath,
|
||||
string? ManifestId,
|
||||
string? ManifestName);
|
||||
|
||||
internal sealed class ElevatedPluginInstallService
|
||||
{
|
||||
public async Task<ElevatedPluginInstallResult> InstallAsync(
|
||||
string sourcePackagePath,
|
||||
string pluginsDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"elevation_unsupported",
|
||||
"Elevated plugin installation is only supported on Windows.",
|
||||
"Elevated plugin installation is only supported on Windows.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
var launcherPath = ResolveLauncherExecutablePath();
|
||||
if (string.IsNullOrWhiteSpace(launcherPath) || !File.Exists(launcherPath))
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"launcher_not_found",
|
||||
"Launcher executable was not found for elevated plugin installation.",
|
||||
$"Launcher executable was not found. ResolvedPath='{launcherPath ?? string.Empty}'.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
var resultPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"LanMountainDesktop.PluginInstall.{Guid.NewGuid():N}.json");
|
||||
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = launcherPath,
|
||||
UseShellExecute = true,
|
||||
Verb = "runas",
|
||||
WorkingDirectory = Path.GetDirectoryName(launcherPath) ?? AppContext.BaseDirectory
|
||||
};
|
||||
startInfo.ArgumentList.Add("plugin");
|
||||
startInfo.ArgumentList.Add("install");
|
||||
startInfo.ArgumentList.Add("--source");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(sourcePackagePath));
|
||||
startInfo.ArgumentList.Add("--plugins-dir");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(pluginsDirectory));
|
||||
startInfo.ArgumentList.Add("--result");
|
||||
startInfo.ArgumentList.Add(resultPath);
|
||||
|
||||
var packageRoot = LauncherRuntimeMetadata.GetPackageRoot();
|
||||
if (!string.IsNullOrWhiteSpace(packageRoot))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--app-root");
|
||||
startInfo.ArgumentList.Add(Path.GetFullPath(packageRoot));
|
||||
}
|
||||
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"launch_failed",
|
||||
"Elevated plugin installer did not start.",
|
||||
"Elevated plugin installer did not start.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (File.Exists(resultPath))
|
||||
{
|
||||
return ReadResult(resultPath);
|
||||
}
|
||||
|
||||
return new ElevatedPluginInstallResult(
|
||||
process.ExitCode == 0,
|
||||
process.ExitCode == 0 ? "ok" : "installer_failed",
|
||||
process.ExitCode == 0 ? "Plugin installed." : $"Elevated installer exited with code {process.ExitCode}.",
|
||||
process.ExitCode == 0 ? null : $"Elevated installer exited with code {process.ExitCode}.",
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"elevation_cancelled",
|
||||
"Plugin installation was cancelled before elevation was approved.",
|
||||
ex.Message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"elevation_failed",
|
||||
"Elevated plugin installation failed.",
|
||||
ex.Message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDelete(resultPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElevatedPluginInstallResult ReadResult(string resultPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(resultPath));
|
||||
var root = document.RootElement;
|
||||
return new ElevatedPluginInstallResult(
|
||||
GetBoolean(root, "Success"),
|
||||
GetString(root, "Code"),
|
||||
GetString(root, "Message"),
|
||||
GetString(root, "ErrorMessage"),
|
||||
GetString(root, "InstalledPackagePath"),
|
||||
GetString(root, "ManifestId"),
|
||||
GetString(root, "ManifestName"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ElevatedPluginInstallResult(
|
||||
false,
|
||||
"invalid_result",
|
||||
"Elevated plugin installer returned an invalid result.",
|
||||
ex.Message,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ResolveLauncherExecutablePath()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
LauncherRuntimeMetadata.GetPackageRoot(),
|
||||
AppContext.BaseDirectory,
|
||||
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, ".."))
|
||||
};
|
||||
|
||||
foreach (var root in candidates.Where(candidate => !string.IsNullOrWhiteSpace(candidate)))
|
||||
{
|
||||
var path = Path.Combine(root!, OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.Launcher.exe"
|
||||
: "LanMountainDesktop.Launcher");
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool GetBoolean(JsonElement element, string propertyName)
|
||||
{
|
||||
return TryGetProperty(element, propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
return TryGetProperty(element, propertyName, out var property) &&
|
||||
property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement property)
|
||||
{
|
||||
foreach (var candidate in element.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(candidate.Name, propertyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
property = candidate.Value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
property = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
|
||||
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
{
|
||||
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
|
||||
|
||||
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
||||
{
|
||||
App? app = null;
|
||||
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HasPendingPluginUpgrades())
|
||||
{
|
||||
return TryRestartWithUpgradeHelper(request);
|
||||
}
|
||||
|
||||
return TryRestartDirectly(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -68,61 +59,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasPendingPluginUpgrades()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
|
||||
return File.Exists(pendingUpgradesPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
|
||||
|
||||
var helperPath = ResolveUpgradeHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
|
||||
return TryRestartDirectly(request);
|
||||
}
|
||||
|
||||
var pluginsDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"Extensions",
|
||||
"Plugins");
|
||||
|
||||
var app = Application.Current as App;
|
||||
var restartPresentationMode = app?.GetCurrentRestartPresentationMode() ?? RestartPresentationMode.Foreground;
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo(restartPresentationMode: restartPresentationMode);
|
||||
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
|
||||
var launchArgs = startInfo?.Arguments ?? "";
|
||||
|
||||
var helperStartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = AppContext.BaseDirectory
|
||||
};
|
||||
|
||||
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
|
||||
|
||||
Process.Start(helperStartInfo);
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, request) == true;
|
||||
}
|
||||
|
||||
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
|
||||
{
|
||||
var app = Application.Current as App;
|
||||
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
|
||||
}
|
||||
|
||||
private static string ResolveUpgradeHelperPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
|
||||
}
|
||||
}
|
||||
|
||||
28
LanMountainDesktop/Services/PluginInstallTargetAccess.cs
Normal file
28
LanMountainDesktop/Services/PluginInstallTargetAccess.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class PluginInstallTargetAccess
|
||||
{
|
||||
public static bool CanWriteDirectory(string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var probePath = Path.Combine(directory, $".write-test-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(probePath, string.Empty);
|
||||
File.Delete(probePath);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
||||
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
||||
{
|
||||
_runtime = runtime;
|
||||
var dataDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"AirAppMarket");
|
||||
var dataDirectory = AppDataPathProvider.GetPluginMarketDirectory();
|
||||
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
|
||||
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
|
||||
_readmeService = new AirAppMarketReadmeService();
|
||||
|
||||
@@ -19,13 +19,14 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
private readonly ResumableDownloadService _downloadService;
|
||||
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
|
||||
private readonly PendingPluginUpgradeService _pendingUpgradeService;
|
||||
private readonly ElevatedPluginInstallService _elevatedInstallService = new();
|
||||
private readonly string _downloadsDirectory;
|
||||
private readonly Version? _hostVersion;
|
||||
|
||||
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
|
||||
_downloadsDirectory = ResolveDownloadsDirectory(dataDirectory);
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
@@ -77,9 +78,10 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
bool isUpgrade,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var canWritePluginsDirectory = PluginInstallTargetAccess.CanWriteDirectory(_runtime.PluginsDirectory);
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Detected {(isUpgrade ? "upgrade" : "new install")} scenario. Downloading package for deferred install. PluginId='{plugin.Id}'.");
|
||||
$"Detected {(isUpgrade ? "upgrade" : "new install")} scenario. Downloading package for {(canWritePluginsDirectory ? "deferred" : "elevated")} install. PluginId='{plugin.Id}'; PluginsDirectory='{_runtime.PluginsDirectory}'; CanWritePluginsDirectory={canWritePluginsDirectory}.");
|
||||
|
||||
var sourceErrors = new List<string>();
|
||||
foreach (var source in sources)
|
||||
@@ -98,6 +100,25 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
try
|
||||
{
|
||||
var manifest = ReadManifestFromPackage(downloadResult.PackagePath);
|
||||
if (!canWritePluginsDirectory)
|
||||
{
|
||||
var elevatedResult = await _elevatedInstallService.InstallAsync(
|
||||
downloadResult.PackagePath,
|
||||
_runtime.PluginsDirectory,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
if (!elevatedResult.Success)
|
||||
{
|
||||
sourceErrors.Add($"{source.SourceKind}: {elevatedResult.ErrorMessage ?? elevatedResult.Message ?? elevatedResult.Code ?? "Elevated install failed."}");
|
||||
continue;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"PluginMarket",
|
||||
$"Plugin package installed through elevated installer. PluginId='{manifest.Id}'; Version='{manifest.Version ?? plugin.Version}'; PackagePath='{downloadResult.PackagePath}'; IsUpgrade={isUpgrade}.");
|
||||
|
||||
return new AirAppMarketInstallResult(true, manifest, null, RestartRequired: true);
|
||||
}
|
||||
|
||||
_pendingUpgradeService.AddPendingInstallOrUpgrade(
|
||||
manifest.Id,
|
||||
downloadResult.PackagePath,
|
||||
@@ -276,6 +297,21 @@ internal sealed class AirAppMarketInstallService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveDownloadsDirectory(string dataDirectory)
|
||||
{
|
||||
var preferred = Path.Combine(dataDirectory, "downloads");
|
||||
if (PluginInstallTargetAccess.CanWriteDirectory(preferred))
|
||||
{
|
||||
return preferred;
|
||||
}
|
||||
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var fallbackRoot = string.IsNullOrWhiteSpace(localAppData)
|
||||
? Path.GetTempPath()
|
||||
: Path.Combine(localAppData, "LanMountainDesktop");
|
||||
return Path.Combine(fallbackRoot, "PluginMarket", "downloads");
|
||||
}
|
||||
|
||||
private async Task<DownloadPackageResult> DownloadPackageAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
AirAppMarketPluginPackageSourceEntry source,
|
||||
|
||||
@@ -46,9 +46,10 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
ISettingsFacadeService? settingsFacade = null,
|
||||
PublicIpcHostService? publicIpcHostService = null)
|
||||
{
|
||||
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
|
||||
var dataRoot = AppDataPathProvider.GetDataRoot();
|
||||
PluginsDirectory = Path.Combine(dataRoot, "Extensions", "Plugins");
|
||||
_sharedContractManager = new PluginSharedContractManager(
|
||||
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
||||
AppDataPathProvider.GetPluginMarketDirectory());
|
||||
_packageManager = new PluginRuntimePackageManager(this);
|
||||
_settingsFacade = settingsFacade ?? new SettingsFacadeService();
|
||||
_publicIpcHostService = publicIpcHostService;
|
||||
@@ -388,9 +389,15 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
if (!PluginInstallTargetAccess.CanWriteDirectory(PluginsDirectory))
|
||||
{
|
||||
return InstallPluginPackageWithElevation(fullPackagePath, manifest, destinationPath);
|
||||
}
|
||||
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime");
|
||||
@@ -405,6 +412,41 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||
}
|
||||
|
||||
private PluginPackageInstallResult InstallPluginPackageWithElevation(
|
||||
string fullPackagePath,
|
||||
PluginManifest manifest,
|
||||
string destinationPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
$"Plugin directory '{PluginsDirectory}' is not writable by the current process.");
|
||||
}
|
||||
|
||||
var elevatedResult = new ElevatedPluginInstallService()
|
||||
.InstallAsync(fullPackagePath, PluginsDirectory, CancellationToken.None)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
if (!elevatedResult.Success)
|
||||
{
|
||||
throw new UnauthorizedAccessException(
|
||||
elevatedResult.ErrorMessage ??
|
||||
elevatedResult.Message ??
|
||||
$"Elevated plugin install failed with code '{elevatedResult.Code ?? "unknown"}'.");
|
||||
}
|
||||
|
||||
var installedPath = !string.IsNullOrWhiteSpace(elevatedResult.InstalledPackagePath)
|
||||
? elevatedResult.InstalledPackagePath
|
||||
: destinationPath;
|
||||
UpdateCatalogAfterPackageInstall(manifest, installedPath);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
AppLogger.Info(
|
||||
"PluginRuntime",
|
||||
$"Package staged through elevated installer. PluginId='{manifest.Id}'; Destination='{installedPath}'.");
|
||||
|
||||
return new PluginPackageInstallResult(manifest, ReplacedExisting: false, RestartRequired: true);
|
||||
}
|
||||
|
||||
private PluginManifest RegisterInstalledPluginPackageCore(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
@@ -694,17 +736,6 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private static string GetUserDataRootDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "LanMountainDesktop");
|
||||
}
|
||||
|
||||
private static PluginLoaderOptions CreateOptions()
|
||||
{
|
||||
var devOptions = DevPluginOptions.Current;
|
||||
|
||||
@@ -257,13 +257,7 @@ internal sealed class PluginSharedContractManager : IDisposable
|
||||
|
||||
private static string GetSharedContractRootDirectory()
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
if (string.IsNullOrWhiteSpace(localAppData))
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "Data");
|
||||
}
|
||||
|
||||
return Path.Combine(localAppData, "LanMountainDesktop");
|
||||
return AppDataPathProvider.GetDataRoot();
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
|
||||
@@ -264,5 +264,6 @@ See `docs/EXTERNAL_IPC_ARCHITECTURE.md` for the detailed contract and migration
|
||||
- `postinstall` may show OOBE only when the launcher is not elevated.
|
||||
- `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.
|
||||
- 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.
|
||||
- Default plugin install targets the Host data root (`AppDataPathProvider.GetDataRoot()/Extensions/Plugins`) and should not ask for UAC when that directory is writable.
|
||||
- In portable data mode, plugin packages follow the configured application data root. If that root is under an administrator-protected install path, Host downloads/verifies the package from a user-writable staging directory and invokes the restricted Launcher `plugin install` command with UAC to copy only into the configured data root.
|
||||
- Marketplace plugin installs are queued under the Host data root when writable and take effect after restart; protected portable installs are applied immediately through the elevated maintenance command and still require restart before loading.
|
||||
|
||||
@@ -161,7 +161,7 @@ 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`.
|
||||
|
||||
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.
|
||||
In-app marketplace plugin installs use the Host data root. When `Extensions/Plugins` is writable, the package is downloaded and verified immediately, then queued and applied on the next Host startup before plugin discovery. When portable data lives under an administrator-protected install path, Host stages the download in a user-writable location and invokes the restricted `LanMountainDesktop.Launcher.exe plugin install --app-root <package-root>` maintenance command with UAC to copy into the configured data root.
|
||||
|
||||
**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.
|
||||
|
||||
|
||||
@@ -444,8 +444,10 @@ _oobeSteps = [
|
||||
- `postinstall` may open OOBE only when the launcher is not elevated and the user state path is available.
|
||||
- `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.
|
||||
- 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.
|
||||
- Default plugin installation targets the Host data root and must not request elevation when that directory is writable.
|
||||
- The Launcher `plugin install` maintenance command accepts `--app-root` so it can verify the configured data root before writing. It rejects targets outside that root.
|
||||
- In-app market installs are deferred Host-side operations when the data root is writable: download and verify now, apply from the pending queue on the next Host startup.
|
||||
- If portable data is configured under an administrator-protected install path, Host stages the package in a user-writable download directory and invokes the restricted Launcher maintenance command with UAC to copy the package into `Extensions/Plugins`.
|
||||
|
||||
## Public IPC Baseline
|
||||
|
||||
|
||||
Reference in New Issue
Block a user