From a2ac302ee7d5a3b038a4d0ee854d8e85eba8e661 Mon Sep 17 00:00:00 2001 From: lincube Date: Mon, 1 Jun 2026 01:12:52 +0800 Subject: [PATCH] =?UTF-8?q?fix.=20=E6=8F=92=E4=BB=B6=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Infrastructure/Commands.cs | 6 +- .../Infrastructure/DataLocationResolver.cs | 6 +- .../Plugins/PluginInstallerService.cs | 12 +- .../Plugins/PluginUpgradeQueueService.cs | 4 +- .../DataLocationResolverTests.cs | 35 +++ .../PluginInstallerServiceTests.cs | 79 ++++-- .../PluginRuntimeDataPathTests.cs | 40 +++ .../Services/AppDataPathProvider.cs | 5 + .../Services/ElevatedPluginInstallService.cs | 237 ++++++++++++++++++ .../HostApplicationLifecycleService.cs | 68 ----- .../Services/PluginInstallTargetAccess.cs | 28 +++ .../plugins/PluginMarketEmbeddedView.cs | 5 +- .../plugins/PluginMarketInstallService.cs | 40 ++- .../plugins/PluginRuntimeService.cs | 59 +++-- .../plugins/PluginSharedContractManager.cs | 8 +- docs/ARCHITECTURE.md | 5 +- docs/DEVELOPMENT.md | 2 +- docs/LAUNCHER.md | 6 +- 18 files changed, 515 insertions(+), 130 deletions(-) create mode 100644 LanMountainDesktop.Tests/DataLocationResolverTests.cs create mode 100644 LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs create mode 100644 LanMountainDesktop/Services/ElevatedPluginInstallService.cs create mode 100644 LanMountainDesktop/Services/PluginInstallTargetAccess.cs diff --git a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs index 0ad3d97..482b904 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/Commands.cs +++ b/LanMountainDesktop.Launcher/Infrastructure/Commands.cs @@ -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 diff --git a/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs b/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs index 0cf3d50..ce8f052 100644 --- a/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs +++ b/LanMountainDesktop.Launcher/Infrastructure/DataLocationResolver.cs @@ -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 diff --git a/LanMountainDesktop.Launcher/Plugins/PluginInstallerService.cs b/LanMountainDesktop.Launcher/Plugins/PluginInstallerService.cs index fb52344..f09d8e1 100644 --- a/LanMountainDesktop.Launcher/Plugins/PluginInstallerService.cs +++ b/LanMountainDesktop.Launcher/Plugins/PluginInstallerService.cs @@ -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 diff --git a/LanMountainDesktop.Launcher/Plugins/PluginUpgradeQueueService.cs b/LanMountainDesktop.Launcher/Plugins/PluginUpgradeQueueService.cs index 2d68a42..fd11de1 100644 --- a/LanMountainDesktop.Launcher/Plugins/PluginUpgradeQueueService.cs +++ b/LanMountainDesktop.Launcher/Plugins/PluginUpgradeQueueService.cs @@ -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 diff --git a/LanMountainDesktop.Tests/DataLocationResolverTests.cs b/LanMountainDesktop.Tests/DataLocationResolverTests.cs new file mode 100644 index 0000000..7d78fe7 --- /dev/null +++ b/LanMountainDesktop.Tests/DataLocationResolverTests.cs @@ -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); + } + } +} diff --git a/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs b/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs index dbb30e6..8972cbb 100644 --- a/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs +++ b/LanMountainDesktop.Tests/PluginInstallerServiceTests.cs @@ -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() diff --git a/LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs b/LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs new file mode 100644 index 0000000..8ec718c --- /dev/null +++ b/LanMountainDesktop.Tests/PluginRuntimeDataPathTests.cs @@ -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 + { + } + } +} diff --git a/LanMountainDesktop/Services/AppDataPathProvider.cs b/LanMountainDesktop/Services/AppDataPathProvider.cs index e1b7f12..59e2d1f 100644 --- a/LanMountainDesktop/Services/AppDataPathProvider.cs +++ b/LanMountainDesktop/Services/AppDataPathProvider.cs @@ -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="; diff --git a/LanMountainDesktop/Services/ElevatedPluginInstallService.cs b/LanMountainDesktop/Services/ElevatedPluginInstallService.cs new file mode 100644 index 0000000..de3c520 --- /dev/null +++ b/LanMountainDesktop/Services/ElevatedPluginInstallService.cs @@ -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 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 + { + } + } +} diff --git a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs index e8de9f2..837220b 100644 --- a/LanMountainDesktop/Services/HostApplicationLifecycleService.cs +++ b/LanMountainDesktop/Services/HostApplicationLifecycleService.cs @@ -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); - } } diff --git a/LanMountainDesktop/Services/PluginInstallTargetAccess.cs b/LanMountainDesktop/Services/PluginInstallTargetAccess.cs new file mode 100644 index 0000000..33fe723 --- /dev/null +++ b/LanMountainDesktop/Services/PluginInstallTargetAccess.cs @@ -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; + } + } +} diff --git a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs index 25b6371..7f041fc 100644 --- a/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs +++ b/LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs @@ -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(); diff --git a/LanMountainDesktop/plugins/PluginMarketInstallService.cs b/LanMountainDesktop/plugins/PluginMarketInstallService.cs index c095729..7bf7c85 100644 --- a/LanMountainDesktop/plugins/PluginMarketInstallService.cs +++ b/LanMountainDesktop/plugins/PluginMarketInstallService.cs @@ -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(); 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 DownloadPackageAsync( AirAppMarketPluginEntry plugin, AirAppMarketPluginPackageSourceEntry source, diff --git a/LanMountainDesktop/plugins/PluginRuntimeService.cs b/LanMountainDesktop/plugins/PluginRuntimeService.cs index f76f43c..baa8d1f 100644 --- a/LanMountainDesktop/plugins/PluginRuntimeService.cs +++ b/LanMountainDesktop/plugins/PluginRuntimeService.cs @@ -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; diff --git a/LanMountainDesktop/plugins/PluginSharedContractManager.cs b/LanMountainDesktop/plugins/PluginSharedContractManager.cs index d487793..9f9f2c7 100644 --- a/LanMountainDesktop/plugins/PluginSharedContractManager.cs +++ b/LanMountainDesktop/plugins/PluginSharedContractManager.cs @@ -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) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bca6255..49d544c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 09fc072..d4df010 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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 ` 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. diff --git a/docs/LAUNCHER.md b/docs/LAUNCHER.md index 0c82012..f21c470 100644 --- a/docs/LAUNCHER.md +++ b/docs/LAUNCHER.md @@ -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