feat.airapp剥离启动器

This commit is contained in:
lincube
2026-05-31 19:41:10 +08:00
parent 21e970c5b6
commit c351a8e7f3
78 changed files with 1957 additions and 1250 deletions

View File

@@ -96,17 +96,17 @@ public sealed class AirAppLauncherServiceTests
}
[Fact]
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
public void CreateRuntimeStartInfo_UsesAirAppRuntimeAndRequesterPid()
{
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
@"C:\Apps\LanMountainDesktop.Launcher.exe",
var startInfo = AirAppLauncherService.CreateRuntimeStartInfo(
@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe",
12345);
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
Assert.Equal(@"C:\Apps\LanMountainDesktop.AirAppRuntime.exe", startInfo.FileName);
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
Assert.False(startInfo.UseShellExecute);
Assert.Equal(
["air-app-broker", "--requester-pid", "12345"],
["--requester-pid", "12345"],
startInfo.ArgumentList);
}
}

View File

@@ -1,5 +1,3 @@
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Infrastructure;
using Xunit;
namespace LanMountainDesktop.Tests;
@@ -29,38 +27,14 @@ public sealed class AirAppProcessStarterRuntimeTests : IDisposable
}
[Fact]
public void CreateStartInfo_UsesArchitectureMatchedDotnetHost_ForDllFallbackOnWindows()
public void CreateStartInfo_UsesDotnetHost_ForDllFallback()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var programFiles = Path.Combine(_root, "ProgramFiles");
var dotnetRoot = Path.Combine(programFiles, "dotnet");
Directory.CreateDirectory(dotnetRoot);
var dotnetHost = Path.Combine(dotnetRoot, "dotnet.exe");
File.WriteAllText(dotnetHost, string.Empty);
Directory.CreateDirectory(Path.Combine(
dotnetRoot,
"shared",
DotNetRuntimeProbe.RequiredSharedFrameworkName,
"10.0.5"));
var hostDll = Path.Combine(_root, "LanMountainDesktop.AirAppHost.dll");
File.WriteAllText(hostDll, string.Empty);
var options = new DotNetRuntimeProbeOptions
{
Architecture = DotNetRuntimeArchitecture.X64,
ProgramFilesPath = programFiles,
ProgramFilesX86Path = Path.Combine(_root, "ProgramFilesX86"),
IncludeRegistry = false,
IncludeDotNetCli = false
};
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll, options);
var startInfo = AirAppProcessStarter.CreateStartInfo(hostDll);
Assert.Equal(dotnetHost, startInfo.FileName);
Assert.Contains("dotnet", Path.GetFileName(startInfo.FileName), StringComparison.OrdinalIgnoreCase);
Assert.Equal(hostDll, startInfo.ArgumentList.Single());
}

View File

@@ -0,0 +1,70 @@
using System.Text.Json;
using LanMountainDesktop.Shared.IPC;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AirAppRuntimeDataRootResolverTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.AirAppRuntimeDataRootResolverTests",
Guid.NewGuid().ToString("N"));
[Fact]
public void ResolveDataRoot_UsesPortableDataLocationConfig()
{
var portableRoot = Path.Combine(_root, "PortableData");
WriteConfig(new
{
dataLocationMode = "Portable",
portableDataPath = portableRoot
});
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
Assert.Equal(Path.GetFullPath(portableRoot), resolved);
}
[Fact]
public void ResolveDataRoot_UsesSystemDataLocationConfig()
{
var systemRoot = Path.Combine(_root, "SystemData");
WriteConfig(new
{
dataLocationMode = "System",
systemDataPath = systemRoot
});
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
Assert.Equal(Path.GetFullPath(systemRoot), resolved);
}
[Fact]
public void ResolveDataRoot_FallsBackToDefaultWhenConfigMissing()
{
var resolved = AirAppRuntimeDataRootResolver.ResolveDataRoot(_root);
Assert.Equal(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "LanMountainDesktop"),
resolved);
}
private void WriteConfig<T>(T config)
{
var configDirectory = Path.Combine(_root, ".Launcher");
Directory.CreateDirectory(configDirectory);
File.WriteAllText(
Path.Combine(configDirectory, "data-location.config.json"),
JsonSerializer.Serialize(config));
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -1,20 +1,17 @@
using System.Diagnostics;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.AirApp;
using LanMountainDesktop.Launcher.Shell.EntryHandlers;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class LauncherAirAppLifecycleServiceTests
public sealed class AirAppRuntimeLifecycleServiceTests
{
[Fact]
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var service = new AirAppLifecycleService(starter);
var request = new AirAppOpenRequest(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
@@ -36,7 +33,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var service = new AirAppLifecycleService(starter);
var first = await service.OpenAsync(new AirAppOpenRequest(
"world-clock",
@@ -62,7 +59,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var service = new AirAppLifecycleService(starter);
var instanceKey = AirAppInstanceKey.Build(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
@@ -92,7 +89,7 @@ public sealed class LauncherAirAppLifecycleServiceTests
[Fact]
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
@@ -112,26 +109,35 @@ public sealed class LauncherAirAppLifecycleServiceTests
}
[Fact]
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
public void RuntimeLifetime_KeepsAliveWhileRequesterIsAlive()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, 0, Environment.ProcessId),
service);
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
Assert.True(lifetime.ShouldKeepAlive());
}
[Fact]
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
public void RuntimeLifetime_StopsWhenNoProcessOrAirAppsRemain()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
service);
Assert.False(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
Assert.False(lifetime.ShouldKeepAlive());
}
[Fact]
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
public async Task RuntimeLifetime_KeepsAliveWhileAirAppIsAlive()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
service);
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
instanceKey,
@@ -142,28 +148,23 @@ public sealed class LauncherAirAppLifecycleServiceTests
BuiltInComponentIds.DesktopWorldClock,
"clock-2"));
Assert.True(AirAppBrokerEntryHandler.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
Assert.True(lifetime.ShouldKeepAlive());
}
[Fact]
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
public async Task RuntimeControl_AttachesHostProcess()
{
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
var service = new AirAppLifecycleService(new TestAirAppProcessStarter(null));
var lifetime = new AirAppRuntimeLifetime(
new AirAppRuntimeOptions(null, null, int.MaxValue, int.MaxValue),
service);
var control = new AirAppRuntimeControlService(lifetime);
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
var result = await control.AttachHostAsync(Environment.ProcessId);
Assert.True(context.IsGuiCommand);
Assert.True(context.IsAirAppBrokerCommand);
Assert.True(context.IsDebugMode);
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
}
Assert.True(result.Accepted);
Assert.Equal(Environment.ProcessId, result.Status.HostProcessId);
Assert.True(result.Status.HostProcessAlive);
}
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter

View File

@@ -9,7 +9,6 @@ public sealed class CommandContextTests
{
{ [], "normal" },
{ ["preview-oobe"], "debug-preview" },
{ ["apply-update"], "normal" },
{ ["--source", "plugin.lmdp", "--plugins-dir", "plugins", "--result", "result.json"], "plugin-install" },
{ ["launch", "--launch-source", "postinstall"], "postinstall" }
};
@@ -22,4 +21,12 @@ public sealed class CommandContextTests
Assert.Equal(expectedLaunchSource, context.LaunchSource);
}
[Fact]
public void FromArgs_DoesNotTreatAirAppBrokerAsLauncherGuiCommand()
{
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
Assert.False(context.IsGuiCommand);
}
}

View File

@@ -1,4 +1,4 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.AirAppRuntime;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;

View File

@@ -11,7 +11,6 @@ public sealed class HostActivationPolicyTests
[Theory]
[InlineData("launch", "normal", true)]
[InlineData("launch", "restart", false)]
[InlineData("apply-update", "normal", false)]
public void ShouldProbeExistingHostBeforeLaunch_RespectsLaunchSource(
string command,
string launchSource,

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
<ProjectReference Include="..\LanMountainDesktop.AirAppRuntime\LanMountainDesktop.AirAppRuntime.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Launcher\LanMountainDesktop.Launcher.csproj" />
</ItemGroup>
</Project>

View File

@@ -47,6 +47,95 @@ public sealed class LauncherArchitectureTests
Assert.Empty(offenders);
}
[Fact]
public void LauncherProject_DoesNotOwnUpdateApplyOrRollback()
{
var launcherFiles = Directory
.EnumerateFiles(LauncherProjectRoot, "*.cs", SearchOption.AllDirectories)
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.Where(file => !file.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
.ToArray();
var forbiddenTokens = new[]
{
"LauncherUpdateCommandExecutor",
"PlondsUpdateApplier",
"UpdateRollbackGateway",
"UpdateInstallGateway",
"LanMountainDesktop.Services.Update",
"apply-update",
"rollback --app-root"
};
var offenders = launcherFiles
.SelectMany(file => forbiddenTokens
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void LauncherProjectFile_DoesNotSourceLinkHostUpdateImplementation()
{
var project = File.ReadAllText(Path.Combine(LauncherProjectRoot, "LanMountainDesktop.Launcher.csproj"));
Assert.DoesNotContain(@"..\LanMountainDesktop\Services\Update", project, StringComparison.Ordinal);
Assert.DoesNotContain("PlondsUpdateApplier", project, StringComparison.Ordinal);
Assert.DoesNotContain("UpdateRollbackGateway", project, StringComparison.Ordinal);
Assert.DoesNotContain("UpdateInstallGateway", project, StringComparison.Ordinal);
}
[Fact]
public void HostUpdateFlow_DoesNotDelegateApplyOrRollbackToLauncher()
{
var guardedFiles = new[]
{
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateInstallGateway.cs"),
Path.Combine(RepoRoot, "LanMountainDesktop", "Services", "Update", "UpdateOrchestrator.cs")
};
var forbiddenTokens = new[]
{
"LauncherPathResolver",
"ResolveLauncherExecutablePath",
"apply-update",
"rollback --app-root",
"Launched Launcher"
};
var offenders = guardedFiles
.SelectMany(file => forbiddenTokens
.Where(token => File.ReadAllText(file).Contains(token, StringComparison.Ordinal))
.Select(token => $"{RelativeToRepo(file)} contains {token}"))
.ToArray();
Assert.Empty(offenders);
}
[Fact]
public void HostUpdateFlow_OwnsDeltaApplyAndRollbackExecution()
{
var installGateway = File.ReadAllText(Path.Combine(
RepoRoot,
"LanMountainDesktop",
"Services",
"Update",
"UpdateInstallGateway.cs"));
var orchestrator = File.ReadAllText(Path.Combine(
RepoRoot,
"LanMountainDesktop",
"Services",
"Update",
"UpdateOrchestrator.cs"));
Assert.Contains("new PlondsUpdateApplier", installGateway, StringComparison.Ordinal);
Assert.Contains("DeploymentLockService.ClearLock", installGateway, StringComparison.Ordinal);
Assert.Contains("new UpdateRollbackGateway().RollbackLatest", orchestrator, StringComparison.Ordinal);
Assert.DoesNotContain("LanMountainDesktop.Launcher", orchestrator, StringComparison.Ordinal);
}
[Fact]
public void LauncherCompositionRootStaysThin()
{

View File

@@ -1,4 +1,4 @@
global using LanMountainDesktop.Launcher.AirApp;
global using LanMountainDesktop.AirAppRuntime;
global using LanMountainDesktop.Launcher.Deployment;
global using LanMountainDesktop.Launcher.Infrastructure;
global using LanMountainDesktop.Launcher.Ipc;

View File

@@ -0,0 +1,59 @@
using System.Text.Json;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Infrastructure;
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class LauncherUpdateCommandTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.LauncherUpdateCommandTests",
Guid.NewGuid().ToString("N"));
[Fact]
public async Task ApplyUpdateCommand_IsNotHandledByLauncherCli()
{
Directory.CreateDirectory(_root);
var resultPath = Path.Combine(_root, "result.json");
var context = CommandContext.FromArgs(["apply-update", "--app-root", _root, "--result", resultPath]);
var exitCode = await Commands.RunCliCommandAsync(context);
var result = ReadResult(resultPath);
Assert.Equal(1, exitCode);
Assert.Equal("command", result.Stage);
Assert.Equal("unsupported_command", result.Code);
}
[Fact]
public async Task RollbackCommand_IsNotHandledByLauncherCli()
{
Directory.CreateDirectory(_root);
var resultPath = Path.Combine(_root, "result.json");
var context = CommandContext.FromArgs(["rollback", "--app-root", _root, "--result", resultPath]);
var exitCode = await Commands.RunCliCommandAsync(context);
var result = ReadResult(resultPath);
Assert.Equal(1, exitCode);
Assert.Equal("command", result.Stage);
Assert.Equal("unsupported_command", result.Code);
}
private static LauncherResult ReadResult(string path)
{
var result = JsonSerializer.Deserialize<LauncherResult>(File.ReadAllText(path));
return result ?? throw new InvalidOperationException("Launcher result was not written.");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
}

View File

@@ -10,10 +10,12 @@ public sealed class PackagingRuntimePolicyTests
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "package.ps1");
Assert.Contains("Publish-LauncherPayload", script);
Assert.Contains("Publish-AirAppRuntimePayload", script);
Assert.Contains("\"app-$Version\"", script);
Assert.Contains("Publish-MainAppFrameworkDependentPayload", script);
Assert.Contains("\"--self-contained\", \"false\"", script);
Assert.Contains("\"-p:SelfContained=false\"", script);
Assert.Contains("\"-p:PublishAot=false\"", script);
}
[Fact]
@@ -28,12 +30,13 @@ public sealed class PackagingRuntimePolicyTests
}
[Fact]
public void WindowsPayloadGuard_RequiresLauncherMainAndAirAppHost()
public void WindowsPayloadGuard_RequiresLauncherRuntimeMainAndAirAppHost()
{
var script = ReadRepositoryFile("LanMountainDesktop", "scripts", "Optimize-PublishPayload.ps1");
Assert.Contains("Assert-WindowsPayloadContainsRequiredHosts", script);
Assert.Contains("LanMountainDesktop.Launcher.exe", script);
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", script);
Assert.Contains("LanMountainDesktop.exe", script);
Assert.Contains("LanMountainDesktop.AirAppHost.exe", script);
}
@@ -44,9 +47,21 @@ public sealed class PackagingRuntimePolicyTests
var workflow = ReadRepositoryFile(".github", "workflows", "release.yml");
Assert.Contains("Verify Windows app host payload", workflow);
Assert.Contains("LanMountainDesktop.AirAppRuntime.exe", workflow);
Assert.Contains("LanMountainDesktop.AirAppHost.exe", workflow);
}
[Fact]
public void AirAppRuntimeProject_IsFrameworkDependentJit()
{
var project = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "LanMountainDesktop.AirAppRuntime.csproj");
Assert.Contains("<PublishAot>false</PublishAot>", project);
Assert.Contains("<SelfContained>false</SelfContained>", project);
Assert.Contains("<PublishTrimmed>false</PublishTrimmed>", project);
Assert.Contains("<PublishReadyToRun>false</PublishReadyToRun>", project);
}
[Fact]
public void Installer_DownloadsArchitectureSpecificDesktopRuntime()
{

View File

@@ -130,7 +130,7 @@ public sealed class WindowLayerIsolationTests
{
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "AirApp", "IAirAppProcessStarter.cs");
var starterSource = ReadRepositoryFile("LanMountainDesktop.AirAppRuntime", "IAirAppProcessStarter.cs");
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
Assert.Contains("DataRoot", optionsSource);