Harden OOBE, launch-source and elevation flow
Introduce a per-user OOBE state model and hardened launch/elevation handling. Adds OobeStateFile/OobeLaunchDecision models, OobeStateService (persisting %LOCALAPPDATA%/.launcher/state/oobe-state.json), and LauncherExecutionContext to capture elevation and user SID. CommandContext now normalizes/infers launch-source values (normal, postinstall, apply-update, plugin-install, debug-preview) and exposes maintenance checks. LauncherFlowCoordinator propagates richer launcher context details for diagnostics and suppresses OOBE for elevated/maintenance contexts. PluginInstallerService avoids requesting elevation for user-scoped installs and returns a clear error when installation target is outside the current user's LocalAppData. LauncherClient maps and surfaces result codes, UpdateWorkflow and installer invocation now pass explicit --launch-source values, and WelcomeOobeStep persists OOBE completion via the new service. Adds unit tests (CommandContext, OobeStateService, PluginInstallerService), docs/specs/checklists for the contract, and makes internals visible to tests.
2026-04-22 09:25:22 +08:00
|
|
|
using LanMountainDesktop.Launcher.Services;
|
Support .laapp/plugin.json and improve market models
Add support for the new plugin package contract (.laapp + plugin.json) while keeping backward compatibility with legacy .lmdp/manifest.json, and improve market metadata resolution and launcher handling.
Key changes:
- LanMountainDesktop.Launcher: PluginInstallerService now recognizes plugin.json and .laapp, preserves legacy manifest/package names, searches for manifests with a helper, and removes existing packages matching either extension.
- LanMountainDesktop.PluginTemplate: README updated to document .laapp, plugin.json, runtime contract and packaging expectations.
- Tests: New and extended tests for PluginInstallerService and a PluginMarketIndexDocumentTests covering nested index parsing and metadata enrichment.
- LauncherClient & PluginMarketInstallService: ResolveLauncherPath now probes multiple candidate locations (useful for dev and packaged layouts); LauncherClient also adjusted launcher arguments to use the updated CLI form.
- SettingsDomainServices: Added BuildCapabilities to safely build capability lists from entries (null checks, projection, de-dup via DistinctBy).
- AirAppMarketMetadataResolverService & PluginMarketModels: Prefer existing manifest/publication/compatibility values when enriching entries, add ApiVersion/Path fields, normalize compatibility logic and package source URL/path handling; handle Sha256/size/publication dates more robustly.
- Misc: Added localization spec/checklist/tasks under .trae for a localization fix initiative.
These changes enable the new plugin packaging format, improve robustness of market data enrichment, make launcher discovery more flexible for different environments, and add tests and docs to cover the new behaviors.
2026-04-30 00:02:52 +08:00
|
|
|
using System.IO.Compression;
|
Harden OOBE, launch-source and elevation flow
Introduce a per-user OOBE state model and hardened launch/elevation handling. Adds OobeStateFile/OobeLaunchDecision models, OobeStateService (persisting %LOCALAPPDATA%/.launcher/state/oobe-state.json), and LauncherExecutionContext to capture elevation and user SID. CommandContext now normalizes/infers launch-source values (normal, postinstall, apply-update, plugin-install, debug-preview) and exposes maintenance checks. LauncherFlowCoordinator propagates richer launcher context details for diagnostics and suppresses OOBE for elevated/maintenance contexts. PluginInstallerService avoids requesting elevation for user-scoped installs and returns a clear error when installation target is outside the current user's LocalAppData. LauncherClient maps and surfaces result codes, UpdateWorkflow and installer invocation now pass explicit --launch-source values, and WelcomeOobeStep persists OOBE completion via the new service. Adds unit tests (CommandContext, OobeStateService, PluginInstallerService), docs/specs/checklists for the contract, and makes internals visible to tests.
2026-04-22 09:25:22 +08:00
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace LanMountainDesktop.Tests;
|
|
|
|
|
|
|
|
|
|
public sealed class PluginInstallerServiceTests : IDisposable
|
|
|
|
|
{
|
|
|
|
|
private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.Tests", nameof(PluginInstallerServiceTests), Guid.NewGuid().ToString("N"));
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void InstallPackage_ReturnsElevationRequired_ForOutsideUserScope_OnWindows()
|
|
|
|
|
{
|
|
|
|
|
if (!OperatingSystem.IsWindows())
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Directory.CreateDirectory(_tempRoot);
|
|
|
|
|
var packagePath = Path.Combine(_tempRoot, "sample.lmdp");
|
|
|
|
|
File.WriteAllText(packagePath, "placeholder");
|
|
|
|
|
|
|
|
|
|
var service = new PluginInstallerService();
|
|
|
|
|
var result = service.InstallPackage(packagePath, Path.Combine(_tempRoot, "Plugins"));
|
|
|
|
|
|
|
|
|
|
Assert.False(result.Success);
|
|
|
|
|
Assert.Equal("plugin_elevation_required", result.Code);
|
|
|
|
|
}
|
|
|
|
|
|
Support .laapp/plugin.json and improve market models
Add support for the new plugin package contract (.laapp + plugin.json) while keeping backward compatibility with legacy .lmdp/manifest.json, and improve market metadata resolution and launcher handling.
Key changes:
- LanMountainDesktop.Launcher: PluginInstallerService now recognizes plugin.json and .laapp, preserves legacy manifest/package names, searches for manifests with a helper, and removes existing packages matching either extension.
- LanMountainDesktop.PluginTemplate: README updated to document .laapp, plugin.json, runtime contract and packaging expectations.
- Tests: New and extended tests for PluginInstallerService and a PluginMarketIndexDocumentTests covering nested index parsing and metadata enrichment.
- LauncherClient & PluginMarketInstallService: ResolveLauncherPath now probes multiple candidate locations (useful for dev and packaged layouts); LauncherClient also adjusted launcher arguments to use the updated CLI form.
- SettingsDomainServices: Added BuildCapabilities to safely build capability lists from entries (null checks, projection, de-dup via DistinctBy).
- AirAppMarketMetadataResolverService & PluginMarketModels: Prefer existing manifest/publication/compatibility values when enriching entries, add ApiVersion/Path fields, normalize compatibility logic and package source URL/path handling; handle Sha256/size/publication dates more robustly.
- Misc: Added localization spec/checklist/tasks under .trae for a localization fix initiative.
These changes enable the new plugin packaging format, improve robustness of market data enrichment, make launcher discovery more flexible for different environments, and add tests and docs to cover the new behaviors.
2026-04-30 00:02:52 +08:00
|
|
|
[Fact]
|
|
|
|
|
public void InstallPackage_InstallsLaappWithPluginJson_InsideUserScope()
|
|
|
|
|
{
|
|
|
|
|
var packagePath = Path.Combine(_tempRoot, "sample.laapp");
|
|
|
|
|
Directory.CreateDirectory(_tempRoot);
|
|
|
|
|
CreatePluginPackage(packagePath, "plugin.json", "plugin.install.sample", "Sample Plugin");
|
|
|
|
|
|
|
|
|
|
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
|
|
|
|
var service = new PluginInstallerService();
|
|
|
|
|
|
|
|
|
|
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success);
|
|
|
|
|
Assert.Equal("ok", result.Code);
|
|
|
|
|
Assert.Equal("plugin.install.sample", result.ManifestId);
|
|
|
|
|
Assert.Equal("Sample Plugin", result.ManifestName);
|
|
|
|
|
Assert.NotNull(result.InstalledPackagePath);
|
|
|
|
|
Assert.True(File.Exists(result.InstalledPackagePath));
|
|
|
|
|
Assert.EndsWith(".laapp", result.InstalledPackagePath, StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
Assert.Empty(Directory.EnumerateFiles(pluginsDirectory, "*.incoming", SearchOption.AllDirectories));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void InstallPackage_ReplacesExistingPackageWithSamePluginId()
|
|
|
|
|
{
|
|
|
|
|
Directory.CreateDirectory(_tempRoot);
|
|
|
|
|
var firstPackagePath = Path.Combine(_tempRoot, "sample-1.laapp");
|
|
|
|
|
var secondPackagePath = Path.Combine(_tempRoot, "sample-2.laapp");
|
|
|
|
|
CreatePluginPackage(firstPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v1");
|
|
|
|
|
CreatePluginPackage(secondPackagePath, "plugin.json", "plugin.replace.sample", "Sample Plugin v2");
|
|
|
|
|
|
|
|
|
|
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
|
|
|
|
var service = new PluginInstallerService();
|
|
|
|
|
|
|
|
|
|
var first = service.InstallPackage(firstPackagePath, pluginsDirectory);
|
|
|
|
|
var second = service.InstallPackage(secondPackagePath, pluginsDirectory);
|
|
|
|
|
|
|
|
|
|
Assert.True(first.Success);
|
|
|
|
|
Assert.True(second.Success);
|
|
|
|
|
Assert.Single(Directory.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.TopDirectoryOnly));
|
|
|
|
|
Assert.True(File.Exists(second.InstalledPackagePath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public void InstallPackage_StillSupportsLegacyManifestJson()
|
|
|
|
|
{
|
|
|
|
|
var packagePath = Path.Combine(_tempRoot, "legacy.lmdp");
|
|
|
|
|
Directory.CreateDirectory(_tempRoot);
|
|
|
|
|
CreatePluginPackage(packagePath, "manifest.json", "plugin.legacy.sample", "Legacy Plugin");
|
|
|
|
|
|
|
|
|
|
var pluginsDirectory = CreateUserScopedPluginsDirectory();
|
|
|
|
|
var service = new PluginInstallerService();
|
|
|
|
|
|
|
|
|
|
var result = service.InstallPackage(packagePath, pluginsDirectory);
|
|
|
|
|
|
|
|
|
|
Assert.True(result.Success);
|
|
|
|
|
Assert.Equal("plugin.legacy.sample", result.ManifestId);
|
|
|
|
|
Assert.True(File.Exists(result.InstalledPackagePath));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static void CreatePluginPackage(string packagePath, string manifestFileName, string pluginId, string pluginName)
|
|
|
|
|
{
|
|
|
|
|
using var archive = ZipFile.Open(packagePath, ZipArchiveMode.Create);
|
|
|
|
|
var entry = archive.CreateEntry(manifestFileName);
|
|
|
|
|
using var stream = entry.Open();
|
|
|
|
|
using var writer = new StreamWriter(stream);
|
|
|
|
|
writer.Write(
|
|
|
|
|
$$"""
|
|
|
|
|
{
|
|
|
|
|
"id": "{{pluginId}}",
|
|
|
|
|
"name": "{{pluginName}}",
|
|
|
|
|
"version": "1.0.0"
|
|
|
|
|
}
|
|
|
|
|
""");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static string CreateUserScopedPluginsDirectory()
|
|
|
|
|
{
|
|
|
|
|
var root = Path.Combine(
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
|
|
|
"LanMountainDesktop",
|
|
|
|
|
"Tests",
|
|
|
|
|
nameof(PluginInstallerServiceTests),
|
|
|
|
|
Guid.NewGuid().ToString("N"),
|
|
|
|
|
"Extensions",
|
|
|
|
|
"Plugins");
|
|
|
|
|
Directory.CreateDirectory(root);
|
|
|
|
|
return root;
|
|
|
|
|
}
|
|
|
|
|
|
Harden OOBE, launch-source and elevation flow
Introduce a per-user OOBE state model and hardened launch/elevation handling. Adds OobeStateFile/OobeLaunchDecision models, OobeStateService (persisting %LOCALAPPDATA%/.launcher/state/oobe-state.json), and LauncherExecutionContext to capture elevation and user SID. CommandContext now normalizes/infers launch-source values (normal, postinstall, apply-update, plugin-install, debug-preview) and exposes maintenance checks. LauncherFlowCoordinator propagates richer launcher context details for diagnostics and suppresses OOBE for elevated/maintenance contexts. PluginInstallerService avoids requesting elevation for user-scoped installs and returns a clear error when installation target is outside the current user's LocalAppData. LauncherClient maps and surfaces result codes, UpdateWorkflow and installer invocation now pass explicit --launch-source values, and WelcomeOobeStep persists OOBE completion via the new service. Adds unit tests (CommandContext, OobeStateService, PluginInstallerService), docs/specs/checklists for the contract, and makes internals visible to tests.
2026-04-22 09:25:22 +08:00
|
|
|
public void Dispose()
|
|
|
|
|
{
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (Directory.Exists(_tempRoot))
|
|
|
|
|
{
|
|
|
|
|
Directory.Delete(_tempRoot, recursive: true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
catch
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|