diff --git a/Directory.Build.props b/Directory.Build.props
new file mode 100644
index 0000000..0ff213c
--- /dev/null
+++ b/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ net8.0
+ x64
+ enable
+ enable
+ true
+ latest
+
+
diff --git a/Directory.Build.targets b/Directory.Build.targets
new file mode 100644
index 0000000..058246e
--- /dev/null
+++ b/Directory.Build.targets
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.App/App.xaml b/Launcher.Next.App/App.xaml
new file mode 100644
index 0000000..23e67d3
--- /dev/null
+++ b/Launcher.Next.App/App.xaml
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.App/App.xaml.cs b/Launcher.Next.App/App.xaml.cs
new file mode 100644
index 0000000..8e77aec
--- /dev/null
+++ b/Launcher.Next.App/App.xaml.cs
@@ -0,0 +1 @@
+namespace Launcher.Next.App; public partial class App : System.Windows.Application { }
diff --git a/Launcher.Next.App/Launcher.Next.App.csproj b/Launcher.Next.App/Launcher.Next.App.csproj
new file mode 100644
index 0000000..837d159
--- /dev/null
+++ b/Launcher.Next.App/Launcher.Next.App.csproj
@@ -0,0 +1 @@
+WinExenet8.0-windowstruetrue
diff --git a/Launcher.Next.App/MainWindow.xaml b/Launcher.Next.App/MainWindow.xaml
new file mode 100644
index 0000000..d43cfdb
--- /dev/null
+++ b/Launcher.Next.App/MainWindow.xaml
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.App/MainWindow.xaml.cs b/Launcher.Next.App/MainWindow.xaml.cs
new file mode 100644
index 0000000..2b0bbe3
--- /dev/null
+++ b/Launcher.Next.App/MainWindow.xaml.cs
@@ -0,0 +1 @@
+namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); }
diff --git a/Launcher.Next.Application/Launcher.Next.Application.csproj b/Launcher.Next.Application/Launcher.Next.Application.csproj
new file mode 100644
index 0000000..bfbb671
--- /dev/null
+++ b/Launcher.Next.Application/Launcher.Next.Application.csproj
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.Application/PrepareLaunchUseCase.cs b/Launcher.Next.Application/PrepareLaunchUseCase.cs
new file mode 100644
index 0000000..8236250
--- /dev/null
+++ b/Launcher.Next.Application/PrepareLaunchUseCase.cs
@@ -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 { ["version"] = product.ProductVersion, ["architecture"] = product.Architecture });
+ var prepared = launcher.Prepare(new(product, profile, scriptPath, true));
+ telemetry.Write("LaunchPrepared", new Dictionary { ["dryRun"] = true });
+ return prepared;
+ }
+}
diff --git a/Launcher.Next.Autodesk/AutodeskAdapters.cs b/Launcher.Next.Autodesk/AutodeskAdapters.cs
new file mode 100644
index 0000000..05c5964
--- /dev/null
+++ b/Launcher.Next.Autodesk/AutodeskAdapters.cs
@@ -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);
+ }
+}
diff --git a/Launcher.Next.Autodesk/Launcher.Next.Autodesk.csproj b/Launcher.Next.Autodesk/Launcher.Next.Autodesk.csproj
new file mode 100644
index 0000000..ef4ca42
--- /dev/null
+++ b/Launcher.Next.Autodesk/Launcher.Next.Autodesk.csproj
@@ -0,0 +1 @@
+net8.0-windows
diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs
new file mode 100644
index 0000000..2f325cd
--- /dev/null
+++ b/Launcher.Next.Core/Contracts.cs
@@ -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 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 Files, StartupDefinition Startup,
+ string? MinimumLauncherVersion = null, IReadOnlyList? CompatibleAutoCadSeries = null,
+ string? RequiredRuntime = null, IReadOnlyList? Dependencies = null,
+ IReadOnlyList? 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 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 Arguments, string WorkingDirectory, bool WouldStart);
+
+public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
+public interface IPackageCatalogClient { Task GetManifestAsync(string packageId, CancellationToken cancellationToken); }
+public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
+public interface IPackageSynchronizer { Task SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress? progress = null); }
+public interface ILocalPackageStore { Task GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
+public interface IPackageActivator { Task ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
+public interface IRollbackManager { Task 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? fields = null); }
+public interface IClock { DateTimeOffset UtcNow { get; } }
+public interface IFileHasher { Task Sha256Async(string path, CancellationToken cancellationToken); }
diff --git a/Launcher.Next.Core/Launcher.Next.Core.csproj b/Launcher.Next.Core/Launcher.Next.Core.csproj
new file mode 100644
index 0000000..6b512ec
--- /dev/null
+++ b/Launcher.Next.Core/Launcher.Next.Core.csproj
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.Core/ManifestValidator.cs b/Launcher.Next.Core/ManifestValidator.cs
new file mode 100644
index 0000000..c1284f4
--- /dev/null
+++ b/Launcher.Next.Core/ManifestValidator.cs
@@ -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();
+ 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(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);
+ }
+}
diff --git a/Launcher.Next.Core/PackageManifestJson.cs b/Launcher.Next.Core/PackageManifestJson.cs
new file mode 100644
index 0000000..67313d6
--- /dev/null
+++ b/Launcher.Next.Core/PackageManifestJson.cs
@@ -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(json, Options) ?? throw new InvalidOperationException("MANIFEST_EMPTY");
+}
diff --git a/Launcher.Next.Infrastructure/Launcher.Next.Infrastructure.csproj b/Launcher.Next.Infrastructure/Launcher.Next.Infrastructure.csproj
new file mode 100644
index 0000000..bfbb671
--- /dev/null
+++ b/Launcher.Next.Infrastructure/Launcher.Next.Infrastructure.csproj
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.Infrastructure/LocalPackageStore.cs b/Launcher.Next.Infrastructure/LocalPackageStore.cs
new file mode 100644
index 0000000..e7c2b37
--- /dev/null
+++ b/Launcher.Next.Infrastructure/LocalPackageStore.cs
@@ -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 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 ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null;
+ public async Task GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
+ public async Task GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
+ public async Task 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 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 SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress? 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; }
+ }
+}
diff --git a/Launcher.Next.Observability/JsonlTelemetry.cs b/Launcher.Next.Observability/JsonlTelemetry.cs
new file mode 100644
index 0000000..72ef065
--- /dev/null
+++ b/Launcher.Next.Observability/JsonlTelemetry.cs
@@ -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? fields = null)
+ { try { Directory.CreateDirectory(logRoot); var allowed = (fields ?? new Dictionary()).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 { } }
+}
diff --git a/Launcher.Next.Observability/Launcher.Next.Observability.csproj b/Launcher.Next.Observability/Launcher.Next.Observability.csproj
new file mode 100644
index 0000000..bfbb671
--- /dev/null
+++ b/Launcher.Next.Observability/Launcher.Next.Observability.csproj
@@ -0,0 +1 @@
+
diff --git a/Launcher.Next.sln b/Launcher.Next.sln
new file mode 100644
index 0000000..3698f75
--- /dev/null
+++ b/Launcher.Next.sln
@@ -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
diff --git a/docs/foundation.md b/docs/foundation.md
new file mode 100644
index 0000000..d912a62
--- /dev/null
+++ b/docs/foundation.md
@@ -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
+```
diff --git a/tests/Launcher.Next.Tests/FoundationTests.cs b/tests/Launcher.Next.Tests/FoundationTests.cs
new file mode 100644
index 0000000..d4bcf67
--- /dev/null
+++ b/tests/Launcher.Next.Tests/FoundationTests.cs
@@ -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(() => 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