Author SHA1 Message Date
codex 175fcef8a9 feat: add Launcher Next .NET 8 foundation 2026-07-31 14:56:56 +03:00
24 changed files with 317 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
+1
View File
@@ -0,0 +1 @@
<Project />
+1
View File
@@ -0,0 +1 @@
<Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml" />
+1
View File
@@ -0,0 +1 @@
namespace Launcher.Next.App; public partial class App : System.Windows.Application { }
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /></ItemGroup></Project>
+1
View File
@@ -0,0 +1 @@
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next (Foundation)" Height="300" Width="540"><StackPanel Margin="20"><TextBlock FontSize="20" Text="Launcher Next — foundation"/><TextBlock Margin="0,12,0,0" Text="Plant 3D: discovery available in diagnostics"/><TextBlock Text="Mode: Legacy (default); NextPilot requires explicit configuration"/><TextBlock Text="Package: no production catalog connected"/><Button Margin="0,16,0,0" IsEnabled="False" Content="Launch (disabled: dry-run foundation)"/></StackPanel></Window>
+1
View File
@@ -0,0 +1 @@
namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); }
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
@@ -0,0 +1,14 @@
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed class PrepareLaunchUseCase(IProductDiscovery discovery, IAutodeskProcessLauncher launcher, ILauncherTelemetry telemetry)
{
public PreparedLaunch Execute(int year, string profile, string scriptPath)
{
telemetry.Write("ProductDiscoveryStarted");
var product = discovery.DiscoverPlant3D(year) ?? throw new InvalidOperationException("PLANT_PRODUCT_NOT_FOUND");
telemetry.Write("ProductDiscovered", new Dictionary<string, object?> { ["version"] = product.ProductVersion, ["architecture"] = product.Architecture });
var prepared = launcher.Prepare(new(product, profile, scriptPath, true));
telemetry.Write("LaunchPrepared", new Dictionary<string, object?> { ["dryRun"] = true });
return prepared;
}
}
@@ -0,0 +1,36 @@
using Microsoft.Win32;
using Launcher.Next.Core;
namespace Launcher.Next.Autodesk;
public interface IRegistryReader { object? GetValue(string path, string name); }
public sealed class WindowsRegistryReader : IRegistryReader { public object? GetValue(string path, string name) => Registry.LocalMachine.OpenSubKey(path)?.GetValue(name); }
public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDiscovery
{
public ProductInstallation? DiscoverPlant3D(int year)
{
var install = registry.GetValue($@"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-xxxx:409", "InstallDir") as string;
if (string.IsNullOrWhiteSpace(install)) return null; var exe = Path.Combine(install, "acad.exe");
return File.Exists(exe) ? new(exe, year.ToString(), "x64", []) : null;
}
}
public sealed class StartupScriptBuilder : IStartupScriptBuilder
{
public string BuildNetloadScript(string root, string entry)
{ var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"(command \"._netload\" \"{full.Replace("\\", "\\\\").Replace("\"", "\\\"")}\")\n"; }
}
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{
public PreparedLaunch Prepare(LaunchRequest r)
{
if (!r.DryRun) throw new InvalidOperationException("PROCESS_START_DISABLED_IN_FOUNDATION");
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = r.Product.ExecutablePath,
WorkingDirectory = Path.GetDirectoryName(r.Product.ExecutablePath)!,
UseShellExecute = false
};
startInfo.ArgumentList.Add("/product"); startInfo.ArgumentList.Add("PLNT3D");
startInfo.ArgumentList.Add("/p"); startInfo.ArgumentList.Add(r.Profile);
startInfo.ArgumentList.Add("/b"); startInfo.ArgumentList.Add(r.StartupScriptPath);
return new(startInfo.FileName, startInfo.ArgumentList.ToArray(), startInfo.WorkingDirectory, false);
}
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
+34
View File
@@ -0,0 +1,34 @@
namespace Launcher.Next.Core;
public enum LauncherMode { Legacy, NextPilot, NextPreferred }
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
public sealed record StartupDefinition(string Type, string Command);
public sealed record PackageManifest(
int SchemaVersion, string PackageId, string Version, string Product, int ProductYear,
string Architecture, string EntryAssembly, IReadOnlyList<PackageFile> Files, StartupDefinition Startup,
string? MinimumLauncherVersion = null, IReadOnlyList<string>? CompatibleAutoCadSeries = null,
string? RequiredRuntime = null, IReadOnlyList<string>? Dependencies = null,
IReadOnlyList<string>? OptionalResources = null, string? RolloutChannel = null,
bool Disabled = false, bool RollbackSafe = false, string? ReleaseNotesReference = null);
public sealed record ValidationIssue(string Code, string Message);
public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
public sealed record PackageProgress(string Stage, int Completed, int Total);
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); }
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
public interface IClock { DateTimeOffset UtcNow { get; } }
public interface IFileHasher { Task<string> Sha256Async(string path, CancellationToken cancellationToken); }
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk" />
+27
View File
@@ -0,0 +1,27 @@
using System.Text.RegularExpressions;
namespace Launcher.Next.Core;
public sealed class PackageManifestValidator : IPackageManifestValidator
{
private static readonly string[] Forbidden = ["acmgd.dll", "acdbmgd.dll", "accoremgd.dll", "adwindows.dll"];
public ValidationResult Validate(PackageManifest manifest, int productYear, string architecture)
{
var issues = new List<ValidationIssue>();
if (manifest.SchemaVersion != 1) issues.Add(new("schema", "Unsupported schemaVersion."));
if (string.IsNullOrWhiteSpace(manifest.PackageId) || string.IsNullOrWhiteSpace(manifest.Version)) issues.Add(new("identity", "packageId and version are required."));
if (!string.Equals(manifest.Product, "Plant3D", StringComparison.OrdinalIgnoreCase) || manifest.ProductYear != productYear) issues.Add(new("product", "Package is incompatible with Plant 3D."));
if (!string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase)) issues.Add(new("architecture", "Package architecture is incompatible."));
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in manifest.Files)
{
if (string.IsNullOrWhiteSpace(file.Path) || Path.IsPathRooted(file.Path) || file.Path.Split(['/', '\\']).Any(x => x is ".." or "")) issues.Add(new("path", $"Invalid package path: {file.Path}"));
if (!paths.Add(file.Path)) issues.Add(new("duplicate", $"Duplicate package path: {file.Path}"));
var name = Path.GetFileName(file.Path);
if (Forbidden.Contains(name, StringComparer.OrdinalIgnoreCase) || name.StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Autodesk.ProcessPower", StringComparison.OrdinalIgnoreCase)) issues.Add(new("autodesk", $"Forbidden Autodesk assembly: {name}"));
if (!Regex.IsMatch(file.Sha256 ?? string.Empty, "^[A-Fa-f0-9]{64}$")) issues.Add(new("hash", $"Invalid SHA-256 for {file.Path}"));
}
if (!paths.Contains(manifest.EntryAssembly)) issues.Add(new("entry", "entryAssembly must be listed in files."));
return issues.Count == 0 ? ValidationResult.Success : new ValidationResult(issues);
}
}
@@ -0,0 +1,7 @@
using System.Text.Json;
namespace Launcher.Next.Core;
public static class PackageManifestJson
{
private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
public static PackageManifest Parse(string json) => JsonSerializer.Deserialize<PackageManifest>(json, Options) ?? throw new InvalidOperationException("MANIFEST_EMPTY");
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
@@ -0,0 +1,31 @@
using System.Security.Cryptography;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class Sha256FileHasher : IFileHasher
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer
{
private sealed record Pointer(string Current, string? Previous);
private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync<Pointer>(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null;
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
{
var dir = Path.Combine(PackageRoot(id), "versions", version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct);
var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true);
return new(version, prior?.Current);
}
public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; }
public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
{
var staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging);
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); var source = Path.Combine(sourceRoot, f.Path); var dest = Path.GetFullPath(Path.Combine(staging, f.Path)); if (!dest.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
var versionDir = Path.Combine(PackageRoot(manifest.PackageId), "versions", manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return await ActivateAsync(manifest.PackageId, manifest.Version, ct); }
catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
}
}
@@ -0,0 +1,8 @@
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Observability;
public sealed class JsonlTelemetry(string logRoot) : ILauncherTelemetry
{
public void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null)
{ try { Directory.CreateDirectory(logRoot); var allowed = (fields ?? new Dictionary<string, object?>()).Where(x => !x.Key.Contains("user", StringComparison.OrdinalIgnoreCase) && !x.Key.Contains("token", StringComparison.OrdinalIgnoreCase) && !x.Key.Contains("password", StringComparison.OrdinalIgnoreCase)).ToDictionary(); var line = JsonSerializer.Serialize(new { Timestamp = DateTimeOffset.UtcNow, EventType = eventType, Fields = allowed }); File.AppendAllText(Path.Combine(logRoot, DateTime.UtcNow.ToString("yyyyMMdd") + ".jsonl"), line + Environment.NewLine); } catch { } }
}
@@ -0,0 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
+54
View File
@@ -0,0 +1,54 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Core", "Launcher.Next.Core\Launcher.Next.Core.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Application", "Launcher.Next.Application\Launcher.Next.Application.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Infrastructure", "Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Autodesk", "Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Observability", "Launcher.Next.Observability\Launcher.Next.Observability.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.App", "Launcher.Next.App\Launcher.Next.App.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Tests", "tests\Launcher.Next.Tests\Launcher.Next.Tests.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Release|x64.Build.0 = Release|x64
EndGlobalSection
EndGlobal
+13
View File
@@ -0,0 +1,13 @@
# Foundation scope
Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled. The next authorized slice is registry discovery validation and a non-production package catalog pilot.
`tests/Launcher.Next.Tests/fixtures/p-rec-annot-pilot.manifest.json` is a synthetic P_Rec_Annot contract. Its all-zero hash is deliberately replaced by a hash of a test-created one-byte placeholder; it does not identify or package a real Autodesk plugin.
Run from this directory:
```powershell
dotnet restore .\Launcher.Next.sln
dotnet build .\Launcher.Next.sln --configuration Release
dotnet test .\tests\Launcher.Next.Tests\Launcher.Next.Tests.csproj --configuration Release --no-build
```
@@ -0,0 +1,39 @@
using System.Security.Cryptography;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class FoundationTests
{
private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"));
private static PackageFile Package(string path, string hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") => new(path, 1, hash);
[Fact] public void Manifest_ValidPackage_IsAccepted() => Assert.True(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AbsolutePath_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("C:\\evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_MissingEntryAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("Other.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AutodeskAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll"), Package("AcMgd.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_DuplicateFile_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll"), Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_WrongArchitecture_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x86").IsValid);
[Fact] public void Manifest_WrongProductYear_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2025, "x64").IsValid);
[Fact] public async Task Synchronizer_UsesStaging_AndRetainsPreviousVersion()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
[Fact] public async Task Synchronizer_HashMismatch_IsRejected()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
{ var p = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new ProductInstallation("C:\\acad.exe", "2026", "x64", []), "Pilot", "C:\\script.scr", true)); Assert.False(p.WouldStart); }
[Fact] public void Core_HasNoAutodeskOrProcessReferences() => Assert.DoesNotContain(typeof(PackageManifest).Assembly.GetReferencedAssemblies(), x => x.Name!.Contains("Autodesk") || x.Name.Contains("Diagnostics.Process"));
[Fact] public async Task PRecAnnotPilot_DryRun_ValidatesActivatesAndPreparesWithoutStarting()
{
var fixture = await System.IO.File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "fixtures", "p-rec-annot-pilot.manifest.json"));
var parsed = PackageManifestJson.Parse(fixture); var bytes = new byte[] { 7 }; var hash = Convert.ToHexString(SHA256.HashData(bytes));
var manifest = parsed with { Files = [new PackageFile(parsed.EntryAssembly, bytes.Length, hash)] };
Assert.True(new PackageManifestValidator().Validate(manifest, 2026, "x64").IsValid);
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "fixture"); Directory.CreateDirectory(source); await System.IO.File.WriteAllBytesAsync(Path.Combine(source, manifest.EntryAssembly), bytes);
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); }
finally { Directory.Delete(root, true); }
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><IsPackable>false</IsPackable></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
</ItemGroup>
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
</Project>
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"packageId": "P_Rec_Annot",
"version": "2026.1.0-pilot",
"product": "Plant3D",
"productYear": 2026,
"architecture": "x64",
"entryAssembly": "PRecAnnot.Plant3D2026.dll",
"files": [
{
"path": "PRecAnnot.Plant3D2026.dll",
"length": 1,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
],
"startup": { "type": "netload", "command": "PHP_REC_ANNOT" },
"rolloutChannel": "pilot",
"rollbackSafe": true
}