diff --git a/.github/workflows/headless-portability.yml b/.github/workflows/headless-portability.yml index 801f3476..757827b9 100644 --- a/.github/workflows/headless-portability.yml +++ b/.github/workflows/headless-portability.yml @@ -8,6 +8,7 @@ on: - "src/AcDream.Platform/**" - "src/AcDream.Launcher.Core/**" - "src/AcDream.Launcher/**" + - "src/AcDream.Bake/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -19,6 +20,7 @@ on: - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Launcher.Core.Tests/**" - "tests/AcDream.Launcher.Tests/**" + - "tests/AcDream.Bake.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -36,6 +38,7 @@ on: - "src/AcDream.Platform/**" - "src/AcDream.Launcher.Core/**" - "src/AcDream.Launcher/**" + - "src/AcDream.Bake/**" - "src/AcDream.Core/**" - "src/AcDream.Core.Net/**" - "src/AcDream.Content/**" @@ -47,6 +50,7 @@ on: - "tests/AcDream.Platform.Tests/**" - "tests/AcDream.Launcher.Core.Tests/**" - "tests/AcDream.Launcher.Tests/**" + - "tests/AcDream.Bake.Tests/**" - "tests/AcDream.Core.Tests/**" - "tests/AcDream.Core.Net.Tests/**" - "tests/AcDream.Content.Tests/**" @@ -80,7 +84,7 @@ jobs: dotnet-version: "10.0.x" # No apt step here on purpose. This job's whole claim is that the closure - # below is presentation-free: it builds Plugin.Abstractions, Core, + # below is presentation-free: it builds Bake, Plugin.Abstractions, Core, # Core.Net, Content, Runtime and Headless, runs their tests, and invokes # the Headless CLI. Nothing in it opens a display, links GL, or calls # xvfb-run, so an "install the graphical smoke dependencies" step here was @@ -94,6 +98,7 @@ jobs: $projects = @( "src/AcDream.Platform/AcDream.Platform.csproj", "src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj", + "src/AcDream.Bake/AcDream.Bake.csproj", "src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj", "src/AcDream.Core/AcDream.Core.csproj", "src/AcDream.Core.Net/AcDream.Core.Net.csproj", @@ -116,6 +121,7 @@ jobs: $projects = @( "tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj", "tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj", + "tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", "tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj", "tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj", "tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj", @@ -166,17 +172,34 @@ jobs: dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Publish the self-contained Linux launcher - if: runner.os == 'Linux' + - name: Publish the self-contained launcher distribution shell: pwsh run: | + $rid = if ($IsWindows) { "win-x64" } else { "linux-x64" } dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj ` -c Release ` - -r linux-x64 ` - -o artifacts/acdream-launcher-linux-x64 + -r $rid ` + -o "artifacts/acdream-launcher-$rid" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - - name: Verify self-contained property and artifact execution + - name: Verify self-contained Windows launcher and bake artifacts + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = "artifacts/acdream-launcher-win-x64" + if (-not (Test-Path -LiteralPath "$root/acdream-launcher.exe" -PathType Leaf)) { throw "launcher executable missing" } + if (-not (Test-Path -LiteralPath "$root/acdream-bake.exe" -PathType Leaf)) { throw "bake executable missing" } + if (Test-Path -LiteralPath "$root/acdream-launcher.dll") { throw "launcher is not single-file" } + if (Test-Path -LiteralPath "$root/acdream-bake.dll") { throw "bake is not single-file" } + $env:DOTNET_ROOT = "Z:\definitely-not-installed" + $env:DOTNET_ROOT_X64 = "Z:\definitely-not-installed" + $env:DOTNET_MULTILEVEL_LOOKUP = "0" + & "$root/acdream-launcher.exe" --verify-publish + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & "$root/acdream-bake.exe" --help + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Verify self-contained Linux launcher and bake artifacts if: runner.os == 'Linux' shell: bash run: | @@ -189,11 +212,17 @@ jobs: -getProperty:SelfContained | tr -d '\r\n ') test "$self_contained" = true test -x "$root/acdream-launcher" + test -x "$root/acdream-bake" test ! -f "$root/acdream-launcher.dll" + test ! -f "$root/acdream-bake.dll" DOTNET_ROOT=/definitely-not-installed \ DOTNET_ROOT_X64=/definitely-not-installed \ DOTNET_MULTILEVEL_LOOKUP=0 \ "$root/acdream-launcher" --verify-publish + DOTNET_ROOT=/definitely-not-installed \ + DOTNET_ROOT_X64=/definitely-not-installed \ + DOTNET_MULTILEVEL_LOOKUP=0 \ + "$root/acdream-bake" --help linux-graphical: runs-on: ubuntu-latest diff --git a/AcDream.slnx b/AcDream.slnx index 50f27be6..6080bb7f 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -27,6 +27,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 2f0f0f5a..2fcef60c 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -301,6 +301,9 @@ src/ AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0) ApplicationPathSet.cs -> shared XDG/Windows config, data, cache, plugin, screenshot, and diagnostic paths + BakePublicationGuardPaths.cs + -> shared launcher/Bake environment nonce and + adjacent publication lock/token naming contract -> zero project/package references (guarded by tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs); Runtime and App reference it directly; Headless reaches it @@ -313,12 +316,28 @@ src/ Status/ -> incremental host-status parsing/tailing Orchestration/ -> immutable UI snapshots, typed actions, capability gates, and running-session lifetime + Installation/ -> portable four-DAT validation, Windows retail + path discovery, versioned JSONL bake-process + orchestration, and atomic SHA/size/tool-version + install-record verification and recovery; one + OS-handle lease serializes recovery/install per + DataDirectory; a second OS-held publication + lock plus durable per-transaction nonce makes + late orphan Bake children irrevocably stale + before recovery, while already-authorized + promotion completes before recovery; only exact + adjacent + `..acdream-bake..tmp` files are + transaction-owned crash residue -> references Platform only; no Avalonia or game-host dependency AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell - ViewModels/ -> thin MVVM projection over Launcher.Core + ViewModels/ -> thin MVVM projection over Launcher.Core, + including the first-run DAT/bake wizard -> references Launcher.Core only (Platform transitively); it never owns a second profile, process, status, or credential state graph + -> every per-RID publish composes the separately published self-contained + `acdream-bake` executable beside the launcher without a project edge -> Linux launcher/probe/headless flows remain portable; graphical-client actions are explicitly disabled until Modern Runtime Slice L resumes diff --git a/src/AcDream.Bake/AcDream.Bake.csproj b/src/AcDream.Bake/AcDream.Bake.csproj index 9b04f741..3557add4 100644 --- a/src/AcDream.Bake/AcDream.Bake.csproj +++ b/src/AcDream.Bake/AcDream.Bake.csproj @@ -12,6 +12,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/src/AcDream.Bake/BakeCommandLine.cs b/src/AcDream.Bake/BakeCommandLine.cs new file mode 100644 index 00000000..ffdfb615 --- /dev/null +++ b/src/AcDream.Bake/BakeCommandLine.cs @@ -0,0 +1,133 @@ +using System.Globalization; + +namespace AcDream.Bake; + +internal sealed record BakeCommandLineOptions( + string DatDirectory, + string OutputPath, + HashSet? IdFilter, + HashSet? LandblockFilter, + int Threads, + bool ProgressJson); + +internal static class BakeCommandLine +{ + internal const string Usage = + "usage: acdream-bake --dat-dir [--out ] " + + "[--ids 0xId,0xId,...] [--landblocks 0xId,...] " + + "[--threads ] [--progress-json]\n" + + " acdream-bake --help"; + + public static bool IsHelpRequest(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + return args.Count == 1 + && args[0] is "--help" or "-h"; + } + + public static bool TryParse( + IReadOnlyList args, + TextWriter error, + out BakeCommandLineOptions? options) + { + ArgumentNullException.ThrowIfNull(args); + ArgumentNullException.ThrowIfNull(error); + + string? datDirectory = null; + string? outputPath = null; + HashSet? idFilter = null; + HashSet? landblockFilter = null; + int threads = Environment.ProcessorCount; + bool progressJson = false; + + for (int i = 0; i < args.Count; i++) + { + switch (args[i]) + { + case "--dat-dir": + datDirectory = Value(args, ref i); + break; + case "--out": + outputPath = Value(args, ref i); + break; + case "--ids": + idFilter = ParseHexList(Value(args, ref i), error); + break; + case "--landblocks": + landblockFilter = ParseHexList(Value(args, ref i), error); + break; + case "--threads": + if (int.TryParse( + Value(args, ref i), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsedThreads) + && parsedThreads > 0) + { + threads = parsedThreads; + } + break; + case "--progress-json": + progressJson = true; + break; + default: + error.WriteLine($"unrecognized argument: {args[i]}"); + options = null; + return false; + } + } + + if (string.IsNullOrWhiteSpace(datDirectory)) + { + error.WriteLine(Usage); + options = null; + return false; + } + + outputPath ??= Path.Combine(datDirectory, "acdream.pak"); + options = new BakeCommandLineOptions( + datDirectory, + outputPath, + idFilter, + landblockFilter, + threads, + progressJson); + return true; + } + + private static string? Value(IReadOnlyList args, ref int index) => + index + 1 < args.Count ? args[++index] : null; + + private static HashSet ParseHexList(string? raw, TextWriter error) + { + var result = new HashSet(); + if (string.IsNullOrWhiteSpace(raw)) + { + return result; + } + + foreach (string token in raw.Split( + ',', + StringSplitOptions.RemoveEmptyEntries + | StringSplitOptions.TrimEntries)) + { + string hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + ? token[2..] + : token; + if (uint.TryParse( + hex, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out uint value)) + { + result.Add(value); + } + else + { + error.WriteLine($"warning: could not parse id '{token}' - skipped"); + } + } + + return result; + } +} diff --git a/src/AcDream.Bake/BakeOutputTransaction.cs b/src/AcDream.Bake/BakeOutputTransaction.cs index 0bc58e16..34f5cf16 100644 --- a/src/AcDream.Bake/BakeOutputTransaction.cs +++ b/src/AcDream.Bake/BakeOutputTransaction.cs @@ -9,11 +9,28 @@ namespace AcDream.Bake; /// public static class BakeOutputTransaction { + internal const string StagingMarker = ".acdream-bake."; + public static TResult WriteValidateAndPublish( string destinationPath, Func writeTemporary, Action validateTemporary, CancellationToken cancellationToken = default) + => WriteValidateAndPublish( + destinationPath, + writeTemporary, + validateTemporary, + beforePublicationLock: null, + beforePromotion: null, + cancellationToken); + + internal static TResult WriteValidateAndPublish( + string destinationPath, + Func writeTemporary, + Action validateTemporary, + Action? beforePublicationLock, + Action? beforePromotion, + CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); ArgumentNullException.ThrowIfNull(writeTemporary); @@ -25,9 +42,7 @@ public static class BakeOutputTransaction throw new InvalidOperationException("destination has no parent directory"); Directory.CreateDirectory(directory); - string temporaryPath = Path.Combine( - directory, - $".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp"); + string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid()); try { @@ -36,6 +51,14 @@ public static class BakeOutputTransaction cancellationToken.ThrowIfCancellationRequested(); validateTemporary(temporaryPath, result); cancellationToken.ThrowIfCancellationRequested(); + beforePublicationLock?.Invoke(); + using IDisposable? publication = + BakePublicationGuard.AcquireIfRequested( + fullDestination, + cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + beforePromotion?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); // Same-volume MoveFileEx/rename is the publication primitive. // File.Replace additionally performs destination metadata/backup @@ -60,4 +83,21 @@ public static class BakeOutputTransaction } } } + + /// + /// Exact adjacent staging-name contract shared, by documentation and + /// conformance tests, with Launcher.Core. Keeping this tiny contract in + /// each BCL-facing assembly avoids an otherwise inverted project edge. + /// + internal static string CreateStagingPath(string destinationPath, Guid transactionId) + { + string fullDestination = Path.GetFullPath(destinationPath); + string directory = Path.GetDirectoryName(fullDestination) + ?? throw new InvalidOperationException( + "destination has no parent directory"); + return Path.Combine( + directory, + $".{Path.GetFileName(fullDestination)}{StagingMarker}" + + $"{transactionId:N}.tmp"); + } } diff --git a/src/AcDream.Bake/BakeProgressJsonWriter.cs b/src/AcDream.Bake/BakeProgressJsonWriter.cs new file mode 100644 index 00000000..8dd7ccfa --- /dev/null +++ b/src/AcDream.Bake/BakeProgressJsonWriter.cs @@ -0,0 +1,101 @@ +using System.Text.Json; + +namespace AcDream.Bake; + +public interface IBakeProgressSink +{ + void Started(uint bakeToolVersion, string outputPath); + + void Progress( + string phase, + long completed, + long total, + int failures, + double elapsedSeconds, + double etaSeconds, + long privateBytes, + long managedBytes); + + void Completed(uint bakeToolVersion, long outputBytes, int failures); + + void Error(string message); +} + +/// +/// Version-1 JSON-lines machine channel enabled only by +/// --progress-json. Ordinary human console lines remain unchanged and +/// share stdout; consumers identify these records by shape instead of +/// scraping human prose. +/// +public sealed class BakeProgressJsonWriter(TextWriter output) : IBakeProgressSink +{ + public const int CurrentVersion = 1; + + private readonly TextWriter _output = output + ?? throw new ArgumentNullException(nameof(output)); + private readonly object _gate = new(); + + public void Started(uint bakeToolVersion, string outputPath) => + Write(new + { + v = CurrentVersion, + e = "started", + t = DateTimeOffset.UtcNow, + bakeToolVersion, + outputPath, + }); + + public void Progress( + string phase, + long completed, + long total, + int failures, + double elapsedSeconds, + double etaSeconds, + long privateBytes, + long managedBytes) => + Write(new + { + v = CurrentVersion, + e = "progress", + t = DateTimeOffset.UtcNow, + phase, + completed, + total, + failures, + elapsedSeconds, + etaSeconds, + privateBytes, + managedBytes, + }); + + public void Completed(uint bakeToolVersion, long outputBytes, int failures) => + Write(new + { + v = CurrentVersion, + e = "completed", + t = DateTimeOffset.UtcNow, + bakeToolVersion, + outputBytes, + failures, + }); + + public void Error(string message) => + Write(new + { + v = CurrentVersion, + e = "error", + t = DateTimeOffset.UtcNow, + message, + }); + + private void Write(T value) + { + string line = JsonSerializer.Serialize(value); + lock (_gate) + { + _output.WriteLine(line); + _output.Flush(); + } + } +} diff --git a/src/AcDream.Bake/BakeProgressReporter.cs b/src/AcDream.Bake/BakeProgressReporter.cs new file mode 100644 index 00000000..954fddac --- /dev/null +++ b/src/AcDream.Bake/BakeProgressReporter.cs @@ -0,0 +1,34 @@ +namespace AcDream.Bake; + +internal static class BakeProgressReporter +{ + public static void Write( + TextWriter humanOutput, + IBakeProgressSink? machineOutput, + string phase, + long completed, + int total, + int failures, + TimeSpan elapsed, + double etaSeconds, + long privateBytes, + long managedBytes) + { + ArgumentNullException.ThrowIfNull(humanOutput); + humanOutput.WriteLine( + $"[{elapsed:hh\\:mm\\:ss}] extracted {completed:N0}/{total:N0}, " + + $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " + + $"ETA={etaSeconds:F0}s, " + + $"private={privateBytes / 1024.0 / 1024.0:F0}MB, " + + $"managed={managedBytes / 1024.0 / 1024.0:F0}MB"); + machineOutput?.Progress( + phase, + completed, + total, + failures, + elapsed.TotalSeconds, + etaSeconds, + privateBytes, + managedBytes); + } +} diff --git a/src/AcDream.Bake/BakePublicationGuard.cs b/src/AcDream.Bake/BakePublicationGuard.cs new file mode 100644 index 00000000..0b35a811 --- /dev/null +++ b/src/AcDream.Bake/BakePublicationGuard.cs @@ -0,0 +1,80 @@ +using AcDream.Platform; + +namespace AcDream.Bake; + +/// +/// Optional launcher authorization checked immediately before atomic +/// publication. Standalone Bake runs have no nonce environment variable and +/// retain the original unguarded behavior. +/// +internal static class BakePublicationGuard +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static IDisposable? AcquireIfRequested( + string outputPath, + CancellationToken cancellationToken) + { + string? nonce = Environment.GetEnvironmentVariable( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (nonce is null) + { + return null; + } + + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new InvalidOperationException( + "The launcher bake publication nonce is invalid."); + } + + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + FileStream? lease = null; + while (lease is null) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + lease = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None); + } + catch (IOException) + { + cancellationToken.WaitHandle.WaitOne(RetryDelay); + } + } + + try + { + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + string authorized = File.Exists(authorizationPath) + ? File.ReadAllText(authorizationPath) + : string.Empty; + if (!string.Equals(authorized, nonce, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "This bake process is no longer authorized to publish its output."); + } + + return lease; + } + catch + { + lease.Dispose(); + throw; + } + } + +} diff --git a/src/AcDream.Bake/BakeRunner.cs b/src/AcDream.Bake/BakeRunner.cs index 9ea6f1bb..6b60a2ea 100644 --- a/src/AcDream.Bake/BakeRunner.cs +++ b/src/AcDream.Bake/BakeRunner.cs @@ -22,6 +22,7 @@ public sealed record BakeOptions public HashSet? LandblockFilter { get; init; } public int Threads { get; init; } = System.Environment.ProcessorCount; public CancellationToken CancellationToken { get; init; } + public IBakeProgressSink? Progress { get; init; } } /// Compact result used by the full-scale gate and deterministic tests. @@ -89,6 +90,7 @@ public static class BakeRunner throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive"); options.CancellationToken.ThrowIfCancellationRequested(); + options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath); var totalStopwatch = Stopwatch.StartNew(); var report = BakeOutputTransaction.WriteValidateAndPublish( options.OutPath, @@ -112,6 +114,10 @@ public static class BakeRunner }; PrintSummary(report, options.OutPath); + options.Progress?.Completed( + report.Header.BakeToolVersion, + report.OutputBytes, + report.Failures); return report; } @@ -273,6 +279,8 @@ public static class BakeRunner failures.Count, stopwatch.Elapsed, lastProgressReport, + options.Progress, + "mesh", batchStart + BatchSize >= ordinaryWork.Count && envCatalog.UniqueGeometryCount == 0); } @@ -372,6 +380,8 @@ public static class BakeRunner failures.Count, stopwatch.Elapsed, lastProgressReport, + options.Progress, + "mesh", batchStart + BatchSize >= envCatalog.Groups.Count); } @@ -571,6 +581,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: false); } @@ -757,6 +769,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: false); } } @@ -769,6 +783,8 @@ public static class BakeRunner failures.Count, collisionStopwatch.Elapsed, lastProgressReport, + options.Progress, + "collision", final: true); writer.Finish(); @@ -910,6 +926,8 @@ public static class BakeRunner int failures, TimeSpan elapsed, Stopwatch lastProgressReport, + IBakeProgressSink? progress, + string phase, bool final) { if (!final && lastProgressReport.Elapsed.TotalSeconds < 5) @@ -920,11 +938,18 @@ public static class BakeRunner using var process = Process.GetCurrentProcess(); process.Refresh(); long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes; - Console.WriteLine( - $"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " + - $"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " + - $"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " + - $"managed={managedHeap / 1024.0 / 1024.0:F0}MB"); + long privateBytes = process.PrivateMemorySize64; + BakeProgressReporter.Write( + Console.Out, + progress, + phase, + done, + total, + failures, + elapsed, + etaSeconds, + privateBytes, + managedHeap); lastProgressReport.Restart(); } diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs index 2d7ee8c0..5aba455e 100644 --- a/src/AcDream.Bake/Program.cs +++ b/src/AcDream.Bake/Program.cs @@ -1,7 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using AcDream.Bake; // acdream-bake: offline CLI producing a versioned pak file containing every @@ -13,66 +9,41 @@ using AcDream.Bake; // // Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5. -string? datDir = null; -string? outPath = null; -HashSet? idFilter = null; -HashSet? landblockFilter = null; -int threads = Environment.ProcessorCount; - -for (int i = 0; i < args.Length; i++) { - switch (args[i]) { - case "--dat-dir": - datDir = args.ElementAtOrDefault(++i); - break; - case "--out": - outPath = args.ElementAtOrDefault(++i); - break; - case "--ids": - idFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--landblocks": - landblockFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--threads": - if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t; - break; - default: - Console.Error.WriteLine($"unrecognized argument: {args[i]}"); - return 2; - } +if (BakeCommandLine.IsHelpRequest(args)) +{ + Console.Out.WriteLine(BakeCommandLine.Usage); + return 0; } -if (string.IsNullOrWhiteSpace(datDir)) { - Console.Error.WriteLine("usage: acdream-bake --dat-dir [--out ] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads ]"); +if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command)) +{ return 2; } -if (!Directory.Exists(datDir)) { - Console.Error.WriteLine($"error: directory not found: {datDir}"); +if (!Directory.Exists(command!.DatDirectory)) +{ + Console.Error.WriteLine($"error: directory not found: {command.DatDirectory}"); return 2; } -outPath ??= Path.Combine(datDir, "acdream.pak"); - -return BakeRunner.Run(new BakeOptions { - DatDir = datDir, - OutPath = outPath, - IdFilter = idFilter, - LandblockFilter = landblockFilter, - Threads = threads, -}); - -static HashSet ParseHexList(string? raw) { - var result = new HashSet(); - if (string.IsNullOrWhiteSpace(raw)) return result; - foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token; - if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) { - result.Add(value); - } - else { - Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped"); - } - } - return result; +IBakeProgressSink? progress = command.ProgressJson + ? new BakeProgressJsonWriter(Console.Out) + : null; +try +{ + return BakeRunner.Run(new BakeOptions + { + DatDir = command.DatDirectory, + OutPath = command.OutputPath, + IdFilter = command.IdFilter, + LandblockFilter = command.LandblockFilter, + Threads = command.Threads, + Progress = progress, + }); +} +catch (Exception exception) +{ + progress?.Error(exception.Message); + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; } diff --git a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj index 85dc49f0..df9c9ad1 100644 --- a/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj +++ b/src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj @@ -14,6 +14,7 @@ + diff --git a/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs new file mode 100644 index 00000000..cefa567e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeOutputStagingContract.cs @@ -0,0 +1,72 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Exact adjacent temporary-file contract emitted by AcDream.Bake's +/// BakeOutputTransaction. This class intentionally has no Bake project +/// dependency: both sides pin the same documented format with conformance +/// tests so Launcher.Core remains BCL-only. +/// +internal static class BakeOutputStagingContract +{ + internal const string StagingMarker = ".acdream-bake."; + private const string Suffix = ".tmp"; + + internal static string CreateStagingPath( + string destinationPath, + Guid transactionId) + { + string fullDestination = Path.GetFullPath(destinationPath); + string directory = Path.GetDirectoryName(fullDestination) + ?? throw new InvalidOperationException( + "The prepared package path has no parent directory."); + return Path.Combine( + directory, + $".{Path.GetFileName(fullDestination)}{StagingMarker}" + + $"{transactionId:N}{Suffix}"); + } + + internal static bool IsOwnedStagingFileName( + string fileName, + string destinationFileName) + { + string prefix = $".{destinationFileName}{StagingMarker}"; + if (!fileName.StartsWith(prefix, StringComparison.Ordinal) + || !fileName.EndsWith(Suffix, StringComparison.Ordinal) + || fileName.Length != prefix.Length + 32 + Suffix.Length) + { + return false; + } + + ReadOnlySpan transaction = fileName.AsSpan(prefix.Length, 32); + return Guid.TryParseExact(transaction, "N", out _); + } + + internal static void DeleteOwnedStagingFiles(string destinationPath) + { + string fullDestination = Path.GetFullPath(destinationPath); + string? directory = Path.GetDirectoryName(fullDestination); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return; + } + + string destinationFileName = Path.GetFileName(fullDestination); + try + { + foreach (string candidate in Directory.EnumerateFiles(directory)) + { + if (IsOwnedStagingFileName( + Path.GetFileName(candidate), + destinationFileName)) + { + LauncherInstallRecordStore.TryDelete(candidate); + } + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best effort: these files are never launchable. A later startup + // retries exact-name cleanup under the publication lock. + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs new file mode 100644 index 00000000..24fbc48c --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs @@ -0,0 +1,198 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +/// The exact child-process contract for one launcher bake. +public sealed record BakeProcessRequest( + string ExecutablePath, + string DatDirectory, + string OutputPath, + int Threads, + string? PublicationNonce = null) +{ + public IReadOnlyList Arguments => + [ + "--dat-dir", + DatDirectory, + "--out", + OutputPath, + "--threads", + Threads.ToString(CultureInfo.InvariantCulture), + "--progress-json", + ]; +} + +public sealed record BakeProcessResult(int ExitCode, string StandardError); + +/// +/// Injectable child seam. Stdout is delivered as arbitrary chunks so the +/// versioned JSONL parser, rather than line-oriented process plumbing, owns +/// partial-record behavior. +/// +public interface IBakeProcessRunner +{ + Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default); +} + +public sealed class SystemBakeProcessRunner : IBakeProcessRunner +{ + private const int BufferSize = 4096; + private const int MaximumCapturedErrorCharacters = 32 * 1024; + + public async Task RunAsync( + BakeProcessRequest request, + Action onStandardOutput, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(onStandardOutput); + if (request.Threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(request), + "Bake thread count must be positive."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + ProcessStartInfo startInfo = CreateStartInfo(request); + + using var process = new Process { StartInfo = startInfo }; + if (!process.Start()) + { + throw new InvalidOperationException("The bake process could not be started."); + } + + // The bake consumes no credential or other stdin input. + process.StandardInput.Close(); + + var standardError = new StringBuilder(); + Task stdoutPump = PumpAsync( + process.StandardOutput, + onStandardOutput, + CancellationToken.None); + Task stderrPump = PumpAsync( + process.StandardError, + chunk => AppendBounded(standardError, chunk), + CancellationToken.None); + + using CancellationTokenRegistration cancellation = cancellationToken.Register( + static state => + { + var child = (Process)state!; + try + { + if (!child.HasExited) + { + child.Kill(entireProcessTree: true); + } + } + catch + { + // The cancellation token remains authoritative. Races with + // natural exit or handle teardown do not replace it. + } + }, + process); + + try + { + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + await Task.WhenAll(stdoutPump, stderrPump).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return new BakeProcessResult(process.ExitCode, standardError.ToString()); + } + catch (OperationCanceledException) + { + try + { + using var cleanupTimeout = new CancellationTokenSource( + TimeSpan.FromSeconds(5)); + await process.WaitForExitAsync(cleanupTimeout.Token) + .ConfigureAwait(false); + await Task.WhenAll(stdoutPump, stderrPump) + .WaitAsync(cleanupTimeout.Token) + .ConfigureAwait(false); + } + catch + { + // Preserve cancellation. The process kill registration above + // already made the best effort to terminate the tree. + } + + throw; + } + } + + internal static ProcessStartInfo CreateStartInfo(BakeProcessRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var startInfo = new ProcessStartInfo + { + FileName = request.ExecutablePath, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (string argument in request.Arguments) + { + startInfo.ArgumentList.Add(argument); + } + startInfo.Environment.Remove( + BakePublicationGuardPaths.NonceEnvironmentVariable); + if (request.PublicationNonce is not null) + { + if (!BakePublicationGuardPaths.IsValidNonce( + request.PublicationNonce)) + { + throw new ArgumentException( + "The bake publication nonce is invalid.", + nameof(request)); + } + + startInfo.Environment[ + BakePublicationGuardPaths.NonceEnvironmentVariable] = + request.PublicationNonce; + } + + return startInfo; + } + + private static async Task PumpAsync( + TextReader reader, + Action sink, + CancellationToken cancellationToken) + { + char[] buffer = new char[BufferSize]; + while (true) + { + int read = await reader.ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return; + } + + sink(new string(buffer, 0, read)); + } + } + + private static void AppendBounded(StringBuilder destination, string chunk) + { + int remaining = MaximumCapturedErrorCharacters - destination.Length; + if (remaining <= 0) + { + return; + } + + destination.Append(chunk.AsSpan(0, Math.Min(remaining, chunk.Length))); + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs new file mode 100644 index 00000000..608c602e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs @@ -0,0 +1,54 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Versioned machine-readable output from acdream-bake +/// --progress-json. Human output shares stdout but remains a distinct +/// event so the installer never derives state by scraping prose. +/// +public abstract record BakeProgressEvent(int Version, string EventName); + +public sealed record BakeStartedEvent( + int Version, + uint BakeToolVersion, + string? OutputPath) + : BakeProgressEvent(Version, "started"); + +public sealed record BakeWorkProgressEvent( + int Version, + string Phase, + long Completed, + long Total, + int Failures, + double ElapsedSeconds, + double EtaSeconds) + : BakeProgressEvent(Version, "progress"); + +public sealed record BakeCompletedEvent( + int Version, + uint BakeToolVersion, + long OutputBytes, + int Failures) + : BakeProgressEvent(Version, "completed"); + +public sealed record BakeErrorEvent(int Version, string Message) + : BakeProgressEvent(Version, "error"); + +public sealed record UnknownBakeProgressEvent( + int Version, + string EventName, + string RawLine) + : BakeProgressEvent(Version, EventName); + +public sealed record FutureBakeProgressEvent( + int Version, + string EventName, + string RawLine) + : BakeProgressEvent(Version, EventName); + +public sealed record MalformedBakeProgressEvent( + string RawLine, + string Reason) + : BakeProgressEvent(0, "malformed"); + +public sealed record BakeHumanOutputEvent(string Text) + : BakeProgressEvent(0, "human"); diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs new file mode 100644 index 00000000..cc4cafb5 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressJsonlParser.cs @@ -0,0 +1,230 @@ +using System.Text; +using System.Text.Json; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Incremental JSONL parser tolerant of arbitrary stream chunk boundaries. +/// Unknown event names and future protocol versions stay observable without +/// failing the bake; malformed known payloads are explicit typed events. +/// +public sealed class BakeProgressJsonlParser +{ + public const int CurrentVersion = 1; + + private readonly StringBuilder _pending = new(); + + public IReadOnlyList Append(string chunk) + { + ArgumentNullException.ThrowIfNull(chunk); + _pending.Append(chunk); + return Drain(completeFinalLine: false); + } + + public IReadOnlyList Complete() => + Drain(completeFinalLine: true); + + public static BakeProgressEvent ParseLine(string line) + { + ArgumentNullException.ThrowIfNull(line); + string trimmed = line.Trim(); + if (trimmed.Length == 0) + { + return new BakeHumanOutputEvent(string.Empty); + } + + if (trimmed[0] != '{') + { + return new BakeHumanOutputEvent(line.TrimEnd('\r')); + } + + try + { + using JsonDocument document = JsonDocument.Parse(trimmed); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !TryGetInt32(root, "v", out int version) + || !TryGetString(root, "e", out string? eventName)) + { + return Malformed(line, "JSON progress requires integer 'v' and string 'e'."); + } + + if (version != CurrentVersion) + { + return new FutureBakeProgressEvent(version, eventName!, line); + } + + return eventName switch + { + "started" => ParseStarted(root, version, line), + "progress" => ParseProgress(root, version, line), + "completed" => ParseCompleted(root, version, line), + "error" => ParseError(root, version, line), + _ => new UnknownBakeProgressEvent(version, eventName!, line), + }; + } + catch (JsonException ex) + { + return Malformed(line, ex.Message); + } + } + + private IReadOnlyList Drain(bool completeFinalLine) + { + var events = new List(); + while (true) + { + int newline = IndexOfNewline(_pending); + if (newline < 0) + { + break; + } + + string line = _pending.ToString(0, newline); + _pending.Remove(0, newline + 1); + events.Add(ParseLine(line)); + } + + if (completeFinalLine && _pending.Length > 0) + { + string line = _pending.ToString(); + _pending.Clear(); + events.Add(ParseLine(line)); + } + + return events; + } + + private static int IndexOfNewline(StringBuilder value) + { + for (int i = 0; i < value.Length; i++) + { + if (value[i] == '\n') + { + return i; + } + } + + return -1; + } + + private static BakeProgressEvent ParseStarted( + JsonElement root, + int version, + string raw) + { + if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion) + || bakeToolVersion == 0) + { + return Malformed(raw, "started requires a positive bakeToolVersion."); + } + + _ = TryGetString(root, "outputPath", out string? outputPath); + return new BakeStartedEvent(version, bakeToolVersion, outputPath); + } + + private static BakeProgressEvent ParseProgress( + JsonElement root, + int version, + string raw) + { + if (!TryGetString(root, "phase", out string? phase) + || !TryGetInt64(root, "completed", out long completed) + || !TryGetInt64(root, "total", out long total) + || !TryGetInt32(root, "failures", out int failures) + || !TryGetDouble(root, "elapsedSeconds", out double elapsedSeconds) + || !TryGetDouble(root, "etaSeconds", out double etaSeconds) + || completed < 0 + || total < 0 + || completed > total + || failures < 0 + || elapsedSeconds < 0 + || etaSeconds < 0) + { + return Malformed(raw, "progress payload has missing or invalid fields."); + } + + return new BakeWorkProgressEvent( + version, + phase!, + completed, + total, + failures, + elapsedSeconds, + etaSeconds); + } + + private static BakeProgressEvent ParseCompleted( + JsonElement root, + int version, + string raw) + { + if (!TryGetUInt32(root, "bakeToolVersion", out uint bakeToolVersion) + || !TryGetInt64(root, "outputBytes", out long outputBytes) + || !TryGetInt32(root, "failures", out int failures) + || bakeToolVersion == 0 + || outputBytes <= 0 + || failures < 0) + { + return Malformed(raw, "completed payload has missing or invalid fields."); + } + + return new BakeCompletedEvent( + version, + bakeToolVersion, + outputBytes, + failures); + } + + private static BakeProgressEvent ParseError( + JsonElement root, + int version, + string raw) => + TryGetString(root, "message", out string? message) + && !string.IsNullOrWhiteSpace(message) + ? new BakeErrorEvent(version, message) + : Malformed(raw, "error requires a non-empty message."); + + private static MalformedBakeProgressEvent Malformed(string raw, string reason) => + new(raw, reason); + + private static bool TryGetString( + JsonElement root, + string name, + out string? value) + { + value = null; + return root.TryGetProperty(name, out JsonElement element) + && element.ValueKind == JsonValueKind.String + && (value = element.GetString()) is not null; + } + + private static bool TryGetInt32(JsonElement root, string name, out int value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetInt32(out value); + } + + private static bool TryGetUInt32(JsonElement root, string name, out uint value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetUInt32(out value); + } + + private static bool TryGetInt64(JsonElement root, string name, out long value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetInt64(out value); + } + + private static bool TryGetDouble(JsonElement root, string name, out double value) + { + value = default; + return root.TryGetProperty(name, out JsonElement element) + && element.TryGetDouble(out value) + && double.IsFinite(value); + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs new file mode 100644 index 00000000..65d5eb92 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs @@ -0,0 +1,121 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Strict state machine for known v1 bake events. Human output, unknown v1 +/// events, and future versions are deliberately transparent; known v1 events +/// cannot be reordered, duplicated, or appended after the first terminal. +/// +internal sealed class BakeProgressProtocol +{ + private BakeProgressProtocolState _state; + + internal BakeStartedEvent? Started { get; private set; } + + internal BakeCompletedEvent? Completed { get; private set; } + + internal BakeErrorEvent? Error { get; private set; } + + internal string? Violation { get; private set; } + + internal bool Observe(BakeProgressEvent progressEvent) + { + ArgumentNullException.ThrowIfNull(progressEvent); + if (Violation is not null) + { + return false; + } + + switch (progressEvent) + { + case BakeHumanOutputEvent: + case UnknownBakeProgressEvent: + case FutureBakeProgressEvent: + return true; + case MalformedBakeProgressEvent malformed: + Reject($"Malformed bake progress: {malformed.Reason}"); + return false; + case BakeStartedEvent started: + if (_state != BakeProgressProtocolState.AwaitingStarted) + { + Reject(_state == BakeProgressProtocolState.Running + ? "The bake protocol emitted more than one v1 started event." + : "The bake protocol emitted a known event after its terminal event."); + return false; + } + + Started = started; + _state = BakeProgressProtocolState.Running; + return true; + case BakeWorkProgressEvent: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("progress")); + return false; + } + + return true; + case BakeCompletedEvent completed: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("completed")); + return false; + } + + Completed = completed; + _state = BakeProgressProtocolState.Completed; + return true; + case BakeErrorEvent error: + if (_state != BakeProgressProtocolState.Running) + { + Reject(KnownEventStateViolation("error")); + return false; + } + + Error = error; + _state = BakeProgressProtocolState.Error; + return true; + default: + Reject("The bake protocol emitted an unsupported known event."); + return false; + } + } + + internal void CompleteInput() + { + if (Violation is not null) + { + return; + } + + if (_state == BakeProgressProtocolState.AwaitingStarted) + { + Reject("The bake protocol did not emit a v1 started event first."); + } + else if (_state == BakeProgressProtocolState.Running) + { + Reject("The bake protocol ended without exactly one terminal event."); + } + } + + private string KnownEventStateViolation(string eventName) => _state switch + { + BakeProgressProtocolState.AwaitingStarted => + $"The bake protocol emitted v1 {eventName} before v1 started.", + BakeProgressProtocolState.Running => + $"The bake protocol emitted an invalid v1 {eventName} event.", + _ => "The bake protocol emitted a known event after its terminal event.", + }; + + private void Reject(string message) + { + Violation ??= message; + } + + private enum BakeProgressProtocolState + { + AwaitingStarted, + Running, + Completed, + Error, + } +} diff --git a/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs new file mode 100644 index 00000000..a3f409c6 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/BakePublicationGuardContract.cs @@ -0,0 +1,120 @@ +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// Launcher half of the environment-only Bake publication guard. Paths are +/// derived from the canonical output path, while a durable GUID nonce grants +/// one child permission to promote its already-validated adjacent staging +/// file. Every token mutation happens while the stable publication lock is +/// held. +/// +internal static class BakePublicationGuardContract +{ + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + internal static async ValueTask AcquireAsync( + string outputPath, + CancellationToken cancellationToken = default) + { + string lockPath = BakePublicationGuardPaths.GetPublishLockPath( + outputPath); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The bake publication lock has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return new PublicationLease(new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + options: FileOptions.None)); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + internal static void Authorize( + string outputPath, + string nonce, + PublicationLease lease) + { + ArgumentNullException.ThrowIfNull(lease); + if (!BakePublicationGuardPaths.IsValidNonce(nonce)) + { + throw new ArgumentException( + "The bake publication nonce must be a lowercase GUID in N format.", + nameof(nonce)); + } + + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + using var stream = new FileStream( + authorizationPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.WriteThrough); + using var writer = new StreamWriter(stream, leaveOpen: true); + writer.Write(nonce); + writer.Flush(); + stream.Flush(flushToDisk: true); + } + + internal static void Invalidate( + string outputPath, + PublicationLease lease, + string? onlyIfNonceMatches = null) + { + ArgumentNullException.ThrowIfNull(lease); + string authorizationPath = + BakePublicationGuardPaths.GetAuthorizationPath(outputPath); + if (!File.Exists(authorizationPath)) + { + return; + } + + if (onlyIfNonceMatches is not null) + { + string current = File.ReadAllText(authorizationPath); + + if (!string.Equals( + current, + onlyIfNonceMatches, + StringComparison.Ordinal)) + { + return; + } + } + + File.Delete(authorizationPath); + } + + internal sealed class PublicationLease : IAsyncDisposable + { + private readonly FileStream _stream; + + internal PublicationLease(FileStream stream) + { + _stream = stream; + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs new file mode 100644 index 00000000..72096a4f --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs @@ -0,0 +1,138 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Portable validation and Windows-only discovery for the four retail data +/// archives consumed by acdream-bake. Discovery is intentionally only +/// a list of conventional paths; validation is the same filesystem operation +/// on Windows and Linux, including for a manually entered path. +/// +public sealed class DatDirectoryLocator +{ + public static IReadOnlyList RequiredFileNames { get; } = + Array.AsReadOnly( + [ + "client_portal.dat", + "client_cell_1.dat", + "client_highres.dat", + "client_local_English.dat", + ]); + + private readonly bool _isWindows; + private readonly string[] _windowsCandidates; + private readonly Func _directoryExists; + private readonly Func _fileExists; + + public DatDirectoryLocator( + bool? isWindows = null, + IEnumerable? windowsCandidates = null, + Func? directoryExists = null, + Func? fileExists = null) + { + _isWindows = isWindows ?? OperatingSystem.IsWindows(); + _windowsCandidates = (windowsCandidates ?? DefaultWindowsCandidates()) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + _directoryExists = directoryExists ?? Directory.Exists; + _fileExists = fileExists ?? File.Exists; + } + + /// + /// Returns conventional Windows locations that actually exist, in + /// preference order. An existing but incomplete directory remains in the + /// result so the wizard can explain exactly which DATs are missing. + /// Linux returns an empty list and relies on the manual picker/path field. + /// + public IReadOnlyList Detect() + { + if (!_isWindows) + { + return []; + } + + return _windowsCandidates + .Where(_directoryExists) + .Select(Validate) + .ToArray(); + } + + public DatDirectoryValidation Validate(string? directory) + { + if (string.IsNullOrWhiteSpace(directory)) + { + return DatDirectoryValidation.Invalid( + directory ?? string.Empty, + "Choose the folder containing the retail DAT files.", + RequiredFileNames); + } + + string fullPath; + try + { + fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return DatDirectoryValidation.Invalid( + directory, + "The DAT directory path is not valid.", + RequiredFileNames); + } + + if (!_directoryExists(fullPath)) + { + return DatDirectoryValidation.Invalid( + fullPath, + "The DAT directory does not exist.", + RequiredFileNames); + } + + string[] missing = RequiredFileNames + .Where(fileName => !_fileExists(Path.Combine(fullPath, fileName))) + .ToArray(); + return missing.Length == 0 + ? DatDirectoryValidation.Valid(fullPath) + : DatDirectoryValidation.Invalid( + fullPath, + "The selected directory is missing required retail DAT files.", + missing); + } + + private static IEnumerable DefaultWindowsCandidates() + { + string userProfile = Environment.GetFolderPath( + Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userProfile)) + { + yield return Path.Combine( + userProfile, + "Documents", + "Asheron's Call"); + } + + yield return @"C:\Turbine\Asheron's Call"; + } +} + +public sealed record DatDirectoryValidation( + string Directory, + bool IsValid, + string Message, + IReadOnlyList MissingFileNames) +{ + internal static DatDirectoryValidation Valid(string directory) => + new( + directory, + true, + "All four required retail DAT files were found.", + []); + + internal static DatDirectoryValidation Invalid( + string directory, + string message, + IReadOnlyList missingFileNames) => + new(directory, false, message, missingFileNames); +} diff --git a/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs new file mode 100644 index 00000000..1fc7ec8e --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/InstallerTransactionLease.cs @@ -0,0 +1,60 @@ +namespace AcDream.Launcher.Core.Installation; + +/// +/// Cross-process ownership for every mutation or recovery of one launcher +/// DataDirectory. The persistent lock pathname is harmless; exclusivity is +/// owned by the open OS handle and therefore disappears if the process dies. +/// +internal sealed class InstallerTransactionLease : IAsyncDisposable +{ + internal const string LockFileName = ".install.lock"; + private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50); + + private readonly FileStream _stream; + + private InstallerTransactionLease(FileStream stream) + { + _stream = stream; + } + + internal static string GetLockPath(string dataDirectory) => + Path.Combine(Path.GetFullPath(dataDirectory), LockFileName); + + internal static async ValueTask AcquireAsync( + string dataDirectory, + CancellationToken cancellationToken = default) + { + string lockPath = GetLockPath(dataDirectory); + Directory.CreateDirectory( + Path.GetDirectoryName(lockPath) + ?? throw new InvalidOperationException( + "The installer lock path has no parent directory.")); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var stream = new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + bufferSize: 1, + FileOptions.None); + return new InstallerTransactionLease(stream); + } + catch (IOException) + { + await Task.Delay(RetryDelay, cancellationToken) + .ConfigureAwait(false); + } + } + } + + public ValueTask DisposeAsync() + { + _stream.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs new file mode 100644 index 00000000..f88dba2f --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -0,0 +1,488 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Launching; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +public enum InstallRecordVerificationState +{ + Missing, + Verified, + Invalid, +} + +public sealed record InstallRecordVerification( + InstallRecordVerificationState State, + LauncherInstallRecord? Record, + string Status) +{ + public bool IsVerified => State == InstallRecordVerificationState.Verified; +} + +/// +/// Versioned install-record persistence and startup verification. The record +/// is atomically replaced only after a complete package has been hashed; a +/// crash during a reinstall can recover the prior verified pak from the +/// adjacent backup before launch is enabled. +/// +public sealed class LauncherInstallRecordStore +{ + public const uint CurrentBakeToolVersion = 4; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; + + private readonly ApplicationPathSet _paths; + private readonly DatDirectoryLocator _datDirectories; + private readonly Func> _computeSha256; + + public LauncherInstallRecordStore( + ApplicationPathSet paths, + DatDirectoryLocator? datDirectories = null, + Func>? computeSha256 = null) + { + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + _datDirectories = datDirectories ?? new DatDirectoryLocator(); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + } + + public string DataDirectory => Path.GetFullPath(_paths.DataDirectory); + + public string RecordPath => Path.Combine(DataDirectory, "install.json"); + + public string PreparedAssetPath => Path.Combine( + DataDirectory, + "pak", + "acdream.pak"); + + public static string GetBackupPath(string preparedAssetPath) => + preparedAssetPath + ".previous-install"; + + public async Task LoadAndVerifyAsync( + CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + return await LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + internal async Task LoadAndVerifyUnderLeaseAsync( + CancellationToken cancellationToken = default) + { + if (!File.Exists(RecordPath)) + { + return new InstallRecordVerification( + InstallRecordVerificationState.Missing, + null, + "Client content is not installed. Complete the first-run setup."); + } + + LauncherInstallRecord? record; + try + { + await using FileStream stream = new( + RecordPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + using JsonDocument document = await JsonDocument.ParseAsync( + stream, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + JsonElement root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("version", out JsonElement version) + || !version.TryGetInt32(out _)) + { + return Invalid( + "The install record must contain an explicit integer version."); + } + + record = root.Deserialize(SerializerOptions); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException) + { + return Invalid($"The install record could not be read: {ex.Message}"); + } + + if (record is null) + { + return Invalid("The install record is empty."); + } + + string? contractError = ValidateRecordContract( + record, + requireCanonicalSerializedPaths: true); + if (contractError is not null) + { + return Invalid(contractError); + } + + string backupPath = GetBackupPath(record.PreparedAssetPath); + FileVerification current = await VerifyFileAsync( + record.PreparedAssetPath, + record, + cancellationToken) + .ConfigureAwait(false); + if (current.IsValid) + { + TryDelete(backupPath); + return Verified(record); + } + + // A process crash may occur after the old verified package was moved + // aside but before the replacement record was published. Verify the + // backup against the still-current record before restoring it. + FileVerification backup = await VerifyFileAsync( + backupPath, + record, + cancellationToken) + .ConfigureAwait(false); + if (backup.IsValid) + { + try + { + Directory.CreateDirectory( + Path.GetDirectoryName(record.PreparedAssetPath) + ?? throw new InvalidOperationException( + "The prepared asset path has no parent directory.")); + File.Move(backupPath, record.PreparedAssetPath, overwrite: true); + return Verified(record, "Recovered and verified the previous client content."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return Invalid( + $"The previous verified package could not be restored: {ex.Message}"); + } + } + + return Invalid(current.Status); + } + + public async Task SaveAtomicallyAsync( + LauncherInstallRecord record, + CancellationToken cancellationToken = default) + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + DataDirectory, + cancellationToken) + .ConfigureAwait(false); + await SaveAtomicallyUnderLeaseAsync(record, cancellationToken) + .ConfigureAwait(false); + } + + internal async Task SaveAtomicallyUnderLeaseAsync( + LauncherInstallRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + LauncherInstallRecord normalized = NormalizeForSave(record); + string? contractError = ValidateRecordContract( + normalized, + requireCanonicalSerializedPaths: true); + if (contractError is not null) + { + throw new InvalidDataException(contractError); + } + + string directory = Path.GetDirectoryName(RecordPath) + ?? throw new InvalidOperationException( + "The install record has no parent directory."); + Directory.CreateDirectory(directory); + string temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(RecordPath)}.{Guid.NewGuid():N}.tmp"); + + try + { + await using (FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + options: FileOptions.Asynchronous | FileOptions.WriteThrough)) + { + await JsonSerializer.SerializeAsync( + stream, + normalized, + SerializerOptions, + cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + stream.Flush(flushToDisk: true); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Move(temporaryPath, RecordPath, overwrite: true); + } + finally + { + TryDelete(temporaryPath); + } + } + + private string? ValidateRecordContract( + LauncherInstallRecord record, + bool requireCanonicalSerializedPaths) + { + if (record.Version != LauncherInstallRecord.CurrentRecordVersion) + { + return $"Install record version {record.Version} is not supported."; + } + + if (!record.HasIntegrityMetadata) + { + return "The install record is missing SHA-256, size, or bake-tool metadata."; + } + + if (record.BakeToolVersion != CurrentBakeToolVersion) + { + return $"Bake tool version {record.BakeToolVersion} is not supported; " + + $"version {CurrentBakeToolVersion} is required."; + } + + if (!IsSha256(record.PreparedAssetSha256)) + { + return "The install record contains an invalid SHA-256 digest."; + } + + if (string.IsNullOrWhiteSpace(record.PreparedAssetPath)) + { + return "The prepared asset path is missing."; + } + + if (string.IsNullOrWhiteSpace(record.DatDirectory)) + { + return "The DAT directory path is missing."; + } + + string canonicalPreparedPath = Path.GetFullPath(PreparedAssetPath); + if (requireCanonicalSerializedPaths + && !Path.IsPathFullyQualified(record.PreparedAssetPath)) + { + return "The prepared asset path must be absolute."; + } + + string recordedPreparedPath; + try + { + recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + return $"The prepared asset path is invalid: {ex.Message}"; + } + + if (!PathsEqual(recordedPreparedPath, canonicalPreparedPath)) + { + return "The install record does not point to the launcher's canonical " + + "DataDirectory/pak/acdream.pak path."; + } + + if (requireCanonicalSerializedPaths + && !CanonicalSpellingEquals( + record.PreparedAssetPath, + recordedPreparedPath, + trimEndingSeparator: false)) + { + return "The prepared asset path is not canonical."; + } + + if (requireCanonicalSerializedPaths + && !Path.IsPathFullyQualified(record.DatDirectory)) + { + return "The DAT directory path must be absolute."; + } + + DatDirectoryValidation datValidation = + _datDirectories.Validate(record.DatDirectory); + if (!datValidation.IsValid) + { + return datValidation.Message + + FormatMissing(datValidation.MissingFileNames); + } + + return requireCanonicalSerializedPaths + && !CanonicalSpellingEquals( + record.DatDirectory, + datValidation.Directory, + trimEndingSeparator: true) + ? "The DAT directory path is not canonical." + : null; + } + + private LauncherInstallRecord NormalizeForSave(LauncherInstallRecord record) + { + if (record.Version != LauncherInstallRecord.CurrentRecordVersion) + { + throw new InvalidDataException( + $"Install record version {record.Version} is not supported."); + } + + if (string.IsNullOrWhiteSpace(record.PreparedAssetPath)) + { + throw new InvalidDataException("The prepared asset path is missing."); + } + + DatDirectoryValidation datValidation = + _datDirectories.Validate(record.DatDirectory); + if (!datValidation.IsValid) + { + throw new InvalidDataException( + datValidation.Message + + FormatMissing(datValidation.MissingFileNames)); + } + + string recordedPreparedPath; + try + { + recordedPreparedPath = Path.GetFullPath(record.PreparedAssetPath); + } + catch (Exception ex) when (ex is ArgumentException + or NotSupportedException + or PathTooLongException) + { + throw new InvalidDataException( + $"The prepared asset path is invalid: {ex.Message}", + ex); + } + + if (!PathsEqual(recordedPreparedPath, PreparedAssetPath)) + { + throw new InvalidDataException( + "The install record does not point to the launcher's canonical " + + "DataDirectory/pak/acdream.pak path."); + } + + return record with + { + Version = LauncherInstallRecord.CurrentRecordVersion, + DatDirectory = datValidation.Directory, + PreparedAssetPath = Path.GetFullPath(PreparedAssetPath), + }; + } + + private async Task VerifyFileAsync( + string path, + LauncherInstallRecord record, + CancellationToken cancellationToken) + { + if (!File.Exists(path)) + { + return new FileVerification(false, "The prepared package is missing."); + } + + try + { + long length = new FileInfo(path).Length; + if (length != record.PreparedAssetSize) + { + return new FileVerification( + false, + $"The prepared package size changed (expected " + + $"{record.PreparedAssetSize}, found {length})."); + } + + string sha256 = await _computeSha256(path, cancellationToken) + .ConfigureAwait(false); + return FileIntegrity.Matches(sha256, record.PreparedAssetSha256) + ? new FileVerification(true, "Client content verified.") + : new FileVerification( + false, + "The prepared package SHA-256 does not match the install record."); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new FileVerification( + false, + $"The prepared package could not be verified: {ex.Message}"); + } + } + + private static InstallRecordVerification Verified( + LauncherInstallRecord record, + string status = "Client content SHA-256, size, and bake-tool version verified.") => + new(InstallRecordVerificationState.Verified, record, status); + + private static InstallRecordVerification Invalid(string status) => + new(InstallRecordVerificationState.Invalid, null, status); + + private static bool IsSha256(string value) => + value.Length == 64 && value.All(Uri.IsHexDigit); + + private static string FormatMissing(IReadOnlyList missing) => + missing.Count == 0 + ? string.Empty + : " Missing: " + string.Join(", ", missing) + "."; + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.TrimEndingDirectorySeparator(left), + Path.TrimEndingDirectorySeparator(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + + private static bool CanonicalSpellingEquals( + string serialized, + string canonical, + bool trimEndingSeparator) + { + string candidate = trimEndingSeparator + ? Path.TrimEndingDirectorySeparator(serialized) + : serialized; + return string.Equals( + candidate, + canonical, + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); + } + + internal static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch + { + // A stale temp/backup is never treated as a published record. The + // next startup verification retries cleanup/recovery. + } + } + + private sealed record FileVerification(bool IsValid, string Status); +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs new file mode 100644 index 00000000..f8de24a7 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -0,0 +1,561 @@ +using AcDream.Launcher.Core.Integrity; +using AcDream.Launcher.Core.Launching; +using AcDream.Platform; + +namespace AcDream.Launcher.Core.Installation; + +public enum LauncherInstallPhase +{ + Idle, + ValidatingDatFiles, + PreparingOutput, + BakingMeshes, + BakingCollision, + VerifyingPackage, + SavingRecord, + Completed, + Cancelled, + Failed, +} + +public sealed record LauncherInstallProgress( + LauncherInstallPhase Phase, + string Status, + long Completed = 0, + long Total = 0, + int Failures = 0, + double EtaSeconds = 0) +{ + public double Fraction => Total > 0 + ? Math.Clamp((double)Completed / Total, 0, 1) + : 0; +} + +public sealed record LauncherInstallResult(LauncherInstallRecord Record); + +public sealed class LauncherInstallException : Exception +{ + public LauncherInstallException(string message) + : base(message) + { + } + + public LauncherInstallException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +public interface ILauncherInstaller +{ + IReadOnlyList DetectDatDirectories(); + + DatDirectoryValidation ValidateDatDirectory(string? directory); + + Task LoadExistingAsync( + CancellationToken cancellationToken = default); + + Task InstallAsync( + string datDirectory, + int threads, + IProgress? progress = null, + CancellationToken cancellationToken = default); +} + +/// +/// BCL-only first-run transaction. It invokes the GL-free bake executable as +/// a child, consumes only its versioned JSONL records, verifies the published +/// pak, and atomically records the install. A prior verified package is moved +/// to an adjacent recovery slot and restored on every failure/cancellation +/// path, so a fake or crashed child cannot replace it with partial output. +/// +public sealed class LauncherInstaller : ILauncherInstaller +{ + private readonly string _bakeExecutablePath; + private readonly DatDirectoryLocator _datDirectories; + private readonly LauncherInstallRecordStore _recordStore; + private readonly IBakeProcessRunner _processRunner; + private readonly Func> _computeSha256; + private readonly SemaphoreSlim _installGate = new(1, 1); + + private LauncherInstallRecord? _verifiedRecord; + + public LauncherInstaller( + ApplicationPathSet paths, + string bakeExecutablePath, + DatDirectoryLocator? datDirectories = null, + LauncherInstallRecordStore? recordStore = null, + IBakeProcessRunner? processRunner = null, + Func>? computeSha256 = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(bakeExecutablePath); + _bakeExecutablePath = Path.GetFullPath(bakeExecutablePath); + _datDirectories = datDirectories ?? new DatDirectoryLocator(); + _computeSha256 = computeSha256 + ?? ((path, cancellationToken) => + FileIntegrity.ComputeSha256HexAsync(path, cancellationToken)); + _recordStore = recordStore + ?? new LauncherInstallRecordStore( + paths, + _datDirectories, + _computeSha256); + _processRunner = processRunner ?? new SystemBakeProcessRunner(); + } + + public IReadOnlyList DetectDatDirectories() => + _datDirectories.Detect(); + + public DatDirectoryValidation ValidateDatDirectory(string? directory) => + _datDirectories.Validate(directory); + + public async Task LoadExistingAsync( + CancellationToken cancellationToken = default) + { + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken) + .ConfigureAwait(false); + InstallRecordVerification verification = + await RecoverExistingUnderPublicationGuardAsync( + cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = verification.Record; + return verification; + } + finally + { + _installGate.Release(); + } + } + + public async Task InstallAsync( + string datDirectory, + int threads, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (threads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(threads), + "Bake thread count must be positive."); + } + + await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await using InstallerTransactionLease lease = + await InstallerTransactionLease.AcquireAsync( + _recordStore.DataDirectory, + cancellationToken) + .ConfigureAwait(false); + return await InstallCoreAsync( + datDirectory, + threads, + progress, + cancellationToken) + .ConfigureAwait(false); + } + finally + { + _installGate.Release(); + } + } + + private async Task InstallCoreAsync( + string datDirectory, + int threads, + IProgress? progress, + CancellationToken cancellationToken) + { + Report( + progress, + LauncherInstallPhase.ValidatingDatFiles, + "Validating the four retail DAT files..."); + DatDirectoryValidation validation = _datDirectories.Validate(datDirectory); + if (!validation.IsValid) + { + string message = validation.Message + + FormatMissing(validation.MissingFileNames); + Report(progress, LauncherInstallPhase.Failed, message); + throw new LauncherInstallException(message); + } + + if (!File.Exists(_bakeExecutablePath)) + { + string message = + $"The co-deployed bake tool is missing at '{_bakeExecutablePath}'."; + Report(progress, LauncherInstallPhase.Failed, message); + throw new LauncherInstallException(message); + } + + string outputPath = _recordStore.PreparedAssetPath; + string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath); + InstallRecordVerification existing = + await RecoverExistingUnderPublicationGuardAsync(cancellationToken) + .ConfigureAwait(false); + _verifiedRecord = existing.Record; + + Directory.CreateDirectory( + Path.GetDirectoryName(outputPath) + ?? throw new InvalidOperationException( + "The prepared package path has no parent directory.")); + + Report( + progress, + LauncherInstallPhase.PreparingOutput, + "Preparing the atomic package transaction..."); + bool previousPreserved = PreservePreviousPackage(outputPath, backupPath); + if (!previousPreserved) + { + LauncherInstallRecordStore.TryDelete(backupPath); + } + + var parser = new BakeProgressJsonlParser(); + var protocol = new BakeProgressProtocol(); + string? publicationNonce = null; + + void Observe(BakeProgressEvent progressEvent) + { + bool accepted = protocol.Observe(progressEvent); + switch (progressEvent) + { + case BakeWorkProgressEvent value when accepted: + LauncherInstallPhase phase = value.Phase switch + { + "mesh" => LauncherInstallPhase.BakingMeshes, + "collision" => LauncherInstallPhase.BakingCollision, + _ => LauncherInstallPhase.BakingMeshes, + }; + Report( + progress, + phase, + $"Baking {value.Phase} assets: " + + $"{value.Completed:N0}/{value.Total:N0}; " + + $"failures: {value.Failures:N0}", + value.Completed, + value.Total, + value.Failures, + value.EtaSeconds); + break; + case BakeErrorEvent value when accepted: + Report( + progress, + LauncherInstallPhase.Failed, + $"Bake tool error: {value.Message}"); + break; + case MalformedBakeProgressEvent value: + Report( + progress, + LauncherInstallPhase.Failed, + $"Malformed bake progress: {value.Reason}"); + break; + // Human lines are deliberately ignored, and unknown event + // kinds are forward-compatible. A future protocol version + // cannot satisfy the required v1 started/completed pair. + } + } + + try + { + cancellationToken.ThrowIfCancellationRequested(); + publicationNonce = BakePublicationGuardPaths.CreateNonce(); + await using ( + BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false)) + { + BakePublicationGuardContract.Authorize( + outputPath, + publicationNonce, + publication); + } + + var request = new BakeProcessRequest( + _bakeExecutablePath, + validation.Directory, + outputPath, + threads, + publicationNonce); + BakeProcessResult processResult = await _processRunner.RunAsync( + request, + chunk => + { + foreach (BakeProgressEvent progressEvent in parser.Append(chunk)) + { + Observe(progressEvent); + } + }, + cancellationToken) + .ConfigureAwait(false); + + foreach (BakeProgressEvent progressEvent in parser.Complete()) + { + Observe(progressEvent); + } + protocol.CompleteInput(); + + cancellationToken.ThrowIfCancellationRequested(); + if (protocol.Violation is not null) + { + throw new LauncherInstallException(protocol.Violation); + } + + if (processResult.ExitCode != 0) + { + throw new LauncherInstallException( + BuildChildFailure( + processResult.ExitCode, + protocol.Error?.Message, + processResult.StandardError)); + } + + if (protocol.Error is not null) + { + throw new LauncherInstallException( + $"The bake tool reported an error: {protocol.Error.Message}"); + } + + BakeStartedEvent? started = protocol.Started; + BakeCompletedEvent? completed = protocol.Completed; + if (started is null || completed is null) + { + throw new LauncherInstallException( + "The bake protocol did not finish with a v1 completed event."); + } + + if (started.BakeToolVersion != completed.BakeToolVersion + || completed.BakeToolVersion + != LauncherInstallRecordStore.CurrentBakeToolVersion) + { + throw new LauncherInstallException( + $"The bake tool reported version {completed.BakeToolVersion}; " + + $"version {LauncherInstallRecordStore.CurrentBakeToolVersion} " + + "is required."); + } + + if (completed.Failures != 0) + { + throw new LauncherInstallException( + $"The bake completed with {completed.Failures:N0} failed assets."); + } + + if (!File.Exists(outputPath)) + { + throw new LauncherInstallException( + "The bake tool reported success but did not publish acdream.pak."); + } + + long size = new FileInfo(outputPath).Length; + if (size <= 0 || size != completed.OutputBytes) + { + throw new LauncherInstallException( + "The published package size does not match the bake completion record."); + } + + Report( + progress, + LauncherInstallPhase.VerifyingPackage, + "Computing the prepared package SHA-256..."); + string sha256 = await _computeSha256(outputPath, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + var record = new LauncherInstallRecord( + validation.Directory, + outputPath, + sha256, + size, + completed.BakeToolVersion); + Report( + progress, + LauncherInstallPhase.SavingRecord, + "Saving the verified install record..."); + await _recordStore.SaveAtomicallyUnderLeaseAsync( + record, + cancellationToken) + .ConfigureAwait(false); + + _verifiedRecord = record; + await FinalizeSuccessfulPublicationAsync( + outputPath, + backupPath, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Completed, + "Client content installed and verified.", + completed: 1, + total: 1); + return new LauncherInstallResult(record); + } + catch (OperationCanceledException) + { + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Cancelled, + "Installation cancelled; no new install record was published."); + throw; + } + catch (Exception ex) + { + await FinalizeFailedPublicationAsync( + outputPath, + backupPath, + previousPreserved, + publicationNonce) + .ConfigureAwait(false); + Report( + progress, + LauncherInstallPhase.Failed, + $"Installation failed: {ex.Message}"); + if (ex is LauncherInstallException) + { + throw; + } + + throw new LauncherInstallException("Installation failed.", ex); + } + } + + private async Task + RecoverExistingUnderPublicationGuardAsync( + CancellationToken cancellationToken) + { + string outputPath = _recordStore.PreparedAssetPath; + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + cancellationToken) + .ConfigureAwait(false); + // Any child whose parent died before it acquired this lock is now + // irrevocably stale. A child already holding the lock must finish its + // promotion before recovery reaches this invalidation point. + BakePublicationGuardContract.Invalidate(outputPath, publication); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken) + .ConfigureAwait(false); + } + + private static async Task FinalizeSuccessfulPublicationAsync( + string outputPath, + string backupPath, + string publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + LauncherInstallRecordStore.TryDelete(backupPath); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } + + private static async Task FinalizeFailedPublicationAsync( + string outputPath, + string backupPath, + bool previousPreserved, + string? publicationNonce) + { + await using BakePublicationGuardContract.PublicationLease publication = + await BakePublicationGuardContract.AcquireAsync( + outputPath, + CancellationToken.None) + .ConfigureAwait(false); + BakePublicationGuardContract.Invalidate( + outputPath, + publication, + publicationNonce); + RestorePreviousPackage(outputPath, backupPath, previousPreserved); + BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath); + } + + private bool PreservePreviousPackage(string outputPath, string backupPath) + { + LauncherInstallRecord? previous = _verifiedRecord; + if (previous is null + || !PathsEqual(previous.PreparedAssetPath, outputPath) + || !File.Exists(outputPath)) + { + return false; + } + + File.Move(outputPath, backupPath, overwrite: true); + return true; + } + + private static void RestorePreviousPackage( + string outputPath, + string backupPath, + bool previousPreserved) + { + if (previousPreserved && File.Exists(backupPath)) + { + File.Move(backupPath, outputPath, overwrite: true); + return; + } + + LauncherInstallRecordStore.TryDelete(outputPath); + LauncherInstallRecordStore.TryDelete(backupPath); + } + + private static string BuildChildFailure( + int exitCode, + string? jsonError, + string standardError) + { + string detail = !string.IsNullOrWhiteSpace(jsonError) + ? jsonError + : standardError.Trim(); + return detail.Length == 0 + ? $"The bake tool exited with code {exitCode}." + : $"The bake tool exited with code {exitCode}: {detail}"; + } + + private static void Report( + IProgress? progress, + LauncherInstallPhase phase, + string status, + long completed = 0, + long total = 0, + int failures = 0, + double etaSeconds = 0) => + progress?.Report(new LauncherInstallProgress( + phase, + status, + completed, + total, + failures, + etaSeconds)); + + private static string FormatMissing(IReadOnlyList missing) => + missing.Count == 0 + ? string.Empty + : " Missing: " + string.Join(", ", missing) + "."; + + private static bool PathsEqual(string left, string right) => + string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal); +} diff --git a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs index 327d406a..dab09b90 100644 --- a/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs +++ b/src/AcDream.Launcher.Core/Integrity/FileIntegrity.cs @@ -27,6 +27,11 @@ public static class FileIntegrity return Convert.ToHexStringLower(hash); } + /// + /// Asynchronous, cancellable counterpart used while verifying a multi- + /// gigabyte prepared package. The file stays streamed and no buffer is + /// retained after the hash completes. + /// public static async Task ComputeSha256HexAsync( string filePath, CancellationToken cancellationToken = default) @@ -38,8 +43,8 @@ public static class FileIntegrity FileMode.Open, FileAccess.Read, FileShare.Read, - bufferSize: 4096, - useAsync: true); + bufferSize: 1024 * 1024, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken) .ConfigureAwait(false); return Convert.ToHexStringLower(hash); diff --git a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs index 5d8e0c07..c89c279a 100644 --- a/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs +++ b/src/AcDream.Launcher.Core/Launching/LauncherInstallRecord.cs @@ -3,10 +3,24 @@ namespace AcDream.Launcher.Core.Launching; /// /// The DAT/pak locations a completed install (LA9) records and every /// session-config composition consumes for -/// . SHA-256/version bookkeeping -/// for the install record itself is LA9/LA10 scope; this slice only -/// needs the two paths a session config requires. +/// . Integrity metadata is launcher- +/// local: it is verified before this record is admitted to the orchestrator +/// and is deliberately not copied into the host session-config contract. /// public sealed record LauncherInstallRecord( string DatDirectory, - string PreparedAssetPath); + string PreparedAssetPath, + string PreparedAssetSha256 = "", + long PreparedAssetSize = 0, + uint BakeToolVersion = 0) +{ + public const int CurrentRecordVersion = 1; + + public int Version { get; init; } = CurrentRecordVersion; + + public bool HasIntegrityMetadata => + !string.IsNullOrEmpty(PreparedAssetSha256) + && PreparedAssetSha256.Length == 64 + && PreparedAssetSize > 0 + && BakeToolVersion > 0; +} diff --git a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs index 358dc4f9..c9a7a582 100644 --- a/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs +++ b/src/AcDream.Launcher.Core/Orchestration/LauncherOrchestrator.cs @@ -29,6 +29,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator private readonly List _activities = []; private LauncherInstallRecord? _installRecord; + private string _installationStatus; private bool _disposed; public LauncherOrchestrator( @@ -40,7 +41,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator ILauncherSessionConfigService? configService = null, ILauncherProcessSupervisorFactory? supervisorFactory = null, IStatusEventSourceFactory? statusSourceFactory = null, - Func? sessionIdFactory = null) + Func? sessionIdFactory = null, + string? installationStatus = null) { _profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore)); _paths = paths ?? throw new ArgumentNullException(nameof(paths)); @@ -51,6 +53,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator _supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory(); _statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory(); _sessionIdFactory = sessionIdFactory ?? CreateSessionId; + _installationStatus = installationStatus + ?? (installRecord is null + ? FirstRunRequired + : "Client content paths are configured."); } public event EventHandler? StateChanged; @@ -85,9 +91,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator sessions, _platform, _installRecord is not null, - _installRecord is null - ? FirstRunRequired - : "Client content paths are configured."); + _installationStatus); } } @@ -184,6 +188,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator { ThrowIfDisposed(); _installRecord = installRecord; + _installationStatus = installRecord is null + ? FirstRunRequired + : "Client content SHA-256, size, and bake-tool version verified."; } RaiseStateChanged(); diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index a4c20260..63fa57ff 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -10,7 +10,8 @@ true true true - true + true + true @@ -22,4 +23,20 @@ + + + + + <_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir) + <_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir) + + + diff --git a/src/AcDream.Launcher/App.axaml.cs b/src/AcDream.Launcher/App.axaml.cs index 75dd93fe..2723ed1d 100644 --- a/src/AcDream.Launcher/App.axaml.cs +++ b/src/AcDream.Launcher/App.axaml.cs @@ -1,3 +1,4 @@ +using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Launching; using AcDream.Launcher.Core.Orchestration; using AcDream.Launcher.Core.Profiles; @@ -22,15 +23,40 @@ public sealed partial class App : Application { ApplicationPathSet paths = ApplicationPathSet.Resolve(); LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths); - LauncherInstallRecord? install = ResolveDevelopmentInstallRecord(); + string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty; + var installer = new LauncherInstaller( + paths, + Path.Combine( + AppContext.BaseDirectory, + "acdream-bake" + executableSuffix)); + InstallRecordVerification verification; + try + { + // Hashing the package before constructing the orchestrator is + // intentional: no launch action is enabled until the persisted + // size/SHA/tool-version record has been verified. + verification = installer.LoadExistingAsync() + .GetAwaiter() + .GetResult(); + } + catch (Exception ex) + { + verification = new InstallRecordVerification( + InstallRecordVerificationState.Invalid, + null, + $"Client content verification failed: {ex.Message}"); + } + _orchestrator = new LauncherOrchestrator( profiles, paths, LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory), - install); + verification.Record, + installationStatus: verification.Status); _viewModel = new LauncherWindowViewModel( _orchestrator, - new AvaloniaUiDispatcher()); + new AvaloniaUiDispatcher(), + installer); _viewModel.Initialize(); desktop.MainWindow = new MainWindow @@ -43,19 +69,6 @@ public sealed partial class App : Application base.OnFrameworkInitializationCompleted(); } - private static LauncherInstallRecord? ResolveDevelopmentInstallRecord() - { - // LA9 owns persisted install discovery. LA4 accepts the existing - // developer environment pair at this one composition root so the - // launch/probe UI can be exercised before the first-run body lands. - string? datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? preparedAssetPath = Environment.GetEnvironmentVariable("ACDREAM_PAK_PATH"); - return !string.IsNullOrWhiteSpace(datDirectory) - && !string.IsNullOrWhiteSpace(preparedAssetPath) - ? new LauncherInstallRecord(datDirectory, preparedAssetPath) - : null; - } - private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) { _viewModel?.Dispose(); diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index f85d1e8a..9af0408c 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -342,21 +342,88 @@ KeyDown="OnModalKeyDown" AutomationProperties.Name="First-run setup modal dialog" IsVisible="{Binding FirstRunWizardShell.IsOpen}"> - - - - - - - -