Author SHA1 Message Date
codex 175fcef8a9 feat: add Launcher Next .NET 8 foundation 2026-07-31 14:56:56 +03:00
25 changed files with 317 additions and 111 deletions
-111
View File
@@ -1,111 +0,0 @@
# AGENTS.md — Launcher Next
## Назначение
Launcher Next — отдельная программа для доставки, activation, запуска и
rollback корпоративных Plant 3D 2026 plugin packages. Она заменяет legacy
цепочку `launcher → P_Serv_Starter → BAT → LISP → OrthoLoader → 20 NETLOAD`:
`Launcher Next → manifest → local staging → SHA-256 validation → versioned activation → one startup NETLOAD → Plant 3D 2026 → entry assembly → wait → automatic rollback`.
Репозиторий `C:\dev\launcher-next` независим. Legacy launcher repositories
используются только read-only.
## Приложение и режимы
Существует одна видимая программа: `Launcher.Next.App`. Не создавать отдельные
test/pilot launchers с отдельным composition root; Pilot CLI — режим того же
приложения.
Launcher modes: `Legacy`, `DryRun`, `NextPilot`, `NextPreferred`. Допустимые
plugin modes: `Smoke`, `DryRun`; Apply требует отдельного задания. Без explicit
pilot configuration безопасные defaults: `DryRun`, `PilotEnabled=false`,
`AllowProcessStart=false`. Pilot CLI читает реальную local configuration, а не
создаёт safe-default вместо отсутствующего файла.
На test machine реальный Process.Start разрешён, если задача требует runtime
проверку и safety gates выполнены. Не ставить лишний review barrier после
foundation/interface/composition root/Prepare: задача real pilot должна дойти
до Execute, process, checkpoint, ProcessExited, automatic rollback,
`RolledBack`, documentation и push.
## Plant 3D launch contract
Подтверждённый Plant 3D 2026 contract:
```text
/product PLNT3D /language ru-RU /b <generated-startup-script>
```
Не передавать `/p`, пока profile не доказан отдельным runtime evidence. Всегда
использовать `ProcessStartInfo.ArgumentList`. Передавать
`P_REC_ANNOT_RUNTIME_MODE=Smoke` или `DryRun` из pilot configuration.
## Pilot root, manifest и package
Разрешённый isolated root: `C:\AutoCAD User Support\LauncherNextPilot`. Он не
смешивается с `C:\AutoCAD User Support\Applications`. Запрещены UNC/relative
roots, Program Files, Autodesk ApplicationPlugins, repository directory, path
traversal, symlink/junction escape и writes в legacy root.
Manifest содержит schema, package ID/version, product/year/series,
architecture, entry assembly, rollout channel, rollback safety, relative files,
length и SHA-256. Блокировать Autodesk DLL, PDB, test/legacy assemblies,
absolute/path-traversal paths, missing entry assembly, changed files и hash
mismatch.
## Prepare, Execute и startup script
`PrepareAsync` читает configuration, discovers product, validates manifest and
source, creates deterministic LaunchPlan/script content/process specification и
не создаёт activation state/script/process.
`ExecuteAsync` повторно проверяет gate и one-time permit, сохраняет
ActivationSnapshot, использует unique staging, повторно проверяет hashes,
атомарно activates version/pointers, materializes script, повторно проверяет
process state, запускает один process и сохраняет PID/session. Не подменять
service ручным PowerShell copying.
Startup script содержит ровно один `_.NETLOAD` только entry assembly из
versioned local directory; он не запускает commands/LISP/OrthoLoader, не меняет
TRUSTEDPATHS, не ссылается на UNC и не NETLOAD internal dependencies.
## Session и rollback
States: `Prepared`, `Activating`, `Activated`, `ProcessStarting`,
`ProcessStarted`, `WaitingForRuntimeCheck`, `ProcessExited`, `RollbackStarted`,
`RolledBack`, `Failed`. Session atomically persisted; permit token никогда не
пишется. Active/incomplete session блокирует второй run.
Automatic rollback выполняется после завершения конкретного PID. Он читает
persisted session/snapshot, восстанавливает exact current/previous pointers,
удаляет startup script и pilot version, очищает staging, идемпотентен и работает
после перезапуска. Проверять фактические файлы, не только сообщение.
CLI: `pilot prepare`, `pilot run`, `pilot status`, `pilot rollback`.
`pilot run --wait-for-exit --rollback-after-exit` не завершается сразу после
Process.Start — он ждёт AutoCAD и rollback.
## Legacy isolation, documentation и tests
Launcher Next не изменяет legacy launcher/P_Serv_Starter/BAT/LISP/
app_netload.lsp/OrthoLoader/Settings.xml, AutoCAD profile, registry или
TRUSTEDPATHS. Допустима read-only hash verification.
Документировать цепочку `CLI → configuration → manifest → validation → Prepare
→ permit → snapshot → staging → activation/pointers → script → Process.Start →
PID → NETLOAD → plugin → wait → ProcessExited → rollback → RolledBack` с
реальными классами/методами.
Tests не запускают AutoCAD; process invoker заменяется fake. Обязательны parser,
gates, manifest/hash, staging/atomic activation, permit, arguments, persistence,
wait, automatic/standalone/idempotent rollback и legacy isolation. Реальный
Plant runtime фиксируется отдельным evidence.
## Definition of Done
Launcher slice завершён, когда normal UI сохранён, CLI вызывает реальный
application service, build без warnings, tests passed, требуемый runtime pilot
выполнен, assembly loaded from versioned directory, PID закрыт штатно, rollback
и `RolledBack` проверены, механика документирована, ветка published обычным
push.
+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
}