fix. 插件安装修复

This commit is contained in:
lincube
2026-06-01 01:12:52 +08:00
parent c351a8e7f3
commit a2ac302ee7
18 changed files with 515 additions and 130 deletions

View File

@@ -14,7 +14,7 @@ internal static class Commands
{ {
var source = context.GetOption("source") ?? string.Empty; var source = context.GetOption("source") ?? string.Empty;
var pluginsDir = context.GetOption("plugins-dir") ?? 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) catch (Exception ex)
{ {
@@ -91,12 +91,12 @@ internal static class Commands
{ {
var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source."); var source = context.GetOption("source") ?? throw new InvalidOperationException("Missing --source.");
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir."); 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": case "update":
{ {
var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir."); var pluginsDir = context.GetOption("plugins-dir") ?? throw new InvalidOperationException("Missing --plugins-dir.");
return pluginUpgrades.ApplyPendingUpgrades(pluginsDir); return pluginUpgrades.ApplyPendingUpgrades(pluginsDir, context.ExplicitAppRoot);
} }
default: default:
return new LauncherResult return new LauncherResult

View File

@@ -193,8 +193,10 @@ internal sealed class DataLocationResolver
public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false) public bool ApplyLocationChoice(DataLocationMode mode, string? customPath = null, bool migrateExistingData = false)
{ {
var targetDataRoot = mode == DataLocationMode.Portable && !string.IsNullOrWhiteSpace(customPath) var targetDataRoot = mode == DataLocationMode.Portable
? Path.GetFullPath(customPath) ? Path.GetFullPath(!string.IsNullOrWhiteSpace(customPath)
? customPath
: DefaultPortableDataPath)
: _defaultSystemDataPath; : _defaultSystemDataPath;
var config = new DataLocationConfig var config = new DataLocationConfig

View File

@@ -22,7 +22,7 @@ internal sealed class PluginInstallerService
TimeSpan.FromMilliseconds(500) 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 fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory); var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
@@ -32,7 +32,7 @@ internal sealed class PluginInstallerService
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath); throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
} }
if (TryBuildElevationRequiredResult(fullPluginsDirectory) is { } elevationRequiredResult) if (TryBuildElevationRequiredResult(fullPluginsDirectory, appRoot) is { } elevationRequiredResult)
{ {
return 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()) if (!OperatingSystem.IsWindows())
{ {
@@ -68,8 +68,10 @@ internal sealed class PluginInstallerService
string? allowedRoot = null; string? allowedRoot = null;
try try
{ {
var appRoot = Commands.ResolveAppRoot(CommandContext.FromArgs([])); var resolvedAppRoot = !string.IsNullOrWhiteSpace(appRoot)
var resolver = new DataLocationResolver(appRoot); ? Path.GetFullPath(appRoot)
: Commands.ResolveAppRoot(CommandContext.FromArgs([]));
var resolver = new DataLocationResolver(resolvedAppRoot);
allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot()); allowedRoot = EnsureTrailingSeparator(resolver.ResolveDataRoot());
} }
catch catch

View File

@@ -14,7 +14,7 @@ internal sealed class PluginUpgradeQueueService
_installerService = installerService; _installerService = installerService;
} }
public LauncherResult ApplyPendingUpgrades(string pluginsDirectory) public LauncherResult ApplyPendingUpgrades(string pluginsDirectory, string? appRoot = null)
{ {
var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName); var pendingPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
if (!File.Exists(pendingPath)) if (!File.Exists(pendingPath))
@@ -43,7 +43,7 @@ internal sealed class PluginUpgradeQueueService
try try
{ {
_installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory); _installerService.InstallPackage(item.SourcePackagePath, pluginsDirectory, appRoot);
succeeded.Add(item); succeeded.Add(item);
} }
catch catch

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

View File

@@ -1,5 +1,6 @@
using LanMountainDesktop.Launcher.Plugins; using LanMountainDesktop.Launcher.Plugins;
using System.IO.Compression; using System.IO.Compression;
using System.Text.Json;
using Xunit; using Xunit;
namespace LanMountainDesktop.Tests; namespace LanMountainDesktop.Tests;
@@ -34,10 +35,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
Directory.CreateDirectory(_tempRoot); Directory.CreateDirectory(_tempRoot);
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin"); CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
var pluginsDirectory = CreateUserScopedPluginsDirectory(); var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService(); var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory); var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success); Assert.True(result.Success);
Assert.Equal("ok", result.Code); Assert.Equal("ok", result.Code);
@@ -49,6 +50,42 @@ public sealed class PluginInstallerServiceTests : IDisposable
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories)); 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] [Fact]
public void InstallPackage_ReplacesExistingPackageWithSamePluginId() public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
{ {
@@ -58,11 +95,11 @@ public sealed class PluginInstallerServiceTests : IDisposable
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1"); CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2"); CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
var pluginsDirectory = CreateUserScopedPluginsDirectory(); var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService(); var service = new PluginInstallerService();
var first = service.InstallPackage(firstPackagePath, pluginsDirectory); var first = service.InstallPackage(firstPackagePath, pluginsDirectory, appRoot);
var second = service.InstallPackage(secondPackagePath, pluginsDirectory); var second = service.InstallPackage(secondPackagePath, pluginsDirectory, appRoot);
Assert.True(first.Success); Assert.True(first.Success);
Assert.True(second.Success); Assert.True(second.Success);
@@ -77,10 +114,10 @@ public sealed class PluginInstallerServiceTests : IDisposable
Directory.CreateDirectory(_tempRoot); Directory.CreateDirectory(_tempRoot);
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin"); CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
var pluginsDirectory = CreateUserScopedPluginsDirectory(); var pluginsDirectory = CreateConfiguredPortablePluginsDirectory(out var appRoot);
var service = new PluginInstallerService(); var service = new PluginInstallerService();
var result = service.InstallPackage(packagePath, pluginsDirectory); var result = service.InstallPackage(packagePath, pluginsDirectory, appRoot);
Assert.True(result.Success); Assert.True(result.Success);
Assert.Equal("plugin.legacy.sample", result.ManifestId); 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( appRoot = Path.Combine(_tempRoot, "ConfiguredPackageRoot", Guid.NewGuid().ToString("N"));
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), var portableDataRoot = Path.Combine(appRoot, "Desktop");
"LanMountainDesktop", var launcherDataRoot = Path.Combine(appRoot, ".Launcher");
"Tests", Directory.CreateDirectory(launcherDataRoot);
nameof(PluginInstallerServiceTests), File.WriteAllText(
Guid.NewGuid().ToString("N"), Path.Combine(launcherDataRoot, "data-location.config.json"),
"Extensions", JsonSerializer.Serialize(new
"Plugins"); {
Directory.CreateDirectory(root); DataLocationMode = "Portable",
return root; SystemDataPath = Path.Combine(_tempRoot, "System"),
PortableDataPath = portableDataRoot
}));
var pluginsDirectory = Path.Combine(portableDataRoot, "Extensions", "Plugins");
Directory.CreateDirectory(pluginsDirectory);
return pluginsDirectory;
} }
public void Dispose() public void Dispose()

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

View File

@@ -50,6 +50,11 @@ public static class AppDataPathProvider
return Path.Combine(GetDataRoot(), "Wallpapers"); return Path.Combine(GetDataRoot(), "Wallpapers");
} }
internal static void ResetForTests()
{
_overriddenDataRoot = null;
}
private static string? ResolveDataRootFromArgs(string[] args) private static string? ResolveDataRootFromArgs(string[] args)
{ {
const string prefix = "--data-root="; const string prefix = "--data-root=";

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

View File

@@ -1,9 +1,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk; using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts.Launcher; using LanMountainDesktop.Shared.Contracts.Launcher;
@@ -11,8 +9,6 @@ namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{ {
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
public bool TryExit(HostApplicationLifecycleRequest? request = null) public bool TryExit(HostApplicationLifecycleRequest? request = null)
{ {
App? app = null; App? app = null;
@@ -53,11 +49,6 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return false; return false;
} }
if (HasPendingPluginUpgrades())
{
return TryRestartWithUpgradeHelper(request);
}
return TryRestartDirectly(request); return TryRestartDirectly(request);
} }
catch (Exception ex) 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) private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{ {
var app = Application.Current as App; var app = Application.Current as App;
@@ -149,8 +85,4 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true; return app?.TrySubmitShutdown(HostShutdownMode.Restart, shutdownRequest) == true;
} }
private static string ResolveUpgradeHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
}
} }

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

View File

@@ -67,10 +67,7 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
public PluginMarketEmbeddedView(PluginRuntimeService runtime) public PluginMarketEmbeddedView(PluginRuntimeService runtime)
{ {
_runtime = runtime; _runtime = runtime;
var dataDirectory = Path.Combine( var dataDirectory = AppDataPathProvider.GetPluginMarketDirectory();
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"AirAppMarket");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory)); _indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
_installService = new AirAppMarketInstallService(runtime, dataDirectory); _installService = new AirAppMarketInstallService(runtime, dataDirectory);
_readmeService = new AirAppMarketReadmeService(); _readmeService = new AirAppMarketReadmeService();

View File

@@ -19,13 +19,14 @@ internal sealed class AirAppMarketInstallService : IDisposable
private readonly ResumableDownloadService _downloadService; private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService; private readonly AirAppMarketReleaseResolverService _releaseResolverService;
private readonly PendingPluginUpgradeService _pendingUpgradeService; private readonly PendingPluginUpgradeService _pendingUpgradeService;
private readonly ElevatedPluginInstallService _elevatedInstallService = new();
private readonly string _downloadsDirectory; private readonly string _downloadsDirectory;
private readonly Version? _hostVersion; private readonly Version? _hostVersion;
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory) public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
{ {
_runtime = runtime; _runtime = runtime;
_downloadsDirectory = Path.Combine(dataDirectory, "downloads"); _downloadsDirectory = ResolveDownloadsDirectory(dataDirectory);
_httpClient = new HttpClient _httpClient = new HttpClient
{ {
Timeout = TimeSpan.FromMinutes(2) Timeout = TimeSpan.FromMinutes(2)
@@ -77,9 +78,10 @@ internal sealed class AirAppMarketInstallService : IDisposable
bool isUpgrade, bool isUpgrade,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var canWritePluginsDirectory = PluginInstallTargetAccess.CanWriteDirectory(_runtime.PluginsDirectory);
AppLogger.Info( AppLogger.Info(
"PluginMarket", "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>(); var sourceErrors = new List<string>();
foreach (var source in sources) foreach (var source in sources)
@@ -98,6 +100,25 @@ internal sealed class AirAppMarketInstallService : IDisposable
try try
{ {
var manifest = ReadManifestFromPackage(downloadResult.PackagePath); 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( _pendingUpgradeService.AddPendingInstallOrUpgrade(
manifest.Id, manifest.Id,
downloadResult.PackagePath, 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( private async Task<DownloadPackageResult> DownloadPackageAsync(
AirAppMarketPluginEntry plugin, AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source, AirAppMarketPluginPackageSourceEntry source,

View File

@@ -46,9 +46,10 @@ public sealed class PluginRuntimeService : IDisposable
ISettingsFacadeService? settingsFacade = null, ISettingsFacadeService? settingsFacade = null,
PublicIpcHostService? publicIpcHostService = null) PublicIpcHostService? publicIpcHostService = null)
{ {
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins"); var dataRoot = AppDataPathProvider.GetDataRoot();
PluginsDirectory = Path.Combine(dataRoot, "Extensions", "Plugins");
_sharedContractManager = new PluginSharedContractManager( _sharedContractManager = new PluginSharedContractManager(
Path.Combine(GetUserDataRootDirectory(), "PluginMarket")); AppDataPathProvider.GetPluginMarketDirectory());
_packageManager = new PluginRuntimePackageManager(this); _packageManager = new PluginRuntimePackageManager(this);
_settingsFacade = settingsFacade ?? new SettingsFacadeService(); _settingsFacade = settingsFacade ?? new SettingsFacadeService();
_publicIpcHostService = publicIpcHostService; _publicIpcHostService = publicIpcHostService;
@@ -388,9 +389,15 @@ public sealed class PluginRuntimeService : IDisposable
AppLogger.Info( AppLogger.Info(
"PluginRuntime", "PluginRuntime",
$"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'."); $"Installing package. PluginId='{manifest.Id}'; Source='{fullPackagePath}'; PluginsDirectory='{PluginsDirectory}'.");
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id)); 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)) if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{ {
FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime"); FileOperationRetryHelper.CopyWithRetry(fullPackagePath, destinationPath, overwrite: true, "PluginRuntime");
@@ -405,6 +412,41 @@ public sealed class PluginRuntimeService : IDisposable
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true); 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) private PluginManifest RegisterInstalledPluginPackageCore(string packagePath)
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath); ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
@@ -694,17 +736,6 @@ public sealed class PluginRuntimeService : IDisposable
: path + Path.DirectorySeparatorChar; : 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() private static PluginLoaderOptions CreateOptions()
{ {
var devOptions = DevPluginOptions.Current; var devOptions = DevPluginOptions.Current;

View File

@@ -257,13 +257,7 @@ internal sealed class PluginSharedContractManager : IDisposable
private static string GetSharedContractRootDirectory() private static string GetSharedContractRootDirectory()
{ {
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); return AppDataPathProvider.GetDataRoot();
if (string.IsNullOrWhiteSpace(localAppData))
{
return Path.Combine(AppContext.BaseDirectory, "Data");
}
return Path.Combine(localAppData, "LanMountainDesktop");
} }
private static string Sanitize(string value) private static string Sanitize(string value)

View File

@@ -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. - `postinstall` may show OOBE only when the launcher is not elevated.
- `plugin-install` and `debug-preview` must not auto-open OOBE. - `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 targets the Host data root (`AppDataPathProvider.GetDataRoot()/Extensions/Plugins`) and should not ask for UAC when that directory is writable.
- Marketplace plugin installs are queued under the user's data root and take effect after restart; they do not use Launcher elevation. - 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.

View File

@@ -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`. 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. **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.

View File

@@ -444,8 +444,10 @@ _oobeSteps = [
- `postinstall` may open OOBE only when the launcher is not elevated and the user state path is available. - `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. - `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 Host data root and must not request elevation when that directory is writable.
- 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. - 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 ## Public IPC Baseline