merge: Campaign LA LA9 - verified installer review-closed
This commit is contained in:
commit
2198a0cc8e
45 changed files with 5143 additions and 132 deletions
41
.github/workflows/headless-portability.yml
vendored
41
.github/workflows/headless-portability.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
||||||
- "src/AcDream.Platform/**"
|
- "src/AcDream.Platform/**"
|
||||||
- "src/AcDream.Launcher.Core/**"
|
- "src/AcDream.Launcher.Core/**"
|
||||||
- "src/AcDream.Launcher/**"
|
- "src/AcDream.Launcher/**"
|
||||||
|
- "src/AcDream.Bake/**"
|
||||||
- "src/AcDream.Core/**"
|
- "src/AcDream.Core/**"
|
||||||
- "src/AcDream.Core.Net/**"
|
- "src/AcDream.Core.Net/**"
|
||||||
- "src/AcDream.Content/**"
|
- "src/AcDream.Content/**"
|
||||||
|
|
@ -19,6 +20,7 @@ on:
|
||||||
- "tests/AcDream.Platform.Tests/**"
|
- "tests/AcDream.Platform.Tests/**"
|
||||||
- "tests/AcDream.Launcher.Core.Tests/**"
|
- "tests/AcDream.Launcher.Core.Tests/**"
|
||||||
- "tests/AcDream.Launcher.Tests/**"
|
- "tests/AcDream.Launcher.Tests/**"
|
||||||
|
- "tests/AcDream.Bake.Tests/**"
|
||||||
- "tests/AcDream.Core.Tests/**"
|
- "tests/AcDream.Core.Tests/**"
|
||||||
- "tests/AcDream.Core.Net.Tests/**"
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
- "tests/AcDream.Content.Tests/**"
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
|
@ -36,6 +38,7 @@ on:
|
||||||
- "src/AcDream.Platform/**"
|
- "src/AcDream.Platform/**"
|
||||||
- "src/AcDream.Launcher.Core/**"
|
- "src/AcDream.Launcher.Core/**"
|
||||||
- "src/AcDream.Launcher/**"
|
- "src/AcDream.Launcher/**"
|
||||||
|
- "src/AcDream.Bake/**"
|
||||||
- "src/AcDream.Core/**"
|
- "src/AcDream.Core/**"
|
||||||
- "src/AcDream.Core.Net/**"
|
- "src/AcDream.Core.Net/**"
|
||||||
- "src/AcDream.Content/**"
|
- "src/AcDream.Content/**"
|
||||||
|
|
@ -47,6 +50,7 @@ on:
|
||||||
- "tests/AcDream.Platform.Tests/**"
|
- "tests/AcDream.Platform.Tests/**"
|
||||||
- "tests/AcDream.Launcher.Core.Tests/**"
|
- "tests/AcDream.Launcher.Core.Tests/**"
|
||||||
- "tests/AcDream.Launcher.Tests/**"
|
- "tests/AcDream.Launcher.Tests/**"
|
||||||
|
- "tests/AcDream.Bake.Tests/**"
|
||||||
- "tests/AcDream.Core.Tests/**"
|
- "tests/AcDream.Core.Tests/**"
|
||||||
- "tests/AcDream.Core.Net.Tests/**"
|
- "tests/AcDream.Core.Net.Tests/**"
|
||||||
- "tests/AcDream.Content.Tests/**"
|
- "tests/AcDream.Content.Tests/**"
|
||||||
|
|
@ -80,7 +84,7 @@ jobs:
|
||||||
dotnet-version: "10.0.x"
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
# No apt step here on purpose. This job's whole claim is that the closure
|
# 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
|
# Core.Net, Content, Runtime and Headless, runs their tests, and invokes
|
||||||
# the Headless CLI. Nothing in it opens a display, links GL, or calls
|
# 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
|
# xvfb-run, so an "install the graphical smoke dependencies" step here was
|
||||||
|
|
@ -94,6 +98,7 @@ jobs:
|
||||||
$projects = @(
|
$projects = @(
|
||||||
"src/AcDream.Platform/AcDream.Platform.csproj",
|
"src/AcDream.Platform/AcDream.Platform.csproj",
|
||||||
"src/AcDream.Launcher.Core/AcDream.Launcher.Core.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.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
|
||||||
"src/AcDream.Core/AcDream.Core.csproj",
|
"src/AcDream.Core/AcDream.Core.csproj",
|
||||||
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
|
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
|
||||||
|
|
@ -116,6 +121,7 @@ jobs:
|
||||||
$projects = @(
|
$projects = @(
|
||||||
"tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj",
|
"tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj",
|
||||||
"tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.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.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
|
||||||
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
|
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
|
||||||
"tests/AcDream.Runtime.Tests/AcDream.Runtime.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
|
dotnet test tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj -c Release
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||||
|
|
||||||
- name: Publish the self-contained Linux launcher
|
- name: Publish the self-contained launcher distribution
|
||||||
if: runner.os == 'Linux'
|
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
|
$rid = if ($IsWindows) { "win-x64" } else { "linux-x64" }
|
||||||
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
|
dotnet publish src/AcDream.Launcher/AcDream.Launcher.csproj `
|
||||||
-c Release `
|
-c Release `
|
||||||
-r linux-x64 `
|
-r $rid `
|
||||||
-o artifacts/acdream-launcher-linux-x64
|
-o "artifacts/acdream-launcher-$rid"
|
||||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
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'
|
if: runner.os == 'Linux'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -189,11 +212,17 @@ jobs:
|
||||||
-getProperty:SelfContained | tr -d '\r\n ')
|
-getProperty:SelfContained | tr -d '\r\n ')
|
||||||
test "$self_contained" = true
|
test "$self_contained" = true
|
||||||
test -x "$root/acdream-launcher"
|
test -x "$root/acdream-launcher"
|
||||||
|
test -x "$root/acdream-bake"
|
||||||
test ! -f "$root/acdream-launcher.dll"
|
test ! -f "$root/acdream-launcher.dll"
|
||||||
|
test ! -f "$root/acdream-bake.dll"
|
||||||
DOTNET_ROOT=/definitely-not-installed \
|
DOTNET_ROOT=/definitely-not-installed \
|
||||||
DOTNET_ROOT_X64=/definitely-not-installed \
|
DOTNET_ROOT_X64=/definitely-not-installed \
|
||||||
DOTNET_MULTILEVEL_LOOKUP=0 \
|
DOTNET_MULTILEVEL_LOOKUP=0 \
|
||||||
"$root/acdream-launcher" --verify-publish
|
"$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:
|
linux-graphical:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
|
||||||
|
<Project Path="tests/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder/AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
|
||||||
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,9 @@ src/
|
||||||
AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0)
|
AcDream.Platform/ BCL-only portable path contract (Campaign LA LA0)
|
||||||
ApplicationPathSet.cs -> shared XDG/Windows config, data, cache,
|
ApplicationPathSet.cs -> shared XDG/Windows config, data, cache,
|
||||||
plugin, screenshot, and diagnostic paths
|
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
|
-> zero project/package references (guarded by
|
||||||
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
|
tests/AcDream.Platform.Tests/PlatformDependencyBoundaryTests.cs);
|
||||||
Runtime and App reference it directly; Headless reaches it
|
Runtime and App reference it directly; Headless reaches it
|
||||||
|
|
@ -313,12 +316,28 @@ src/
|
||||||
Status/ -> incremental host-status parsing/tailing
|
Status/ -> incremental host-status parsing/tailing
|
||||||
Orchestration/ -> immutable UI snapshots, typed actions,
|
Orchestration/ -> immutable UI snapshots, typed actions,
|
||||||
capability gates, and running-session lifetime
|
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
|
||||||
|
`.<pak>.acdream-bake.<guid:N>.tmp` files are
|
||||||
|
transaction-owned crash residue
|
||||||
-> references Platform only; no Avalonia or game-host dependency
|
-> references Platform only; no Avalonia or game-host dependency
|
||||||
|
|
||||||
AcDream.Launcher/ Avalonia 12 Windows/Linux desktop shell
|
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
|
-> references Launcher.Core only (Platform transitively); it never owns
|
||||||
a second profile, process, status, or credential state graph
|
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
|
-> Linux launcher/probe/headless flows remain portable; graphical-client
|
||||||
actions are explicitly disabled until Modern Runtime Slice L resumes
|
actions are explicitly disabled until Modern Runtime Slice L resumes
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
|
<InternalsVisibleTo Include="AcDream.Bake.Tests" />
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -24,6 +25,7 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
|
||||||
|
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
133
src/AcDream.Bake/BakeCommandLine.cs
Normal file
133
src/AcDream.Bake/BakeCommandLine.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace AcDream.Bake;
|
||||||
|
|
||||||
|
internal sealed record BakeCommandLineOptions(
|
||||||
|
string DatDirectory,
|
||||||
|
string OutputPath,
|
||||||
|
HashSet<uint>? IdFilter,
|
||||||
|
HashSet<uint>? LandblockFilter,
|
||||||
|
int Threads,
|
||||||
|
bool ProgressJson);
|
||||||
|
|
||||||
|
internal static class BakeCommandLine
|
||||||
|
{
|
||||||
|
internal const string Usage =
|
||||||
|
"usage: acdream-bake --dat-dir <path> [--out <file>] "
|
||||||
|
+ "[--ids 0xId,0xId,...] [--landblocks 0xId,...] "
|
||||||
|
+ "[--threads <n>] [--progress-json]\n"
|
||||||
|
+ " acdream-bake --help";
|
||||||
|
|
||||||
|
public static bool IsHelpRequest(IReadOnlyList<string> args)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
return args.Count == 1
|
||||||
|
&& args[0] is "--help" or "-h";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParse(
|
||||||
|
IReadOnlyList<string> args,
|
||||||
|
TextWriter error,
|
||||||
|
out BakeCommandLineOptions? options)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(args);
|
||||||
|
ArgumentNullException.ThrowIfNull(error);
|
||||||
|
|
||||||
|
string? datDirectory = null;
|
||||||
|
string? outputPath = null;
|
||||||
|
HashSet<uint>? idFilter = null;
|
||||||
|
HashSet<uint>? 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<string> args, ref int index) =>
|
||||||
|
index + 1 < args.Count ? args[++index] : null;
|
||||||
|
|
||||||
|
private static HashSet<uint> ParseHexList(string? raw, TextWriter error)
|
||||||
|
{
|
||||||
|
var result = new HashSet<uint>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,28 @@ namespace AcDream.Bake;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BakeOutputTransaction
|
public static class BakeOutputTransaction
|
||||||
{
|
{
|
||||||
|
internal const string StagingMarker = ".acdream-bake.";
|
||||||
|
|
||||||
public static TResult WriteValidateAndPublish<TResult>(
|
public static TResult WriteValidateAndPublish<TResult>(
|
||||||
string destinationPath,
|
string destinationPath,
|
||||||
Func<string, TResult> writeTemporary,
|
Func<string, TResult> writeTemporary,
|
||||||
Action<string, TResult> validateTemporary,
|
Action<string, TResult> validateTemporary,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
=> WriteValidateAndPublish(
|
||||||
|
destinationPath,
|
||||||
|
writeTemporary,
|
||||||
|
validateTemporary,
|
||||||
|
beforePublicationLock: null,
|
||||||
|
beforePromotion: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
internal static TResult WriteValidateAndPublish<TResult>(
|
||||||
|
string destinationPath,
|
||||||
|
Func<string, TResult> writeTemporary,
|
||||||
|
Action<string, TResult> validateTemporary,
|
||||||
|
Action? beforePublicationLock,
|
||||||
|
Action? beforePromotion,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
||||||
ArgumentNullException.ThrowIfNull(writeTemporary);
|
ArgumentNullException.ThrowIfNull(writeTemporary);
|
||||||
|
|
@ -25,9 +42,7 @@ public static class BakeOutputTransaction
|
||||||
throw new InvalidOperationException("destination has no parent directory");
|
throw new InvalidOperationException("destination has no parent directory");
|
||||||
Directory.CreateDirectory(directory);
|
Directory.CreateDirectory(directory);
|
||||||
|
|
||||||
string temporaryPath = Path.Combine(
|
string temporaryPath = CreateStagingPath(fullDestination, Guid.NewGuid());
|
||||||
directory,
|
|
||||||
$".{Path.GetFileName(fullDestination)}.{Guid.NewGuid():N}.tmp");
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -36,6 +51,14 @@ public static class BakeOutputTransaction
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
validateTemporary(temporaryPath, result);
|
validateTemporary(temporaryPath, result);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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.
|
// Same-volume MoveFileEx/rename is the publication primitive.
|
||||||
// File.Replace additionally performs destination metadata/backup
|
// File.Replace additionally performs destination metadata/backup
|
||||||
|
|
@ -60,4 +83,21 @@ public static class BakeOutputTransaction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
src/AcDream.Bake/BakeProgressJsonWriter.cs
Normal file
101
src/AcDream.Bake/BakeProgressJsonWriter.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Version-1 JSON-lines machine channel enabled only by
|
||||||
|
/// <c>--progress-json</c>. Ordinary human console lines remain unchanged and
|
||||||
|
/// share stdout; consumers identify these records by shape instead of
|
||||||
|
/// scraping human prose.
|
||||||
|
/// </summary>
|
||||||
|
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>(T value)
|
||||||
|
{
|
||||||
|
string line = JsonSerializer.Serialize(value);
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_output.WriteLine(line);
|
||||||
|
_output.Flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/AcDream.Bake/BakeProgressReporter.cs
Normal file
34
src/AcDream.Bake/BakeProgressReporter.cs
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/AcDream.Bake/BakePublicationGuard.cs
Normal file
80
src/AcDream.Bake/BakePublicationGuard.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Bake;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional launcher authorization checked immediately before atomic
|
||||||
|
/// publication. Standalone Bake runs have no nonce environment variable and
|
||||||
|
/// retain the original unguarded behavior.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ public sealed record BakeOptions
|
||||||
public HashSet<uint>? LandblockFilter { get; init; }
|
public HashSet<uint>? LandblockFilter { get; init; }
|
||||||
public int Threads { get; init; } = System.Environment.ProcessorCount;
|
public int Threads { get; init; } = System.Environment.ProcessorCount;
|
||||||
public CancellationToken CancellationToken { get; init; }
|
public CancellationToken CancellationToken { get; init; }
|
||||||
|
public IBakeProgressSink? Progress { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
|
/// <summary>Compact result used by the full-scale gate and deterministic tests.</summary>
|
||||||
|
|
@ -89,6 +90,7 @@ public static class BakeRunner
|
||||||
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
|
throw new ArgumentOutOfRangeException(nameof(options), "thread count must be positive");
|
||||||
|
|
||||||
options.CancellationToken.ThrowIfCancellationRequested();
|
options.CancellationToken.ThrowIfCancellationRequested();
|
||||||
|
options.Progress?.Started(PakFormat.CurrentBakeToolVersion, options.OutPath);
|
||||||
var totalStopwatch = Stopwatch.StartNew();
|
var totalStopwatch = Stopwatch.StartNew();
|
||||||
var report = BakeOutputTransaction.WriteValidateAndPublish(
|
var report = BakeOutputTransaction.WriteValidateAndPublish(
|
||||||
options.OutPath,
|
options.OutPath,
|
||||||
|
|
@ -112,6 +114,10 @@ public static class BakeRunner
|
||||||
};
|
};
|
||||||
|
|
||||||
PrintSummary(report, options.OutPath);
|
PrintSummary(report, options.OutPath);
|
||||||
|
options.Progress?.Completed(
|
||||||
|
report.Header.BakeToolVersion,
|
||||||
|
report.OutputBytes,
|
||||||
|
report.Failures);
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -273,6 +279,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"mesh",
|
||||||
batchStart + BatchSize >= ordinaryWork.Count &&
|
batchStart + BatchSize >= ordinaryWork.Count &&
|
||||||
envCatalog.UniqueGeometryCount == 0);
|
envCatalog.UniqueGeometryCount == 0);
|
||||||
}
|
}
|
||||||
|
|
@ -372,6 +380,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
stopwatch.Elapsed,
|
stopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"mesh",
|
||||||
batchStart + BatchSize >= envCatalog.Groups.Count);
|
batchStart + BatchSize >= envCatalog.Groups.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -571,6 +581,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
collisionStopwatch.Elapsed,
|
collisionStopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"collision",
|
||||||
final: false);
|
final: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -757,6 +769,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
collisionStopwatch.Elapsed,
|
collisionStopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"collision",
|
||||||
final: false);
|
final: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -769,6 +783,8 @@ public static class BakeRunner
|
||||||
failures.Count,
|
failures.Count,
|
||||||
collisionStopwatch.Elapsed,
|
collisionStopwatch.Elapsed,
|
||||||
lastProgressReport,
|
lastProgressReport,
|
||||||
|
options.Progress,
|
||||||
|
"collision",
|
||||||
final: true);
|
final: true);
|
||||||
|
|
||||||
writer.Finish();
|
writer.Finish();
|
||||||
|
|
@ -910,6 +926,8 @@ public static class BakeRunner
|
||||||
int failures,
|
int failures,
|
||||||
TimeSpan elapsed,
|
TimeSpan elapsed,
|
||||||
Stopwatch lastProgressReport,
|
Stopwatch lastProgressReport,
|
||||||
|
IBakeProgressSink? progress,
|
||||||
|
string phase,
|
||||||
bool final)
|
bool final)
|
||||||
{
|
{
|
||||||
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
|
if (!final && lastProgressReport.Elapsed.TotalSeconds < 5)
|
||||||
|
|
@ -920,11 +938,18 @@ public static class BakeRunner
|
||||||
using var process = Process.GetCurrentProcess();
|
using var process = Process.GetCurrentProcess();
|
||||||
process.Refresh();
|
process.Refresh();
|
||||||
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
|
long managedHeap = GC.GetGCMemoryInfo().HeapSizeBytes;
|
||||||
Console.WriteLine(
|
long privateBytes = process.PrivateMemorySize64;
|
||||||
$"[{elapsed:hh\\:mm\\:ss}] extracted {done:N0}/{total:N0}, " +
|
BakeProgressReporter.Write(
|
||||||
$"failures={failures:N0}, elapsed={elapsed.TotalSeconds:F0}s, " +
|
Console.Out,
|
||||||
$"ETA={etaSeconds:F0}s, private={process.PrivateMemorySize64 / 1024.0 / 1024.0:F0}MB, " +
|
progress,
|
||||||
$"managed={managedHeap / 1024.0 / 1024.0:F0}MB");
|
phase,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
failures,
|
||||||
|
elapsed,
|
||||||
|
etaSeconds,
|
||||||
|
privateBytes,
|
||||||
|
managedHeap);
|
||||||
lastProgressReport.Restart();
|
lastProgressReport.Restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,3 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using AcDream.Bake;
|
using AcDream.Bake;
|
||||||
|
|
||||||
// acdream-bake: offline CLI producing a versioned pak file containing every
|
// 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.
|
// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5.
|
||||||
|
|
||||||
string? datDir = null;
|
if (BakeCommandLine.IsHelpRequest(args))
|
||||||
string? outPath = null;
|
{
|
||||||
HashSet<uint>? idFilter = null;
|
Console.Out.WriteLine(BakeCommandLine.Usage);
|
||||||
HashSet<uint>? landblockFilter = null;
|
return 0;
|
||||||
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 (string.IsNullOrWhiteSpace(datDir)) {
|
if (!BakeCommandLine.TryParse(args, Console.Error, out BakeCommandLineOptions? command))
|
||||||
Console.Error.WriteLine("usage: acdream-bake --dat-dir <path> [--out <file>] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads <n>]");
|
{
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Directory.Exists(datDir)) {
|
if (!Directory.Exists(command!.DatDirectory))
|
||||||
Console.Error.WriteLine($"error: directory not found: {datDir}");
|
{
|
||||||
|
Console.Error.WriteLine($"error: directory not found: {command.DatDirectory}");
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
outPath ??= Path.Combine(datDir, "acdream.pak");
|
IBakeProgressSink? progress = command.ProgressJson
|
||||||
|
? new BakeProgressJsonWriter(Console.Out)
|
||||||
return BakeRunner.Run(new BakeOptions {
|
: null;
|
||||||
DatDir = datDir,
|
try
|
||||||
OutPath = outPath,
|
{
|
||||||
IdFilter = idFilter,
|
return BakeRunner.Run(new BakeOptions
|
||||||
LandblockFilter = landblockFilter,
|
{
|
||||||
Threads = threads,
|
DatDir = command.DatDirectory,
|
||||||
});
|
OutPath = command.OutputPath,
|
||||||
|
IdFilter = command.IdFilter,
|
||||||
static HashSet<uint> ParseHexList(string? raw) {
|
LandblockFilter = command.LandblockFilter,
|
||||||
var result = new HashSet<uint>();
|
Threads = command.Threads,
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return result;
|
Progress = progress,
|
||||||
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)) {
|
catch (Exception exception)
|
||||||
result.Add(value);
|
{
|
||||||
}
|
progress?.Error(exception.Message);
|
||||||
else {
|
Console.Error.WriteLine($"error: {exception.Message}");
|
||||||
Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped");
|
return 1;
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests" />
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<char> 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs
Normal file
198
src/AcDream.Launcher.Core/Installation/BakeProcessRunner.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>The exact child-process contract for one launcher bake.</summary>
|
||||||
|
public sealed record BakeProcessRequest(
|
||||||
|
string ExecutablePath,
|
||||||
|
string DatDirectory,
|
||||||
|
string OutputPath,
|
||||||
|
int Threads,
|
||||||
|
string? PublicationNonce = null)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> Arguments =>
|
||||||
|
[
|
||||||
|
"--dat-dir",
|
||||||
|
DatDirectory,
|
||||||
|
"--out",
|
||||||
|
OutputPath,
|
||||||
|
"--threads",
|
||||||
|
Threads.ToString(CultureInfo.InvariantCulture),
|
||||||
|
"--progress-json",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record BakeProcessResult(int ExitCode, string StandardError);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Injectable child seam. Stdout is delivered as arbitrary chunks so the
|
||||||
|
/// versioned JSONL parser, rather than line-oriented process plumbing, owns
|
||||||
|
/// partial-record behavior.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBakeProcessRunner
|
||||||
|
{
|
||||||
|
Task<BakeProcessResult> RunAsync(
|
||||||
|
BakeProcessRequest request,
|
||||||
|
Action<string> onStandardOutput,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SystemBakeProcessRunner : IBakeProcessRunner
|
||||||
|
{
|
||||||
|
private const int BufferSize = 4096;
|
||||||
|
private const int MaximumCapturedErrorCharacters = 32 * 1024;
|
||||||
|
|
||||||
|
public async Task<BakeProcessResult> RunAsync(
|
||||||
|
BakeProcessRequest request,
|
||||||
|
Action<string> 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<string> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs
Normal file
54
src/AcDream.Launcher.Core/Installation/BakeProgressEvent.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Versioned machine-readable output from <c>acdream-bake
|
||||||
|
/// --progress-json</c>. Human output shares stdout but remains a distinct
|
||||||
|
/// event so the installer never derives state by scraping prose.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class BakeProgressJsonlParser
|
||||||
|
{
|
||||||
|
public const int CurrentVersion = 1;
|
||||||
|
|
||||||
|
private readonly StringBuilder _pending = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<BakeProgressEvent> Append(string chunk)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(chunk);
|
||||||
|
_pending.Append(chunk);
|
||||||
|
return Drain(completeFinalLine: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<BakeProgressEvent> 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<BakeProgressEvent> Drain(bool completeFinalLine)
|
||||||
|
{
|
||||||
|
var events = new List<BakeProgressEvent>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
Normal file
121
src/AcDream.Launcher.Core/Installation/BakeProgressProtocol.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
internal static class BakePublicationGuardContract
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan RetryDelay = TimeSpan.FromMilliseconds(50);
|
||||||
|
|
||||||
|
internal static async ValueTask<PublicationLease> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs
Normal file
138
src/AcDream.Launcher.Core/Installation/DatDirectoryLocator.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Portable validation and Windows-only discovery for the four retail data
|
||||||
|
/// archives consumed by <c>acdream-bake</c>. 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DatDirectoryLocator
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<string> RequiredFileNames { get; } =
|
||||||
|
Array.AsReadOnly<string>(
|
||||||
|
[
|
||||||
|
"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<string, bool> _directoryExists;
|
||||||
|
private readonly Func<string, bool> _fileExists;
|
||||||
|
|
||||||
|
public DatDirectoryLocator(
|
||||||
|
bool? isWindows = null,
|
||||||
|
IEnumerable<string>? windowsCandidates = null,
|
||||||
|
Func<string, bool>? directoryExists = null,
|
||||||
|
Func<string, bool>? 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<DatDirectoryValidation> 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<string> 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<string> 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<string> missingFileNames) =>
|
||||||
|
new(directory, false, message, missingFileNames);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
namespace AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<InstallerTransactionLease> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, CancellationToken, Task<string>> _computeSha256;
|
||||||
|
|
||||||
|
public LauncherInstallRecordStore(
|
||||||
|
ApplicationPathSet paths,
|
||||||
|
DatDirectoryLocator? datDirectories = null,
|
||||||
|
Func<string, CancellationToken, Task<string>>? 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<InstallRecordVerification> LoadAndVerifyAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using InstallerTransactionLease lease =
|
||||||
|
await InstallerTransactionLease.AcquireAsync(
|
||||||
|
DataDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task<InstallRecordVerification> 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<LauncherInstallRecord>(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<FileVerification> 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<string> 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);
|
||||||
|
}
|
||||||
561
src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
Normal file
561
src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs
Normal file
|
|
@ -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<DatDirectoryValidation> DetectDatDirectories();
|
||||||
|
|
||||||
|
DatDirectoryValidation ValidateDatDirectory(string? directory);
|
||||||
|
|
||||||
|
Task<InstallRecordVerification> LoadExistingAsync(
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<LauncherInstallResult> InstallAsync(
|
||||||
|
string datDirectory,
|
||||||
|
int threads,
|
||||||
|
IProgress<LauncherInstallProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class LauncherInstaller : ILauncherInstaller
|
||||||
|
{
|
||||||
|
private readonly string _bakeExecutablePath;
|
||||||
|
private readonly DatDirectoryLocator _datDirectories;
|
||||||
|
private readonly LauncherInstallRecordStore _recordStore;
|
||||||
|
private readonly IBakeProcessRunner _processRunner;
|
||||||
|
private readonly Func<string, CancellationToken, Task<string>> _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<string, CancellationToken, Task<string>>? 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<DatDirectoryValidation> DetectDatDirectories() =>
|
||||||
|
_datDirectories.Detect();
|
||||||
|
|
||||||
|
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
|
||||||
|
_datDirectories.Validate(directory);
|
||||||
|
|
||||||
|
public async Task<InstallRecordVerification> 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<LauncherInstallResult> InstallAsync(
|
||||||
|
string datDirectory,
|
||||||
|
int threads,
|
||||||
|
IProgress<LauncherInstallProgress>? 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<LauncherInstallResult> InstallCoreAsync(
|
||||||
|
string datDirectory,
|
||||||
|
int threads,
|
||||||
|
IProgress<LauncherInstallProgress>? 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<InstallRecordVerification>
|
||||||
|
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<LauncherInstallProgress>? 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<string> 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);
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,11 @@ public static class FileIntegrity
|
||||||
return Convert.ToHexStringLower(hash);
|
return Convert.ToHexStringLower(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronous, cancellable counterpart used while verifying a multi-
|
||||||
|
/// gigabyte prepared package. The file stays streamed and no buffer is
|
||||||
|
/// retained after the hash completes.
|
||||||
|
/// </summary>
|
||||||
public static async Task<string> ComputeSha256HexAsync(
|
public static async Task<string> ComputeSha256HexAsync(
|
||||||
string filePath,
|
string filePath,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|
@ -38,8 +43,8 @@ public static class FileIntegrity
|
||||||
FileMode.Open,
|
FileMode.Open,
|
||||||
FileAccess.Read,
|
FileAccess.Read,
|
||||||
FileShare.Read,
|
FileShare.Read,
|
||||||
bufferSize: 4096,
|
bufferSize: 1024 * 1024,
|
||||||
useAsync: true);
|
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||||
byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken)
|
byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return Convert.ToHexStringLower(hash);
|
return Convert.ToHexStringLower(hash);
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,24 @@ namespace AcDream.Launcher.Core.Launching;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The DAT/pak locations a completed install (LA9) records and every
|
/// The DAT/pak locations a completed install (LA9) records and every
|
||||||
/// session-config composition consumes for
|
/// session-config composition consumes for
|
||||||
/// <see cref="SessionContentDescriptor"/>. SHA-256/version bookkeeping
|
/// <see cref="SessionContentDescriptor"/>. Integrity metadata is launcher-
|
||||||
/// for the install record itself is LA9/LA10 scope; this slice only
|
/// local: it is verified before this record is admitted to the orchestrator
|
||||||
/// needs the two paths a session config requires.
|
/// and is deliberately not copied into the host session-config contract.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record LauncherInstallRecord(
|
public sealed record LauncherInstallRecord(
|
||||||
string DatDirectory,
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
private readonly List<ManagedActivity> _activities = [];
|
private readonly List<ManagedActivity> _activities = [];
|
||||||
|
|
||||||
private LauncherInstallRecord? _installRecord;
|
private LauncherInstallRecord? _installRecord;
|
||||||
|
private string _installationStatus;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
public LauncherOrchestrator(
|
public LauncherOrchestrator(
|
||||||
|
|
@ -40,7 +41,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
ILauncherSessionConfigService? configService = null,
|
ILauncherSessionConfigService? configService = null,
|
||||||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||||
Func<string>? sessionIdFactory = null)
|
Func<string>? sessionIdFactory = null,
|
||||||
|
string? installationStatus = null)
|
||||||
{
|
{
|
||||||
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
|
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
|
||||||
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||||
|
|
@ -51,6 +53,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
|
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
|
||||||
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
|
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
|
||||||
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
|
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
|
||||||
|
_installationStatus = installationStatus
|
||||||
|
?? (installRecord is null
|
||||||
|
? FirstRunRequired
|
||||||
|
: "Client content paths are configured.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public event EventHandler? StateChanged;
|
public event EventHandler? StateChanged;
|
||||||
|
|
@ -85,9 +91,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
sessions,
|
sessions,
|
||||||
_platform,
|
_platform,
|
||||||
_installRecord is not null,
|
_installRecord is not null,
|
||||||
_installRecord is null
|
_installationStatus);
|
||||||
? FirstRunRequired
|
|
||||||
: "Client content paths are configured.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,6 +188,9 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
{
|
{
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
_installRecord = installRecord;
|
_installRecord = installRecord;
|
||||||
|
_installationStatus = installRecord is null
|
||||||
|
? FirstRunRequired
|
||||||
|
: "Client content SHA-256, size, and bake-tool version verified.";
|
||||||
}
|
}
|
||||||
|
|
||||||
RaiseStateChanged();
|
RaiseStateChanged();
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
<PublishSingleFile>true</PublishSingleFile>
|
<PublishSingleFile>true</PublishSingleFile>
|
||||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||||
<SelfContained Condition="'$(RuntimeIdentifier)' == 'linux-x64'">true</SelfContained>
|
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||||
|
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|
@ -22,4 +23,20 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- Distribution composition only: do not add a Launcher -> Bake project
|
||||||
|
reference. A per-RID launcher publish explicitly publishes the GL-free
|
||||||
|
CLI as its own self-contained single file into the same directory. -->
|
||||||
|
<Target Name="PublishCoDeployedBakeTool"
|
||||||
|
AfterTargets="Publish"
|
||||||
|
Condition="'$(RuntimeIdentifier)' != '' and '$(PublishBakeTool)' == 'true'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_BakePublishDirectory Condition="$([System.IO.Path]::IsPathRooted('$(PublishDir)'))">$(PublishDir)</_BakePublishDirectory>
|
||||||
|
<_BakePublishDirectory Condition="'$(_BakePublishDirectory)' == ''">$(MSBuildProjectDirectory)\$(PublishDir)</_BakePublishDirectory>
|
||||||
|
</PropertyGroup>
|
||||||
|
<MSBuild Projects="$(MSBuildProjectDirectory)\..\AcDream.Bake\AcDream.Bake.csproj"
|
||||||
|
Targets="Restore;Publish"
|
||||||
|
BuildInParallel="false"
|
||||||
|
Properties="Configuration=$(Configuration);RuntimeIdentifier=$(RuntimeIdentifier);SelfContained=true;PublishSingleFile=true;IncludeNativeLibrariesForSelfExtract=true;EnableSingleFileAnalyzer=false;PublishDir=$(_BakePublishDirectory);PublishBakeTool=false" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
@ -22,15 +23,40 @@ public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
ApplicationPathSet paths = ApplicationPathSet.Resolve();
|
||||||
LauncherProfileStore profiles = LauncherProfileStore.ForApplicationPaths(paths);
|
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(
|
_orchestrator = new LauncherOrchestrator(
|
||||||
profiles,
|
profiles,
|
||||||
paths,
|
paths,
|
||||||
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
|
LauncherExecutableSet.FromDirectory(AppContext.BaseDirectory),
|
||||||
install);
|
verification.Record,
|
||||||
|
installationStatus: verification.Status);
|
||||||
_viewModel = new LauncherWindowViewModel(
|
_viewModel = new LauncherWindowViewModel(
|
||||||
_orchestrator,
|
_orchestrator,
|
||||||
new AvaloniaUiDispatcher());
|
new AvaloniaUiDispatcher(),
|
||||||
|
installer);
|
||||||
_viewModel.Initialize();
|
_viewModel.Initialize();
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
|
|
@ -43,19 +69,6 @@ public sealed partial class App : Application
|
||||||
base.OnFrameworkInitializationCompleted();
|
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)
|
private void OnDesktopExit(object? sender, ControlledApplicationLifetimeExitEventArgs e)
|
||||||
{
|
{
|
||||||
_viewModel?.Dispose();
|
_viewModel?.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -342,21 +342,88 @@
|
||||||
KeyDown="OnModalKeyDown"
|
KeyDown="OnModalKeyDown"
|
||||||
AutomationProperties.Name="First-run setup modal dialog"
|
AutomationProperties.Name="First-run setup modal dialog"
|
||||||
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
IsVisible="{Binding FirstRunWizardShell.IsOpen}">
|
||||||
<Border Classes="card" Width="560" HorizontalAlignment="Center" VerticalAlignment="Center">
|
<Border Classes="card"
|
||||||
<StackPanel Spacing="12">
|
Width="680"
|
||||||
<TextBlock Text="{Binding FirstRunWizardShell.Title}" FontSize="24" FontWeight="Bold" />
|
MaxHeight="680"
|
||||||
<TextBlock Text="{Binding FirstRunWizardShell.Body}" TextWrapping="Wrap" />
|
HorizontalAlignment="Center"
|
||||||
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
<ScrollViewer>
|
||||||
</Border>
|
<StackPanel Spacing="12">
|
||||||
<Button x:Name="FirstRunCloseButton"
|
<TextBlock Text="{Binding FirstRunWizardShell.Title}" FontSize="24" FontWeight="Bold" />
|
||||||
Content="Close"
|
<TextBlock Text="{Binding FirstRunWizardShell.Body}" TextWrapping="Wrap" />
|
||||||
HorizontalAlignment="Right"
|
|
||||||
IsCancel="True"
|
<TextBlock Text="Retail DAT directory" FontWeight="SemiBold" />
|
||||||
IsDefault="True"
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||||
AutomationProperties.Name="Close first-run setup"
|
<TextBox x:Name="FirstRunDatDirectoryTextBox"
|
||||||
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
AutomationProperties.Name="Retail DAT directory"
|
||||||
</StackPanel>
|
IsEnabled="{Binding FirstRunWizardShell.CanEditInputs}"
|
||||||
|
PlaceholderText="Path containing client_portal.dat"
|
||||||
|
Text="{Binding FirstRunWizardShell.DatDirectory, Mode=TwoWay}" />
|
||||||
|
<Button Grid.Column="1"
|
||||||
|
Content="Browse..."
|
||||||
|
AutomationProperties.Name="Browse for retail DAT directory"
|
||||||
|
IsEnabled="{Binding FirstRunWizardShell.CanEditInputs}"
|
||||||
|
Click="OnBrowseDatDirectory" />
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Spacing="3">
|
||||||
|
<TextBlock Text="{Binding FirstRunWizardShell.ValidationStatus}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock Text="{Binding FirstRunWizardShell.MissingFiles}"
|
||||||
|
Foreground="#FFB0A0"
|
||||||
|
IsVisible="{Binding FirstRunWizardShell.HasMissingFiles}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="160,*" ColumnSpacing="10">
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Text="Bake worker threads" FontWeight="SemiBold" />
|
||||||
|
<TextBox AutomationProperties.Name="Bake worker threads"
|
||||||
|
IsEnabled="{Binding FirstRunWizardShell.CanEditInputs}"
|
||||||
|
Text="{Binding FirstRunWizardShell.ThreadsText, Mode=TwoWay}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
|
VerticalAlignment="Bottom"
|
||||||
|
Classes="muted"
|
||||||
|
Text="{Binding FirstRunWizardShell.ThreadCountValidation}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Border Background="#24344B" Padding="10" CornerRadius="5">
|
||||||
|
<StackPanel Spacing="7">
|
||||||
|
<TextBlock Text="{Binding FirstRunWizardShell.Status}" TextWrapping="Wrap" />
|
||||||
|
<ProgressBar Minimum="0"
|
||||||
|
Maximum="100"
|
||||||
|
Value="{Binding FirstRunWizardShell.ProgressPercent}"
|
||||||
|
IsIndeterminate="{Binding FirstRunWizardShell.IsProgressIndeterminate}" />
|
||||||
|
<TextBlock Text="{Binding FirstRunWizardShell.Phase}"
|
||||||
|
Classes="muted" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding FirstRunWizardShell.Error}"
|
||||||
|
Foreground="#FF9A9A"
|
||||||
|
IsVisible="{Binding FirstRunWizardShell.HasError}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8">
|
||||||
|
<Button Content="Validate"
|
||||||
|
AutomationProperties.Name="Validate retail DAT directory"
|
||||||
|
Command="{Binding FirstRunWizardShell.ValidateCommand}" />
|
||||||
|
<Button Content="Cancel bake"
|
||||||
|
AutomationProperties.Name="Cancel first-run bake"
|
||||||
|
Command="{Binding FirstRunWizardShell.CancelCommand}" />
|
||||||
|
<Button x:Name="FirstRunCloseButton"
|
||||||
|
Content="Close"
|
||||||
|
IsCancel="True"
|
||||||
|
AutomationProperties.Name="Close first-run setup"
|
||||||
|
Command="{Binding FirstRunWizardShell.CloseCommand}" />
|
||||||
|
<Button Content="Build and install"
|
||||||
|
Classes="primary"
|
||||||
|
IsDefault="True"
|
||||||
|
AutomationProperties.Name="Build and install client content"
|
||||||
|
Command="{Binding FirstRunWizardShell.StartCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ using System.ComponentModel;
|
||||||
using AcDream.Launcher.ViewModels;
|
using AcDream.Launcher.ViewModels;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
|
||||||
namespace AcDream.Launcher;
|
namespace AcDream.Launcher;
|
||||||
|
|
@ -116,7 +118,7 @@ public sealed partial class MainWindow : Window
|
||||||
}
|
}
|
||||||
else if (viewModel.FirstRunWizardShell.IsOpen)
|
else if (viewModel.FirstRunWizardShell.IsOpen)
|
||||||
{
|
{
|
||||||
FirstRunCloseButton.Focus();
|
FirstRunDatDirectoryTextBox.Focus();
|
||||||
}
|
}
|
||||||
else if (viewModel.UpdatePromptShell.IsOpen)
|
else if (viewModel.UpdatePromptShell.IsOpen)
|
||||||
{
|
{
|
||||||
|
|
@ -134,4 +136,33 @@ public sealed partial class MainWindow : Window
|
||||||
viewModel.CloseActiveModal();
|
viewModel.CloseActiveModal();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnBrowseDatDirectory(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not LauncherWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IReadOnlyList<IStorageFolder> folders = await StorageProvider
|
||||||
|
.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||||
|
{
|
||||||
|
Title = "Choose the retail Asheron's Call DAT directory",
|
||||||
|
AllowMultiple = false,
|
||||||
|
});
|
||||||
|
if (folders.Count > 0)
|
||||||
|
{
|
||||||
|
viewModel.FirstRunWizardShell.SelectDatDirectory(
|
||||||
|
folders[0].Path.LocalPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
viewModel.FirstRunWizardShell.ReportPickerError(ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
384
src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs
Normal file
384
src/AcDream.Launcher/ViewModels/FirstRunInstallerViewModel.cs
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
using System.ComponentModel;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thin wizard projection over the BCL-only installer transaction. Filesystem,
|
||||||
|
/// child-process, hashing, recovery, and record publication all remain in
|
||||||
|
/// Launcher.Core; this type owns only editable fields and UI command state.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FirstRunInstallerViewModel : ObservableObject, IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILauncherInstaller _installer;
|
||||||
|
private readonly IUiDispatcher _dispatcher;
|
||||||
|
private readonly Action<LauncherInstallRecord> _onInstalled;
|
||||||
|
private readonly Func<bool> _canOpen;
|
||||||
|
private readonly Func<bool> _canStart;
|
||||||
|
private CancellationTokenSource? _cancellation;
|
||||||
|
private string _datDirectory = string.Empty;
|
||||||
|
private string _threadsText = Math.Max(1, Environment.ProcessorCount).ToString();
|
||||||
|
private string _status = "Choose the folder containing the retail DAT files.";
|
||||||
|
private string _validationStatus = "No DAT directory selected.";
|
||||||
|
private string _missingFiles = string.Empty;
|
||||||
|
private string? _error;
|
||||||
|
private LauncherInstallPhase _phase = LauncherInstallPhase.Idle;
|
||||||
|
private double _progressPercent;
|
||||||
|
private bool _isOpen;
|
||||||
|
private bool _isRunning;
|
||||||
|
private bool _isDatDirectoryValid;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public FirstRunInstallerViewModel(
|
||||||
|
ILauncherInstaller installer,
|
||||||
|
IUiDispatcher dispatcher,
|
||||||
|
Action<LauncherInstallRecord> onInstalled,
|
||||||
|
Func<bool>? canOpen = null,
|
||||||
|
Func<bool>? canStart = null)
|
||||||
|
{
|
||||||
|
_installer = installer ?? throw new ArgumentNullException(nameof(installer));
|
||||||
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
|
_onInstalled = onInstalled ?? throw new ArgumentNullException(nameof(onInstalled));
|
||||||
|
_canOpen = canOpen ?? (() => true);
|
||||||
|
_canStart = canStart ?? (() => true);
|
||||||
|
|
||||||
|
OpenCommand = new RelayCommand(Open, () => !_disposed && _canOpen());
|
||||||
|
CloseCommand = new RelayCommand(Close, () => !IsRunning);
|
||||||
|
ValidateCommand = new RelayCommand(Validate, () => !IsRunning);
|
||||||
|
StartCommand = new AsyncRelayCommand(StartAsync, CanBeginInstall);
|
||||||
|
CancelCommand = new RelayCommand(
|
||||||
|
() => _cancellation?.Cancel(),
|
||||||
|
() => IsRunning && _cancellation is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Title => "First-run setup";
|
||||||
|
|
||||||
|
public string Body =>
|
||||||
|
"Select the retail Asheron's Call DAT folder. acdream will validate "
|
||||||
|
+ "the four required files, build DataDirectory/pak/acdream.pak, and "
|
||||||
|
+ "verify its SHA-256 before enabling launch.";
|
||||||
|
|
||||||
|
public string DatDirectory
|
||||||
|
{
|
||||||
|
get => _datDirectory;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _datDirectory, value ?? string.Empty))
|
||||||
|
{
|
||||||
|
Validate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ThreadsText
|
||||||
|
{
|
||||||
|
get => _threadsText;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _threadsText, value ?? string.Empty))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsThreadCountValid));
|
||||||
|
OnPropertyChanged(nameof(ThreadCountValidation));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsThreadCountValid =>
|
||||||
|
int.TryParse(ThreadsText, out int threads) && threads > 0;
|
||||||
|
|
||||||
|
public string ThreadCountValidation => IsThreadCountValid
|
||||||
|
? "Worker count is valid."
|
||||||
|
: "Threads must be a positive whole number.";
|
||||||
|
|
||||||
|
public string Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
private set => SetProperty(ref _status, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ValidationStatus
|
||||||
|
{
|
||||||
|
get => _validationStatus;
|
||||||
|
private set => SetProperty(ref _validationStatus, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string MissingFiles
|
||||||
|
{
|
||||||
|
get => _missingFiles;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _missingFiles, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasMissingFiles));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasMissingFiles => MissingFiles.Length > 0;
|
||||||
|
|
||||||
|
public string? Error
|
||||||
|
{
|
||||||
|
get => _error;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _error, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasError => !string.IsNullOrWhiteSpace(Error);
|
||||||
|
|
||||||
|
public LauncherInstallPhase Phase
|
||||||
|
{
|
||||||
|
get => _phase;
|
||||||
|
private set => SetProperty(ref _phase, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double ProgressPercent
|
||||||
|
{
|
||||||
|
get => _progressPercent;
|
||||||
|
private set => SetProperty(ref _progressPercent, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsProgressIndeterminate =>
|
||||||
|
IsRunning && ProgressPercent <= 0;
|
||||||
|
|
||||||
|
public bool CanEditInputs => !IsRunning;
|
||||||
|
|
||||||
|
public bool IsOpen
|
||||||
|
{
|
||||||
|
get => _isOpen;
|
||||||
|
private set => SetProperty(ref _isOpen, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsRunning
|
||||||
|
{
|
||||||
|
get => _isRunning;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isRunning, value))
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(IsProgressIndeterminate));
|
||||||
|
OnPropertyChanged(nameof(CanEditInputs));
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsDatDirectoryValid
|
||||||
|
{
|
||||||
|
get => _isDatDirectoryValid;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isDatDirectoryValid, value))
|
||||||
|
{
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelayCommand OpenCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand CloseCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand ValidateCommand { get; }
|
||||||
|
|
||||||
|
public AsyncRelayCommand StartCommand { get; }
|
||||||
|
|
||||||
|
public RelayCommand CancelCommand { get; }
|
||||||
|
|
||||||
|
public void SelectDatDirectory(string directory) => DatDirectory = directory;
|
||||||
|
|
||||||
|
public void ReportPickerError(string message)
|
||||||
|
{
|
||||||
|
Error = string.IsNullOrWhiteSpace(message)
|
||||||
|
? "The DAT directory picker failed."
|
||||||
|
: message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NotifyCommandStates()
|
||||||
|
{
|
||||||
|
OpenCommand.NotifyCanExecuteChanged();
|
||||||
|
CloseCommand.NotifyCanExecuteChanged();
|
||||||
|
ValidateCommand.NotifyCanExecuteChanged();
|
||||||
|
StartCommand.NotifyCanExecuteChanged();
|
||||||
|
CancelCommand.NotifyCanExecuteChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Close()
|
||||||
|
{
|
||||||
|
if (!IsRunning)
|
||||||
|
{
|
||||||
|
IsOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_cancellation?.Cancel();
|
||||||
|
_cancellation?.Dispose();
|
||||||
|
_cancellation = null;
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Open()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(DatDirectory))
|
||||||
|
{
|
||||||
|
IReadOnlyList<DatDirectoryValidation> candidates =
|
||||||
|
_installer.DetectDatDirectories();
|
||||||
|
DatDirectoryValidation? preferred =
|
||||||
|
candidates.FirstOrDefault(candidate => candidate.IsValid)
|
||||||
|
?? candidates.FirstOrDefault();
|
||||||
|
if (preferred is not null)
|
||||||
|
{
|
||||||
|
_datDirectory = preferred.Directory;
|
||||||
|
OnPropertyChanged(nameof(DatDirectory));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Validate();
|
||||||
|
IsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Validate()
|
||||||
|
{
|
||||||
|
if (IsRunning)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DatDirectoryValidation validation =
|
||||||
|
_installer.ValidateDatDirectory(DatDirectory);
|
||||||
|
IsDatDirectoryValid = validation.IsValid;
|
||||||
|
ValidationStatus = validation.Message;
|
||||||
|
MissingFiles = validation.MissingFileNames.Count == 0
|
||||||
|
? string.Empty
|
||||||
|
: "Missing: " + string.Join(", ", validation.MissingFileNames);
|
||||||
|
Error = null;
|
||||||
|
NotifyCommandStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanBeginInstall() =>
|
||||||
|
!_disposed
|
||||||
|
&& IsOpen
|
||||||
|
&& !IsRunning
|
||||||
|
&& IsDatDirectoryValid
|
||||||
|
&& IsThreadCountValid
|
||||||
|
&& _canStart();
|
||||||
|
|
||||||
|
private async Task StartAsync()
|
||||||
|
{
|
||||||
|
if (!int.TryParse(ThreadsText, out int threads) || threads <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
_cancellation = cancellation;
|
||||||
|
IsRunning = true;
|
||||||
|
Error = null;
|
||||||
|
ProgressPercent = 0;
|
||||||
|
Phase = LauncherInstallPhase.ValidatingDatFiles;
|
||||||
|
Status = "Starting installation...";
|
||||||
|
|
||||||
|
var progress = new CallbackProgress<LauncherInstallProgress>(value =>
|
||||||
|
_dispatcher.Post(() => ApplyProgress(value)));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
LauncherInstallResult result = await _installer.InstallAsync(
|
||||||
|
DatDirectory,
|
||||||
|
threads,
|
||||||
|
progress,
|
||||||
|
cancellation.Token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
_onInstalled(result.Record);
|
||||||
|
Phase = LauncherInstallPhase.Completed;
|
||||||
|
ProgressPercent = 100;
|
||||||
|
Status = "Client content installed and verified. Launch is enabled.";
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Phase = LauncherInstallPhase.Cancelled;
|
||||||
|
Status = "Installation cancelled. The previous verified install was preserved.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Phase = LauncherInstallPhase.Failed;
|
||||||
|
Error = string.IsNullOrWhiteSpace(ex.Message)
|
||||||
|
? "Installation failed."
|
||||||
|
: ex.Message;
|
||||||
|
Status = "Installation failed; no new install record was published.";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_cancellation, cancellation))
|
||||||
|
{
|
||||||
|
_cancellation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
IsRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyProgress(LauncherInstallProgress progress)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Phase = progress.Phase;
|
||||||
|
Status = progress.Status;
|
||||||
|
ProgressPercent = progress.Total > 0
|
||||||
|
? progress.Fraction * 100
|
||||||
|
: 0;
|
||||||
|
OnPropertyChanged(nameof(IsProgressIndeterminate));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class CallbackProgress<T>(Action<T> callback) : IProgress<T>
|
||||||
|
{
|
||||||
|
private readonly Action<T> _callback = callback
|
||||||
|
?? throw new ArgumentNullException(nameof(callback));
|
||||||
|
|
||||||
|
public void Report(T value) => _callback(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class UnavailableLauncherInstaller : ILauncherInstaller
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() => [];
|
||||||
|
|
||||||
|
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
|
||||||
|
new(
|
||||||
|
directory ?? string.Empty,
|
||||||
|
false,
|
||||||
|
"The installer service is unavailable in this host.",
|
||||||
|
DatDirectoryLocator.RequiredFileNames);
|
||||||
|
|
||||||
|
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(new InstallRecordVerification(
|
||||||
|
InstallRecordVerificationState.Missing,
|
||||||
|
null,
|
||||||
|
"The installer service is unavailable in this host."));
|
||||||
|
|
||||||
|
public Task<LauncherInstallResult> InstallAsync(
|
||||||
|
string datDirectory,
|
||||||
|
int threads,
|
||||||
|
IProgress<LauncherInstallProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromException<LauncherInstallResult>(
|
||||||
|
new LauncherInstallException(
|
||||||
|
"The installer service is unavailable in this host."));
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
|
||||||
|
|
@ -22,20 +24,20 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
public LauncherWindowViewModel(
|
public LauncherWindowViewModel(
|
||||||
ILauncherOrchestrator orchestrator,
|
ILauncherOrchestrator orchestrator,
|
||||||
IUiDispatcher dispatcher)
|
IUiDispatcher dispatcher,
|
||||||
|
ILauncherInstaller? installer = null)
|
||||||
{
|
{
|
||||||
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
_orchestrator = orchestrator ?? throw new ArgumentNullException(nameof(orchestrator));
|
||||||
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
|
||||||
_orchestrator.StateChanged += OnOrchestratorStateChanged;
|
_orchestrator.StateChanged += OnOrchestratorStateChanged;
|
||||||
|
|
||||||
EditorDialog = new ProfileEditorDialogViewModel();
|
EditorDialog = new ProfileEditorDialogViewModel();
|
||||||
FirstRunWizardShell = new LauncherShellViewModel(
|
FirstRunWizardShell = new FirstRunInstallerViewModel(
|
||||||
"First-run setup",
|
installer ?? new UnavailableLauncherInstaller(),
|
||||||
"Choose and validate the retail DAT directory, build acdream.pak, "
|
dispatcher,
|
||||||
+ "and record the installed client. The installer transaction and "
|
OnInstallCompleted,
|
||||||
+ "progress body land in Campaign LA slice LA9.",
|
() => CanInteract,
|
||||||
"Installer shell ready — implementation arrives in LA9.",
|
() => !IsBusy && Sessions.All(session => !session.IsActive));
|
||||||
() => CanInteract);
|
|
||||||
UpdatePromptShell = new LauncherShellViewModel(
|
UpdatePromptShell = new LauncherShellViewModel(
|
||||||
"Client update",
|
"Client update",
|
||||||
"Review a signed release manifest, verify the downloaded archive, "
|
"Review a signed release manifest, verify the downloaded archive, "
|
||||||
|
|
@ -88,7 +90,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
|
|
||||||
public ProfileEditorDialogViewModel EditorDialog { get; }
|
public ProfileEditorDialogViewModel EditorDialog { get; }
|
||||||
|
|
||||||
public LauncherShellViewModel FirstRunWizardShell { get; }
|
public FirstRunInstallerViewModel FirstRunWizardShell { get; }
|
||||||
|
|
||||||
public LauncherShellViewModel UpdatePromptShell { get; }
|
public LauncherShellViewModel UpdatePromptShell { get; }
|
||||||
|
|
||||||
|
|
@ -332,6 +334,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
EditorDialog.PropertyChanged -= OnModalPropertyChanged;
|
||||||
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
FirstRunWizardShell.PropertyChanged -= OnModalPropertyChanged;
|
||||||
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
|
UpdatePromptShell.PropertyChanged -= OnModalPropertyChanged;
|
||||||
|
FirstRunWizardShell.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
private void OnOrchestratorStateChanged(object? sender, EventArgs e) =>
|
||||||
|
|
@ -346,7 +349,8 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
private void OnModalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
if (e.PropertyName != nameof(ProfileEditorDialogViewModel.IsOpen)
|
||||||
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen))
|
&& e.PropertyName != nameof(LauncherShellViewModel.IsOpen)
|
||||||
|
&& e.PropertyName != nameof(FirstRunInstallerViewModel.IsOpen))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -370,7 +374,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
}
|
}
|
||||||
else if (FirstRunWizardShell.IsOpen)
|
else if (FirstRunWizardShell.IsOpen)
|
||||||
{
|
{
|
||||||
FirstRunWizardShell.IsOpen = false;
|
FirstRunWizardShell.Close();
|
||||||
}
|
}
|
||||||
else if (UpdatePromptShell.IsOpen)
|
else if (UpdatePromptShell.IsOpen)
|
||||||
{
|
{
|
||||||
|
|
@ -964,6 +968,14 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
|
||||||
: message;
|
: message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnInstallCompleted(LauncherInstallRecord record)
|
||||||
|
{
|
||||||
|
_orchestrator.SetInstallRecord(record);
|
||||||
|
OperationStatus = "Client content installed and verified.";
|
||||||
|
LastError = null;
|
||||||
|
RefreshFromCore();
|
||||||
|
}
|
||||||
|
|
||||||
private void NotifyCommandStates()
|
private void NotifyCommandStates()
|
||||||
{
|
{
|
||||||
AddServerCommand.NotifyCanExecuteChanged();
|
AddServerCommand.NotifyCanExecuteChanged();
|
||||||
|
|
|
||||||
35
src/AcDream.Platform/BakePublicationGuardPaths.cs
Normal file
35
src/AcDream.Platform/BakePublicationGuardPaths.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
namespace AcDream.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Portable, versioned naming contract shared by the launcher parent and the
|
||||||
|
/// independently published bake child. The durable token grants one child
|
||||||
|
/// permission to publish while the adjacent OS-held lock serializes its final
|
||||||
|
/// promotion with launcher recovery.
|
||||||
|
/// </summary>
|
||||||
|
public static class BakePublicationGuardPaths
|
||||||
|
{
|
||||||
|
public const string NonceEnvironmentVariable =
|
||||||
|
"ACDREAM_BAKE_PUBLISH_NONCE_V1";
|
||||||
|
public const string PublishLockSuffix = ".publish.lock";
|
||||||
|
public const string AuthorizationSuffix = ".publish-token";
|
||||||
|
|
||||||
|
public static string CreateNonce() => Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
public static bool IsValidNonce(string? nonce) =>
|
||||||
|
nonce is not null
|
||||||
|
&& nonce.Length == 32
|
||||||
|
&& Guid.TryParseExact(nonce, "N", out Guid parsed)
|
||||||
|
&& string.Equals(parsed.ToString("N"), nonce, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
public static string GetPublishLockPath(string outputPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||||
|
return Path.GetFullPath(outputPath) + PublishLockSuffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetAuthorizationPath(string outputPath)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||||
|
return Path.GetFullPath(outputPath) + AuthorizationSuffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,24 @@ public sealed class BakeOutputTransactionTests : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StagingPathUsesTheDocumentedLauncherRecoveryContract()
|
||||||
|
{
|
||||||
|
string destination = Path.Combine(_directory, "pak", "acdream.pak");
|
||||||
|
Guid transaction = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef");
|
||||||
|
|
||||||
|
string staging = BakeOutputTransaction.CreateStagingPath(
|
||||||
|
destination,
|
||||||
|
transaction);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(
|
||||||
|
_directory,
|
||||||
|
"pak",
|
||||||
|
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
|
||||||
|
staging);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
|
public void Publish_ReplacesExistingDestinationOnlyAfterValidation()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
115
tests/AcDream.Bake.Tests/BakeProgressCliTests.cs
Normal file
115
tests/AcDream.Bake.Tests/BakeProgressCliTests.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Content.Pak;
|
||||||
|
|
||||||
|
namespace AcDream.Bake.Tests;
|
||||||
|
|
||||||
|
public sealed class BakeProgressCliTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("--help")]
|
||||||
|
[InlineData("-h")]
|
||||||
|
public void HelpIsAZeroDatArgumentProbe(string argument)
|
||||||
|
{
|
||||||
|
Assert.True(BakeCommandLine.IsHelpRequest([argument]));
|
||||||
|
Assert.False(BakeCommandLine.IsHelpRequest([argument, "extra"]));
|
||||||
|
Assert.Contains("--help", BakeCommandLine.Usage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProgressJsonFlagIsOptInAndDefaultOutputRemainsInTheDatDirectory()
|
||||||
|
{
|
||||||
|
using var errors = new StringWriter();
|
||||||
|
Assert.True(BakeCommandLine.TryParse(
|
||||||
|
["--dat-dir", "retail-dats"],
|
||||||
|
errors,
|
||||||
|
out BakeCommandLineOptions? defaults));
|
||||||
|
|
||||||
|
Assert.NotNull(defaults);
|
||||||
|
Assert.False(defaults.ProgressJson);
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine("retail-dats", "acdream.pak"),
|
||||||
|
defaults.OutputPath);
|
||||||
|
|
||||||
|
Assert.True(BakeCommandLine.TryParse(
|
||||||
|
[
|
||||||
|
"--dat-dir", "retail-dats",
|
||||||
|
"--out", "prepared/acdream.pak",
|
||||||
|
"--threads", "7",
|
||||||
|
"--progress-json",
|
||||||
|
],
|
||||||
|
errors,
|
||||||
|
out BakeCommandLineOptions? machine));
|
||||||
|
|
||||||
|
Assert.NotNull(machine);
|
||||||
|
Assert.True(machine.ProgressJson);
|
||||||
|
Assert.Equal("prepared/acdream.pak", machine.OutputPath);
|
||||||
|
Assert.Equal(7, machine.Threads);
|
||||||
|
Assert.Contains("--progress-json", BakeCommandLine.Usage, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void HumanFiveSecondLineIsAlwaysWrittenAndJsonIsOnlyWrittenWhenEnabled()
|
||||||
|
{
|
||||||
|
using var humanOnly = new StringWriter();
|
||||||
|
BakeProgressReporter.Write(
|
||||||
|
humanOnly,
|
||||||
|
machineOutput: null,
|
||||||
|
phase: "mesh",
|
||||||
|
completed: 1250,
|
||||||
|
total: 5000,
|
||||||
|
failures: 2,
|
||||||
|
elapsed: TimeSpan.FromSeconds(5),
|
||||||
|
etaSeconds: 15,
|
||||||
|
privateBytes: 64L * 1024 * 1024,
|
||||||
|
managedBytes: 16L * 1024 * 1024);
|
||||||
|
|
||||||
|
string defaultText = humanOnly.ToString();
|
||||||
|
Assert.Contains("[00:00:05] extracted", defaultText, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("failures=2", defaultText, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("\"v\":", defaultText, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
using var combined = new StringWriter();
|
||||||
|
var json = new BakeProgressJsonWriter(combined);
|
||||||
|
BakeProgressReporter.Write(
|
||||||
|
combined,
|
||||||
|
json,
|
||||||
|
phase: "collision",
|
||||||
|
completed: 5,
|
||||||
|
total: 10,
|
||||||
|
failures: 0,
|
||||||
|
elapsed: TimeSpan.FromSeconds(10),
|
||||||
|
etaSeconds: 10,
|
||||||
|
privateBytes: 1,
|
||||||
|
managedBytes: 2);
|
||||||
|
|
||||||
|
string[] lines = combined.ToString().Split(
|
||||||
|
Environment.NewLine,
|
||||||
|
StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
Assert.Equal(2, lines.Length);
|
||||||
|
Assert.StartsWith("[00:00:10] extracted", lines[0], StringComparison.Ordinal);
|
||||||
|
using JsonDocument document = JsonDocument.Parse(lines[1]);
|
||||||
|
Assert.Equal(1, document.RootElement.GetProperty("v").GetInt32());
|
||||||
|
Assert.Equal("progress", document.RootElement.GetProperty("e").GetString());
|
||||||
|
Assert.Equal("collision", document.RootElement.GetProperty("phase").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VersionedWriterCarriesCurrentBakeVersionOnTerminalRecords()
|
||||||
|
{
|
||||||
|
using var output = new StringWriter();
|
||||||
|
var writer = new BakeProgressJsonWriter(output);
|
||||||
|
|
||||||
|
writer.Started(PakFormat.CurrentBakeToolVersion, "prepared/acdream.pak");
|
||||||
|
writer.Completed(PakFormat.CurrentBakeToolVersion, 1234, failures: 0);
|
||||||
|
|
||||||
|
JsonElement[] events = output.ToString()
|
||||||
|
.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(line => JsonDocument.Parse(line).RootElement.Clone())
|
||||||
|
.ToArray();
|
||||||
|
Assert.Equal(["started", "completed"], events.Select(value =>
|
||||||
|
value.GetProperty("e").GetString()));
|
||||||
|
Assert.All(events, value => Assert.Equal(
|
||||||
|
PakFormat.CurrentBakeToolVersion,
|
||||||
|
value.GetProperty("bakeToolVersion").GetUInt32()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Bake\AcDream.Bake.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using AcDream.Bake;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
return args.FirstOrDefault() switch
|
||||||
|
{
|
||||||
|
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
||||||
|
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
||||||
|
"orphan-child" => RunOrphanChild(args[1..]),
|
||||||
|
_ => 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
static async Task<int> HoldInstallLeaseAsync(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 3)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string lockPath = Path.GetFullPath(arguments[0]);
|
||||||
|
string stagingPath = Path.GetFullPath(arguments[1]);
|
||||||
|
string readyPath = Path.GetFullPath(arguments[2]);
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(lockPath)
|
||||||
|
?? throw new InvalidOperationException("lock path has no parent"));
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(stagingPath)
|
||||||
|
?? throw new InvalidOperationException("staging path has no parent"));
|
||||||
|
|
||||||
|
using var lease = new FileStream(
|
||||||
|
lockPath,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None);
|
||||||
|
File.WriteAllText(stagingPath, "abandoned bake staging");
|
||||||
|
File.WriteAllText(readyPath, "ready");
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<int> RunOrphanParentAsync(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 8)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||||
|
string datDirectory = Path.GetFullPath(arguments[1]);
|
||||||
|
string bakeMarker = Path.GetFullPath(arguments[2]);
|
||||||
|
string schedule = arguments[3];
|
||||||
|
string childReadyPath = Path.GetFullPath(arguments[4]);
|
||||||
|
string childReleasePath = Path.GetFullPath(arguments[5]);
|
||||||
|
string childPidPath = Path.GetFullPath(arguments[6]);
|
||||||
|
string childExitPath = Path.GetFullPath(arguments[7]);
|
||||||
|
var paths = new ApplicationPathSet(
|
||||||
|
Path.Combine(dataDirectory, "fixture-config"),
|
||||||
|
dataDirectory,
|
||||||
|
Path.Combine(dataDirectory, "fixture-cache"),
|
||||||
|
null);
|
||||||
|
var runner = new OrphanBakeProcessRunner(
|
||||||
|
schedule,
|
||||||
|
childReadyPath,
|
||||||
|
childReleasePath,
|
||||||
|
childPidPath,
|
||||||
|
childExitPath);
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
paths,
|
||||||
|
bakeMarker,
|
||||||
|
processRunner: runner);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await installer.InstallAsync(datDirectory, 1);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int RunOrphanChild(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 5)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string outputPath = Path.GetFullPath(arguments[0]);
|
||||||
|
string schedule = arguments[1];
|
||||||
|
string readyPath = Path.GetFullPath(arguments[2]);
|
||||||
|
string releasePath = Path.GetFullPath(arguments[3]);
|
||||||
|
string exitPath = Path.GetFullPath(arguments[4]);
|
||||||
|
Action barrier = () =>
|
||||||
|
{
|
||||||
|
File.WriteAllText(readyPath, schedule);
|
||||||
|
while (!File.Exists(releasePath))
|
||||||
|
{
|
||||||
|
Thread.Sleep(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
int exitCode;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BakeOutputTransaction.WriteValidateAndPublish(
|
||||||
|
outputPath,
|
||||||
|
temporaryPath =>
|
||||||
|
{
|
||||||
|
File.WriteAllText(temporaryPath, "orphan replacement");
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
(temporaryPath, _) =>
|
||||||
|
{
|
||||||
|
if (File.ReadAllText(temporaryPath) != "orphan replacement")
|
||||||
|
{
|
||||||
|
throw new InvalidDataException("staging content changed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforePublicationLock: schedule == "late" ? barrier : null,
|
||||||
|
beforePromotion: schedule == "holds" ? barrier : null,
|
||||||
|
CancellationToken.None);
|
||||||
|
exitCode = 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
File.WriteAllText(exitPath + ".error", ex.Message);
|
||||||
|
exitCode = 17;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(exitPath, exitCode.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
file sealed class OrphanBakeProcessRunner(
|
||||||
|
string schedule,
|
||||||
|
string childReadyPath,
|
||||||
|
string childReleasePath,
|
||||||
|
string childPidPath,
|
||||||
|
string childExitPath) : IBakeProcessRunner
|
||||||
|
{
|
||||||
|
public async Task<BakeProcessResult> RunAsync(
|
||||||
|
BakeProcessRequest request,
|
||||||
|
Action<string> onStandardOutput,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
string dotnetHost = Environment.ProcessPath
|
||||||
|
?? throw new InvalidOperationException("dotnet host path is unavailable");
|
||||||
|
string fixtureDll = Assembly.GetExecutingAssembly().Location;
|
||||||
|
var startInfo = new ProcessStartInfo(dotnetHost)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(fixtureDll);
|
||||||
|
startInfo.ArgumentList.Add("orphan-child");
|
||||||
|
startInfo.ArgumentList.Add(request.OutputPath);
|
||||||
|
startInfo.ArgumentList.Add(schedule);
|
||||||
|
startInfo.ArgumentList.Add(childReadyPath);
|
||||||
|
startInfo.ArgumentList.Add(childReleasePath);
|
||||||
|
startInfo.ArgumentList.Add(childExitPath);
|
||||||
|
startInfo.Environment.Remove(
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable);
|
||||||
|
startInfo.Environment[
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable] =
|
||||||
|
request.PublicationNonce
|
||||||
|
?? throw new InvalidOperationException("publication nonce is missing");
|
||||||
|
|
||||||
|
using Process child = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("orphan child did not start");
|
||||||
|
File.WriteAllText(
|
||||||
|
childPidPath,
|
||||||
|
child.Id.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
await child.WaitForExitAsync(cancellationToken);
|
||||||
|
if (child.ExitCode == 0)
|
||||||
|
{
|
||||||
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
|
onStandardOutput("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||||
|
onStandardOutput($"{{\"v\":1,\"e\":\"completed\","
|
||||||
|
+ $"\"bakeToolVersion\":4,\"outputBytes\":{bytes},"
|
||||||
|
+ "\"failures\":0}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BakeProcessResult(child.ExitCode, "orphan fixture child");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,5 +19,11 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
<ProjectReference Include="..\..\src\AcDream.Platform\AcDream.Platform.csproj" />
|
||||||
|
<!-- Build ordering only. The crash-recovery test launches this fixture in
|
||||||
|
a separate process so the OS owns and releases the install lease. -->
|
||||||
|
<ProjectReference Include="..\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder\AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.csproj">
|
||||||
|
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
|
||||||
|
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
|
||||||
|
</ProjectReference>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class BakeProcessRunnerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PublicationNonceIsEnvironmentOnlyAndVisibleArgumentsStayPinned()
|
||||||
|
{
|
||||||
|
string nonce = Guid.Parse("01234567-89ab-cdef-0123-456789abcdef")
|
||||||
|
.ToString("N");
|
||||||
|
var request = new BakeProcessRequest(
|
||||||
|
"acdream-bake",
|
||||||
|
"retail-dats",
|
||||||
|
"data/pak/acdream.pak",
|
||||||
|
7,
|
||||||
|
nonce);
|
||||||
|
|
||||||
|
System.Diagnostics.ProcessStartInfo startInfo =
|
||||||
|
SystemBakeProcessRunner.CreateStartInfo(request);
|
||||||
|
|
||||||
|
Assert.Equal(request.Arguments, startInfo.ArgumentList);
|
||||||
|
Assert.DoesNotContain(nonce, startInfo.ArgumentList);
|
||||||
|
Assert.Equal(
|
||||||
|
nonce,
|
||||||
|
startInfo.Environment[
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnguardedRequestExplicitlyRemovesInheritedAuthorization()
|
||||||
|
{
|
||||||
|
var request = new BakeProcessRequest(
|
||||||
|
"acdream-bake",
|
||||||
|
"retail-dats",
|
||||||
|
"data/pak/acdream.pak",
|
||||||
|
1);
|
||||||
|
|
||||||
|
System.Diagnostics.ProcessStartInfo startInfo =
|
||||||
|
SystemBakeProcessRunner.CreateStartInfo(request);
|
||||||
|
|
||||||
|
Assert.False(startInfo.Environment.ContainsKey(
|
||||||
|
BakePublicationGuardPaths.NonceEnvironmentVariable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class BakeProgressJsonlParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PartialChunksAreBufferedUntilTheJsonLineIsComplete()
|
||||||
|
{
|
||||||
|
var parser = new BakeProgressJsonlParser();
|
||||||
|
|
||||||
|
Assert.IsType<BakeHumanOutputEvent>(Assert.Single(
|
||||||
|
parser.Append("human startup text\n{\"v\":1,\"e\":\"pro")));
|
||||||
|
IReadOnlyList<BakeProgressEvent> events = parser.Append(
|
||||||
|
"gress\",\"phase\":\"mesh\",\"completed\":4,\"total\":10,"
|
||||||
|
+ "\"failures\":0,\"elapsedSeconds\":5,\"etaSeconds\":7}\n");
|
||||||
|
|
||||||
|
Assert.Single(events);
|
||||||
|
BakeWorkProgressEvent progress =
|
||||||
|
Assert.IsType<BakeWorkProgressEvent>(events[0]);
|
||||||
|
Assert.Equal("mesh", progress.Phase);
|
||||||
|
Assert.Equal(4, progress.Completed);
|
||||||
|
Assert.Equal(10, progress.Total);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MalformedKnownPayloadAndTruncatedFinalLineNeverThrow()
|
||||||
|
{
|
||||||
|
var parser = new BakeProgressJsonlParser();
|
||||||
|
IReadOnlyList<BakeProgressEvent> first = parser.Append(
|
||||||
|
"{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\"}\n"
|
||||||
|
+ "{not-json");
|
||||||
|
Assert.IsType<MalformedBakeProgressEvent>(Assert.Single(first));
|
||||||
|
|
||||||
|
MalformedBakeProgressEvent final = Assert.IsType<MalformedBakeProgressEvent>(
|
||||||
|
Assert.Single(parser.Complete()));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(final.Reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UnknownKindsAndFutureVersionsRemainTypedAndFutureSafe()
|
||||||
|
{
|
||||||
|
var parser = new BakeProgressJsonlParser();
|
||||||
|
IReadOnlyList<BakeProgressEvent> events = parser.Append(
|
||||||
|
"{\"v\":1,\"e\":\"newMetric\",\"value\":9}\n"
|
||||||
|
+ "{\"v\":2,\"e\":\"progress\",\"newShape\":true}\n"
|
||||||
|
+ "{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4,"
|
||||||
|
+ "\"outputPath\":\"pak\",\"futureField\":42}\n");
|
||||||
|
|
||||||
|
Assert.IsType<UnknownBakeProgressEvent>(events[0]);
|
||||||
|
FutureBakeProgressEvent future =
|
||||||
|
Assert.IsType<FutureBakeProgressEvent>(events[1]);
|
||||||
|
Assert.Equal(2, future.Version);
|
||||||
|
BakeStartedEvent started = Assert.IsType<BakeStartedEvent>(events[2]);
|
||||||
|
Assert.Equal(4u, started.BakeToolVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class BakeProgressProtocolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void OneStartedProgressAndCompletedSequenceIsAccepted()
|
||||||
|
{
|
||||||
|
var protocol = new BakeProgressProtocol();
|
||||||
|
|
||||||
|
Assert.True(protocol.Observe(new BakeHumanOutputEvent("human")));
|
||||||
|
Assert.True(protocol.Observe(new UnknownBakeProgressEvent(
|
||||||
|
1,
|
||||||
|
"newMetric",
|
||||||
|
"{}")));
|
||||||
|
Assert.True(protocol.Observe(new FutureBakeProgressEvent(
|
||||||
|
2,
|
||||||
|
"started",
|
||||||
|
"{}")));
|
||||||
|
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, "pak")));
|
||||||
|
Assert.True(protocol.Observe(new BakeWorkProgressEvent(
|
||||||
|
1,
|
||||||
|
"mesh",
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1)));
|
||||||
|
Assert.True(protocol.Observe(new BakeCompletedEvent(1, 4, 100, 0)));
|
||||||
|
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
|
Assert.Null(protocol.Violation);
|
||||||
|
Assert.NotNull(protocol.Started);
|
||||||
|
Assert.NotNull(protocol.Completed);
|
||||||
|
Assert.Null(protocol.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[MemberData(nameof(InvalidKnownSequences))]
|
||||||
|
public void OutOfOrderDuplicateAndPostTerminalKnownEventsAreRejected(
|
||||||
|
BakeProgressEvent[] events)
|
||||||
|
{
|
||||||
|
var protocol = new BakeProgressProtocol();
|
||||||
|
|
||||||
|
foreach (BakeProgressEvent progressEvent in events)
|
||||||
|
{
|
||||||
|
protocol.Observe(progressEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
|
Assert.NotNull(protocol.Violation);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ErrorTerminalCannotBeOverwrittenByContradictoryCompletion()
|
||||||
|
{
|
||||||
|
var protocol = new BakeProgressProtocol();
|
||||||
|
var failure = new BakeErrorEvent(1, "first failure");
|
||||||
|
|
||||||
|
Assert.True(protocol.Observe(new BakeStartedEvent(1, 4, null)));
|
||||||
|
Assert.True(protocol.Observe(failure));
|
||||||
|
Assert.False(protocol.Observe(new BakeCompletedEvent(1, 4, 10, 0)));
|
||||||
|
protocol.CompleteInput();
|
||||||
|
|
||||||
|
Assert.Same(failure, protocol.Error);
|
||||||
|
Assert.Null(protocol.Completed);
|
||||||
|
Assert.Contains("after", protocol.Violation, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TheoryData<BakeProgressEvent[]> InvalidKnownSequences => new()
|
||||||
|
{
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeWorkProgressEvent(1, "mesh", 0, 1, 0, 0, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeErrorEvent(1, "before start"),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
new BakeCompletedEvent(1, 4, 10, 0),
|
||||||
|
new BakeWorkProgressEvent(1, "mesh", 1, 1, 0, 1, 0),
|
||||||
|
},
|
||||||
|
new BakeProgressEvent[]
|
||||||
|
{
|
||||||
|
new BakeStartedEvent(1, 4, null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class DatDirectoryLocatorTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-dat-locator-tests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public DatDirectoryLocatorTests() => Directory.CreateDirectory(_root);
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PortableValidationRequiresTheFourExactDatFileNames()
|
||||||
|
{
|
||||||
|
string directory = Path.Combine(_root, "retail");
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
foreach (string fileName in DatDirectoryLocator.RequiredFileNames.Take(3))
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
|
||||||
|
}
|
||||||
|
|
||||||
|
var locator = new DatDirectoryLocator(isWindows: false);
|
||||||
|
DatDirectoryValidation incomplete = locator.Validate(directory);
|
||||||
|
|
||||||
|
Assert.False(incomplete.IsValid);
|
||||||
|
Assert.Equal(["client_local_English.dat"], incomplete.MissingFileNames);
|
||||||
|
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.Combine(directory, "client_local_English.dat"),
|
||||||
|
"fixture");
|
||||||
|
DatDirectoryValidation valid = locator.Validate(directory);
|
||||||
|
Assert.True(valid.IsValid);
|
||||||
|
Assert.Equal(Path.GetFullPath(directory), valid.Directory);
|
||||||
|
Assert.Empty(valid.MissingFileNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WindowsDetectionChecksBothConventionalLocationsInOrder()
|
||||||
|
{
|
||||||
|
string documents = Path.Combine(_root, "Documents", "Asheron's Call");
|
||||||
|
string turbine = Path.Combine(_root, "Turbine", "Asheron's Call");
|
||||||
|
CreateCompleteDatDirectory(documents);
|
||||||
|
Directory.CreateDirectory(turbine);
|
||||||
|
File.WriteAllText(Path.Combine(turbine, "client_portal.dat"), "fixture");
|
||||||
|
|
||||||
|
var locator = new DatDirectoryLocator(
|
||||||
|
isWindows: true,
|
||||||
|
windowsCandidates: [documents, turbine]);
|
||||||
|
|
||||||
|
IReadOnlyList<DatDirectoryValidation> detected = locator.Detect();
|
||||||
|
Assert.Equal(2, detected.Count);
|
||||||
|
Assert.Equal(Path.GetFullPath(documents), detected[0].Directory);
|
||||||
|
Assert.True(detected[0].IsValid);
|
||||||
|
Assert.Equal(Path.GetFullPath(turbine), detected[1].Directory);
|
||||||
|
Assert.False(detected[1].IsValid);
|
||||||
|
Assert.Equal(3, detected[1].MissingFileNames.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LinuxHasNoWindowsAutoDetectionButManualValidationStillWorks()
|
||||||
|
{
|
||||||
|
string manual = Path.Combine(_root, "linux-dats");
|
||||||
|
CreateCompleteDatDirectory(manual);
|
||||||
|
var locator = new DatDirectoryLocator(
|
||||||
|
isWindows: false,
|
||||||
|
windowsCandidates: [manual]);
|
||||||
|
|
||||||
|
Assert.Empty(locator.Detect());
|
||||||
|
Assert.True(locator.Validate(manual).IsValid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateCompleteDatDirectory(string directory)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
using AcDream.Launcher.Core.Integrity;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
[CollectionDefinition(WorkingDirectoryCollection.Name, DisableParallelization = true)]
|
||||||
|
public sealed class WorkingDirectoryCollection
|
||||||
|
{
|
||||||
|
public const string Name = "Launcher install-record working directory";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Collection(WorkingDirectoryCollection.Name)]
|
||||||
|
public sealed class LauncherInstallRecordStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-install-record-tests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly ApplicationPathSet _paths;
|
||||||
|
private readonly string _dats;
|
||||||
|
|
||||||
|
public LauncherInstallRecordStoreTests()
|
||||||
|
{
|
||||||
|
_paths = new ApplicationPathSet(
|
||||||
|
Path.Combine(_root, "config"),
|
||||||
|
Path.Combine(_root, "data"),
|
||||||
|
Path.Combine(_root, "cache"),
|
||||||
|
null);
|
||||||
|
_dats = Path.Combine(_root, "retail-dats");
|
||||||
|
CreateCompleteDatDirectory(_dats);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AtomicRecordRoundTripVerifiesShaSizeAndBakeToolVersion()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
LauncherInstallRecord record = await CreateRecordAsync(store);
|
||||||
|
|
||||||
|
await store.SaveAtomicallyAsync(record);
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.True(verification.IsVerified);
|
||||||
|
Assert.Equal(record, verification.Record);
|
||||||
|
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
|
||||||
|
Assert.Empty(Directory.EnumerateFiles(_paths.DataDirectory, ".install.json.*.tmp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SizeAndShaCorruptionDisableTheInstall()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "original");
|
||||||
|
LauncherInstallRecord record = await CreateRecordAsync(store);
|
||||||
|
await store.SaveAtomicallyAsync(record);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "different-size");
|
||||||
|
InstallRecordVerification size = await store.LoadAndVerifyAsync();
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, size.State);
|
||||||
|
Assert.Contains("size changed", size.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "tampered");
|
||||||
|
var sameSizeRecord = record with
|
||||||
|
{
|
||||||
|
PreparedAssetSize = new FileInfo(store.PreparedAssetPath).Length,
|
||||||
|
PreparedAssetSha256 = new string('0', 64),
|
||||||
|
};
|
||||||
|
await store.SaveAtomicallyAsync(sameSizeRecord);
|
||||||
|
InstallRecordVerification sha = await store.LoadAndVerifyAsync();
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, sha.State);
|
||||||
|
Assert.Contains("SHA-256", sha.Status, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StaleBakeToolVersionIsRejectedBeforeHashing()
|
||||||
|
{
|
||||||
|
int hashCalls = 0;
|
||||||
|
var store = new LauncherInstallRecordStore(
|
||||||
|
_paths,
|
||||||
|
computeSha256: (_, _) =>
|
||||||
|
{
|
||||||
|
hashCalls++;
|
||||||
|
return Task.FromResult(new string('a', 64));
|
||||||
|
});
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "package");
|
||||||
|
var stale = new LauncherInstallRecord(
|
||||||
|
_dats,
|
||||||
|
store.PreparedAssetPath,
|
||||||
|
new string('a', 64),
|
||||||
|
new FileInfo(store.PreparedAssetPath).Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion - 1);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(stale, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("Bake tool version", verification.Status, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(0, hashCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NullIntegrityMetadataIsReportedAsInvalidInsteadOfThrowing()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
datDirectory = _dats,
|
||||||
|
preparedAssetPath = store.PreparedAssetPath,
|
||||||
|
preparedAssetSha256 = (string?)null,
|
||||||
|
preparedAssetSize = 12,
|
||||||
|
bakeToolVersion =
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
|
version = LauncherInstallRecord.CurrentRecordVersion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("missing", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MissingExplicitVersionIsRejectedBeforeAdmission()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
datDirectory = Path.GetFullPath(_dats),
|
||||||
|
preparedAssetPath = Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
preparedAssetSha256 = new string('a', 64),
|
||||||
|
preparedAssetSize = 12,
|
||||||
|
bakeToolVersion =
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
|
}));
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("explicit", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveNormalizesCanonicalAbsoluteDatAndPreparedPaths()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
var nonCanonical = new LauncherInstallRecord(
|
||||||
|
Path.Combine(_dats, "..", Path.GetFileName(_dats), "."),
|
||||||
|
Path.Combine(
|
||||||
|
Path.GetDirectoryName(store.PreparedAssetPath)!,
|
||||||
|
"..",
|
||||||
|
"pak",
|
||||||
|
Path.GetFileName(store.PreparedAssetPath)),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
|
||||||
|
await store.SaveAtomicallyAsync(nonCanonical);
|
||||||
|
|
||||||
|
using JsonDocument document = JsonDocument.Parse(
|
||||||
|
await File.ReadAllTextAsync(store.RecordPath));
|
||||||
|
Assert.Equal(
|
||||||
|
Path.GetFullPath(_dats),
|
||||||
|
document.RootElement.GetProperty("datDirectory").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
document.RootElement.GetProperty("preparedAssetPath").GetString());
|
||||||
|
Assert.Equal(
|
||||||
|
LauncherInstallRecord.CurrentRecordVersion,
|
||||||
|
document.RootElement.GetProperty("version").GetInt32());
|
||||||
|
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RelativeDatRecordCannotChangeMeaningWithWorkingDirectory()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
string alternateWorkingDirectory = Path.Combine(_root, "alternate-cwd");
|
||||||
|
string alternateDats = Path.Combine(alternateWorkingDirectory, "retail-dats");
|
||||||
|
CreateCompleteDatDirectory(alternateDats);
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
var relative = new LauncherInstallRecord(
|
||||||
|
"retail-dats",
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(relative, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
string originalWorkingDirectory = Environment.CurrentDirectory;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Environment.CurrentDirectory = alternateWorkingDirectory;
|
||||||
|
InstallRecordVerification verification =
|
||||||
|
await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("absolute", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Environment.CurrentDirectory = originalWorkingDirectory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OlderAbsoluteButNonCanonicalDatDocumentIsRejected()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "verified package");
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
var nonCanonical = new LauncherInstallRecord(
|
||||||
|
Path.Combine(_dats, "..", Path.GetFileName(_dats)),
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
Directory.CreateDirectory(_paths.DataDirectory);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.RecordPath,
|
||||||
|
JsonSerializer.Serialize(nonCanonical, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
}));
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
|
||||||
|
Assert.Contains("canonical", verification.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StartupRecoversPriorVerifiedPackageAfterInterruptedReplacement()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "previous-good");
|
||||||
|
LauncherInstallRecord record = await CreateRecordAsync(store);
|
||||||
|
await store.SaveAtomicallyAsync(record);
|
||||||
|
|
||||||
|
string backup = LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath);
|
||||||
|
File.Move(store.PreparedAssetPath, backup);
|
||||||
|
await File.WriteAllTextAsync(store.PreparedAssetPath, "partial-new");
|
||||||
|
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
|
||||||
|
Assert.True(verification.IsVerified);
|
||||||
|
Assert.Equal("previous-good", await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
Assert.False(File.Exists(backup));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LauncherInstallRecord> CreateRecordAsync(
|
||||||
|
LauncherInstallRecordStore store)
|
||||||
|
{
|
||||||
|
var info = new FileInfo(store.PreparedAssetPath);
|
||||||
|
return new LauncherInstallRecord(
|
||||||
|
Path.GetFullPath(_dats),
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
info.Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateCompleteDatDirectory(string directory)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,793 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using AcDream.Launcher.Core.Integrity;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
using AcDream.Launcher.Core.Profiles;
|
||||||
|
using AcDream.Platform;
|
||||||
|
|
||||||
|
namespace AcDream.Launcher.Core.Tests.Installation;
|
||||||
|
|
||||||
|
public sealed class LauncherInstallerTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"acdream-installer-tests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
private readonly ApplicationPathSet _paths;
|
||||||
|
private readonly string _dats;
|
||||||
|
private readonly string _bakeExecutable;
|
||||||
|
|
||||||
|
public LauncherInstallerTests()
|
||||||
|
{
|
||||||
|
_paths = new ApplicationPathSet(
|
||||||
|
Path.Combine(_root, "config"),
|
||||||
|
Path.Combine(_root, "data"),
|
||||||
|
Path.Combine(_root, "cache"),
|
||||||
|
null);
|
||||||
|
_dats = Path.Combine(_root, "retail-dats");
|
||||||
|
_bakeExecutable = Path.Combine(_root, "bin", "acdream-bake");
|
||||||
|
CreateCompleteDatDirectory(_dats);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!);
|
||||||
|
File.WriteAllText(_bakeExecutable, "fake executable marker");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
{
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FakeChildProgressPublishesVerifiedRecordAndFeedsExactSessionContent()
|
||||||
|
{
|
||||||
|
BakeProcessRequest? observedRequest = null;
|
||||||
|
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
|
||||||
|
{
|
||||||
|
observedRequest = request;
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "complete prepared package");
|
||||||
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
|
output("acdream-bake human header\n{\"v\":1,\"e\":\"star");
|
||||||
|
output("ted\",\"bakeToolVersion\":4,\"outputPath\":\"pak\"}\n");
|
||||||
|
output("{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\","
|
||||||
|
+ "\"completed\":25,\"total\":100,\"failures\":0,"
|
||||||
|
+ "\"elapsedSeconds\":5,\"etaSeconds\":15}\n");
|
||||||
|
output("{\"v\":1,\"e\":\"newMetric\",\"value\":1}\n");
|
||||||
|
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||||
|
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||||
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
});
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
processRunner: runner);
|
||||||
|
var progress = new List<LauncherInstallProgress>();
|
||||||
|
|
||||||
|
LauncherInstallResult result = await installer.InstallAsync(
|
||||||
|
_dats,
|
||||||
|
threads: 7,
|
||||||
|
new ImmediateProgress<LauncherInstallProgress>(progress.Add));
|
||||||
|
|
||||||
|
Assert.NotNull(observedRequest);
|
||||||
|
Assert.Equal(Path.GetFullPath(_bakeExecutable), observedRequest.ExecutablePath);
|
||||||
|
Assert.Equal(Path.GetFullPath(_dats), observedRequest.DatDirectory);
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
|
||||||
|
observedRequest.OutputPath);
|
||||||
|
Assert.Equal(
|
||||||
|
[
|
||||||
|
"--dat-dir", Path.GetFullPath(_dats),
|
||||||
|
"--out", Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
|
||||||
|
"--threads", "7",
|
||||||
|
"--progress-json",
|
||||||
|
],
|
||||||
|
observedRequest.Arguments);
|
||||||
|
Assert.True(BakePublicationGuardPaths.IsValidNonce(
|
||||||
|
observedRequest.PublicationNonce));
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
observedRequest.PublicationNonce!,
|
||||||
|
observedRequest.Arguments);
|
||||||
|
Assert.Equal(LauncherInstallRecordStore.CurrentBakeToolVersion,
|
||||||
|
result.Record.BakeToolVersion);
|
||||||
|
Assert.Equal(new FileInfo(result.Record.PreparedAssetPath).Length,
|
||||||
|
result.Record.PreparedAssetSize);
|
||||||
|
Assert.Equal(
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(result.Record.PreparedAssetPath),
|
||||||
|
result.Record.PreparedAssetSha256);
|
||||||
|
Assert.Contains(progress, value => value.Phase == LauncherInstallPhase.BakingMeshes);
|
||||||
|
Assert.Equal(LauncherInstallPhase.Completed, progress[^1].Phase);
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
result.Record.PreparedAssetPath)));
|
||||||
|
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
Assert.True(verification.IsVerified);
|
||||||
|
Assert.Equal(result.Record, verification.Record);
|
||||||
|
|
||||||
|
var server = new ServerProfile
|
||||||
|
{
|
||||||
|
Name = "Local ACE",
|
||||||
|
Host = "127.0.0.1",
|
||||||
|
Port = 9000,
|
||||||
|
};
|
||||||
|
var account = new AccountProfile
|
||||||
|
{
|
||||||
|
Account = "testaccount",
|
||||||
|
Password = "credential-never-serialized",
|
||||||
|
};
|
||||||
|
var character = new CharacterProfile
|
||||||
|
{
|
||||||
|
Name = "+Acdream",
|
||||||
|
Id = "0x5000000A",
|
||||||
|
LaunchMode = LaunchMode.Gui,
|
||||||
|
};
|
||||||
|
ComposedSessionConfig composed = SessionConfigComposer.Compose(
|
||||||
|
server,
|
||||||
|
account,
|
||||||
|
character,
|
||||||
|
result.Record,
|
||||||
|
_paths,
|
||||||
|
"installed-session");
|
||||||
|
JsonObject content = JsonNode.Parse(
|
||||||
|
SessionConfigComposer.Serialize(composed.Document))!
|
||||||
|
["process"]!["content"]!.AsObject();
|
||||||
|
Assert.Equal(result.Record.DatDirectory, (string?)content["datDirectory"]);
|
||||||
|
Assert.Equal(
|
||||||
|
result.Record.PreparedAssetPath,
|
||||||
|
(string?)content["preparedAssetPath"]);
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
result.Record.PreparedAssetSha256,
|
||||||
|
SessionConfigComposer.Serialize(composed.Document),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FailedChildRestoresPriorVerifiedPakAndRecord()
|
||||||
|
{
|
||||||
|
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||||
|
await CreateInstallerWithPriorRecordAsync(
|
||||||
|
async (request, output, _) =>
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "partial replacement");
|
||||||
|
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||||
|
output("{\"v\":1,\"e\":\"error\",\"message\":\"fixture failed\"}\n");
|
||||||
|
return new BakeProcessResult(9, "human failure detail");
|
||||||
|
},
|
||||||
|
loadExisting: false);
|
||||||
|
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
|
||||||
|
var progress = new List<LauncherInstallProgress>();
|
||||||
|
|
||||||
|
LauncherInstallException exception = await Assert.ThrowsAsync<LauncherInstallException>(
|
||||||
|
() => installer.InstallAsync(
|
||||||
|
_dats,
|
||||||
|
2,
|
||||||
|
new ImmediateProgress<LauncherInstallProgress>(progress.Add)));
|
||||||
|
|
||||||
|
Assert.Contains("fixture failed", exception.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("previous verified package", await File.ReadAllTextAsync(
|
||||||
|
store.PreparedAssetPath));
|
||||||
|
Assert.Equal(recordBefore, await File.ReadAllTextAsync(store.RecordPath));
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
Assert.True(verification.IsVerified);
|
||||||
|
Assert.Equal(old, verification.Record);
|
||||||
|
Assert.Equal(LauncherInstallPhase.Failed, progress[^1].Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContradictoryTerminalCannotReplaceFirstFailureOrPriorInstall()
|
||||||
|
{
|
||||||
|
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||||
|
await CreateInstallerWithPriorRecordAsync(
|
||||||
|
async (request, output, _) =>
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "contradictory output");
|
||||||
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
|
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||||
|
output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n");
|
||||||
|
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||||
|
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||||
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
});
|
||||||
|
|
||||||
|
LauncherInstallException exception =
|
||||||
|
await Assert.ThrowsAsync<LauncherInstallException>(
|
||||||
|
() => installer.InstallAsync(_dats, 2));
|
||||||
|
|
||||||
|
Assert.Contains("after", exception.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
Assert.Equal(old, (await store.LoadAndVerifyAsync()).Record);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellationRestoresPriorInstallAndNeverPublishesPartialOutput()
|
||||||
|
{
|
||||||
|
var enteredChild = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
(LauncherInstaller installer, LauncherInstallRecordStore store, LauncherInstallRecord old) =
|
||||||
|
await CreateInstallerWithPriorRecordAsync(
|
||||||
|
async (request, _, cancellationToken) =>
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
request.OutputPath,
|
||||||
|
"partial replacement",
|
||||||
|
cancellationToken);
|
||||||
|
enteredChild.SetResult();
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
});
|
||||||
|
var progress = new List<LauncherInstallProgress>();
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
|
||||||
|
Task<LauncherInstallResult> operation = installer.InstallAsync(
|
||||||
|
_dats,
|
||||||
|
3,
|
||||||
|
new ImmediateProgress<LauncherInstallProgress>(progress.Add),
|
||||||
|
cancellation.Token);
|
||||||
|
await enteredChild.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
|
||||||
|
Assert.Equal("previous verified package", await File.ReadAllTextAsync(
|
||||||
|
store.PreparedAssetPath));
|
||||||
|
Assert.False(File.Exists(LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
|
||||||
|
Assert.True(verification.IsVerified);
|
||||||
|
Assert.Equal(old, verification.Record);
|
||||||
|
Assert.Equal(LauncherInstallPhase.Cancelled, progress[^1].Phase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FailedFirstInstallRemovesPartialPakAndCreatesNoRecord()
|
||||||
|
{
|
||||||
|
var runner = new FakeBakeProcessRunner(async (request, _, _) =>
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "partial");
|
||||||
|
return new BakeProcessResult(1, "failed");
|
||||||
|
});
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
processRunner: runner);
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LauncherInstallException>(
|
||||||
|
() => installer.InstallAsync(_dats, 1));
|
||||||
|
|
||||||
|
Assert.False(File.Exists(store.PreparedAssetPath));
|
||||||
|
Assert.False(File.Exists(store.RecordPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CancellationDuringHashRemovesUnrecordedPublishedPackage()
|
||||||
|
{
|
||||||
|
var hashEntered = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "complete but unverified");
|
||||||
|
long bytes = new FileInfo(request.OutputPath).Length;
|
||||||
|
output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n");
|
||||||
|
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4,"
|
||||||
|
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
|
||||||
|
return new BakeProcessResult(0, string.Empty);
|
||||||
|
});
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: store,
|
||||||
|
processRunner: runner,
|
||||||
|
computeSha256: async (_, cancellationToken) =>
|
||||||
|
{
|
||||||
|
hashEntered.SetResult();
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
return new string('a', 64);
|
||||||
|
});
|
||||||
|
using var cancellation = new CancellationTokenSource();
|
||||||
|
|
||||||
|
Task<LauncherInstallResult> operation = installer.InstallAsync(
|
||||||
|
_dats,
|
||||||
|
2,
|
||||||
|
cancellationToken: cancellation.Token);
|
||||||
|
await hashEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
cancellation.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
|
||||||
|
Assert.False(File.Exists(store.PreparedAssetPath));
|
||||||
|
Assert.False(File.Exists(store.RecordPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task IndependentInstallersSerializeAndWaitingCancellationTouchesNothing()
|
||||||
|
{
|
||||||
|
var storeA = new LauncherInstallRecordStore(_paths);
|
||||||
|
LauncherInstallRecord old = await CreatePriorRecordAsync(storeA);
|
||||||
|
string backupPath = LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
storeA.PreparedAssetPath);
|
||||||
|
var childEntered = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var releaseChild = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var runnerA = new FakeBakeProcessRunner(async (request, _, _) =>
|
||||||
|
{
|
||||||
|
await File.WriteAllTextAsync(request.OutputPath, "installer A in progress");
|
||||||
|
childEntered.SetResult();
|
||||||
|
await releaseChild.Task;
|
||||||
|
return new BakeProcessResult(1, "fixture A failed");
|
||||||
|
});
|
||||||
|
bool runnerBEntered = false;
|
||||||
|
var runnerB = new FakeBakeProcessRunner((_, _, _) =>
|
||||||
|
{
|
||||||
|
runnerBEntered = true;
|
||||||
|
return Task.FromResult(new BakeProcessResult(1, "must not run"));
|
||||||
|
});
|
||||||
|
var installerA = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: storeA,
|
||||||
|
processRunner: runnerA);
|
||||||
|
var installerB = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths),
|
||||||
|
processRunner: runnerB);
|
||||||
|
|
||||||
|
Task<LauncherInstallResult> operationA =
|
||||||
|
installerA.InstallAsync(_dats, 1);
|
||||||
|
await childEntered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cancellationB = new CancellationTokenSource();
|
||||||
|
Task<LauncherInstallResult> operationB = installerB.InstallAsync(
|
||||||
|
_dats,
|
||||||
|
1,
|
||||||
|
cancellationToken: cancellationB.Token);
|
||||||
|
await Task.Delay(150);
|
||||||
|
cancellationB.Cancel();
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operationB);
|
||||||
|
Assert.False(runnerBEntered);
|
||||||
|
Assert.Equal(
|
||||||
|
"installer A in progress",
|
||||||
|
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(backupPath));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
releaseChild.TrySetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LauncherInstallException>(() => operationA);
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
|
||||||
|
Assert.False(File.Exists(backupPath));
|
||||||
|
Assert.Equal(old, (await storeA.LoadAndVerifyAsync()).Record);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StagingCleanupDeletesOnlyExactBakeTransactionNames()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
string outputPath = store.PreparedAssetPath;
|
||||||
|
string directory = Path.GetDirectoryName(outputPath)!;
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
string owned = BakeOutputStagingContract.CreateStagingPath(
|
||||||
|
outputPath,
|
||||||
|
Guid.Parse("01234567-89ab-cdef-0123-456789abcdef"));
|
||||||
|
string canonical = outputPath;
|
||||||
|
string backup = LauncherInstallRecordStore.GetBackupPath(outputPath);
|
||||||
|
string oldPattern = Path.Combine(
|
||||||
|
directory,
|
||||||
|
$".{Path.GetFileName(outputPath)}.{Guid.NewGuid():N}.tmp");
|
||||||
|
string invalidTransaction = Path.Combine(
|
||||||
|
directory,
|
||||||
|
$".{Path.GetFileName(outputPath)}.acdream-bake.not-a-guid.tmp");
|
||||||
|
string unrelated = Path.Combine(directory, "unrelated.tmp");
|
||||||
|
Assert.Equal(
|
||||||
|
Path.Combine(
|
||||||
|
directory,
|
||||||
|
".acdream.pak.acdream-bake.0123456789abcdef0123456789abcdef.tmp"),
|
||||||
|
owned);
|
||||||
|
foreach (string path in new[]
|
||||||
|
{
|
||||||
|
owned,
|
||||||
|
canonical,
|
||||||
|
backup,
|
||||||
|
oldPattern,
|
||||||
|
invalidTransaction,
|
||||||
|
unrelated,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, Path.GetFileName(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(owned));
|
||||||
|
Assert.True(File.Exists(canonical));
|
||||||
|
Assert.True(File.Exists(backup));
|
||||||
|
Assert.True(File.Exists(oldPattern));
|
||||||
|
Assert.True(File.Exists(invalidTransaction));
|
||||||
|
Assert.True(File.Exists(unrelated));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task KilledProcessReleasesLeaseAndRestartReclaimsOnlyBakeStaging()
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
|
||||||
|
string staging = BakeOutputStagingContract.CreateStagingPath(
|
||||||
|
store.PreparedAssetPath,
|
||||||
|
Guid.Parse("fedcba98-7654-3210-fedc-ba9876543210"));
|
||||||
|
string ready = Path.Combine(_root, "fixture-ready");
|
||||||
|
string fixtureDll = GetInstallLeaseFixturePath();
|
||||||
|
Assert.True(File.Exists(fixtureDll), $"Missing fixture: {fixtureDll}");
|
||||||
|
|
||||||
|
var startInfo = new ProcessStartInfo("dotnet")
|
||||||
|
{
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(fixtureDll);
|
||||||
|
startInfo.ArgumentList.Add("hold-install-lease");
|
||||||
|
startInfo.ArgumentList.Add(
|
||||||
|
InstallerTransactionLease.GetLockPath(store.DataDirectory));
|
||||||
|
startInfo.ArgumentList.Add(staging);
|
||||||
|
startInfo.ArgumentList.Add(ready);
|
||||||
|
using Process helper = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not start lease fixture.");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForFileAsync(ready, helper, TimeSpan.FromSeconds(10));
|
||||||
|
Assert.True(File.Exists(staging));
|
||||||
|
|
||||||
|
var blockedInstaller = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths));
|
||||||
|
using var blockedCancellation = new CancellationTokenSource(
|
||||||
|
TimeSpan.FromMilliseconds(200));
|
||||||
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||||
|
() => blockedInstaller.LoadExistingAsync(blockedCancellation.Token));
|
||||||
|
Assert.True(File.Exists(staging));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!helper.HasExited)
|
||||||
|
{
|
||||||
|
helper.Kill(entireProcessTree: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
var restarted = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths));
|
||||||
|
InstallRecordVerification recovered = await restarted.LoadExistingAsync();
|
||||||
|
|
||||||
|
Assert.True(recovered.IsVerified);
|
||||||
|
Assert.Equal(old, recovered.Record);
|
||||||
|
Assert.False(File.Exists(staging));
|
||||||
|
Assert.Equal(
|
||||||
|
"previous verified package",
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("holds", 0)]
|
||||||
|
[InlineData("late", 17)]
|
||||||
|
public async Task OrphanBakeCanNeverPublishAfterRestartRecovery(
|
||||||
|
string schedule,
|
||||||
|
int expectedChildExitCode)
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
LauncherInstallRecord old = await CreatePriorRecordAsync(store);
|
||||||
|
string recordBefore = await File.ReadAllTextAsync(store.RecordPath);
|
||||||
|
string control = Path.Combine(_root, "orphan-" + schedule);
|
||||||
|
Directory.CreateDirectory(control);
|
||||||
|
string ready = Path.Combine(control, "child-ready");
|
||||||
|
string release = Path.Combine(control, "child-release");
|
||||||
|
string childPid = Path.Combine(control, "child-pid");
|
||||||
|
string childExit = Path.Combine(control, "child-exit");
|
||||||
|
string fixtureDll = GetInstallLeaseFixturePath();
|
||||||
|
var startInfo = new ProcessStartInfo("dotnet")
|
||||||
|
{
|
||||||
|
RedirectStandardError = true,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
};
|
||||||
|
foreach (string argument in new[]
|
||||||
|
{
|
||||||
|
fixtureDll,
|
||||||
|
"orphan-parent",
|
||||||
|
store.DataDirectory,
|
||||||
|
_dats,
|
||||||
|
_bakeExecutable,
|
||||||
|
schedule,
|
||||||
|
ready,
|
||||||
|
release,
|
||||||
|
childPid,
|
||||||
|
childExit,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
using Process parent = Process.Start(startInfo)
|
||||||
|
?? throw new InvalidOperationException("Could not start orphan parent.");
|
||||||
|
int orphanPid = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await WaitForFileAsync(ready, parent, TimeSpan.FromSeconds(15));
|
||||||
|
orphanPid = int.Parse(
|
||||||
|
await File.ReadAllTextAsync(childPid),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
Assert.True(File.Exists(
|
||||||
|
LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
Assert.True(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
|
||||||
|
parent.Kill(entireProcessTree: false);
|
||||||
|
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var restarted = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: new LauncherInstallRecordStore(_paths));
|
||||||
|
Task<InstallRecordVerification> recovery =
|
||||||
|
restarted.LoadExistingAsync();
|
||||||
|
InstallRecordVerification recovered;
|
||||||
|
if (schedule == "holds")
|
||||||
|
{
|
||||||
|
await Task.Delay(200);
|
||||||
|
Assert.False(recovery.IsCompleted);
|
||||||
|
File.WriteAllText(release, "release");
|
||||||
|
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
File.WriteAllText(release, "release");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.True(recovered.IsVerified);
|
||||||
|
Assert.Equal(old, recovered.Record);
|
||||||
|
string canonicalAfterRecovery =
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath);
|
||||||
|
string recordAfterRecovery =
|
||||||
|
await File.ReadAllTextAsync(store.RecordPath);
|
||||||
|
bool backupAfterRecovery = File.Exists(
|
||||||
|
LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath));
|
||||||
|
|
||||||
|
await WaitForFileAsync(childExit, TimeSpan.FromSeconds(15));
|
||||||
|
Assert.Equal(
|
||||||
|
expectedChildExitCode,
|
||||||
|
int.Parse(
|
||||||
|
await File.ReadAllTextAsync(childExit),
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
if (schedule == "late")
|
||||||
|
{
|
||||||
|
Assert.Contains(
|
||||||
|
"no longer authorized",
|
||||||
|
await File.ReadAllTextAsync(childExit + ".error"),
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
await Task.Delay(200);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
canonicalAfterRecovery,
|
||||||
|
await File.ReadAllTextAsync(store.PreparedAssetPath));
|
||||||
|
Assert.Equal("previous verified package", canonicalAfterRecovery);
|
||||||
|
Assert.Equal(recordBefore, recordAfterRecovery);
|
||||||
|
Assert.Equal(recordAfterRecovery, await File.ReadAllTextAsync(store.RecordPath));
|
||||||
|
Assert.Equal(
|
||||||
|
backupAfterRecovery,
|
||||||
|
File.Exists(LauncherInstallRecordStore.GetBackupPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
Assert.False(backupAfterRecovery);
|
||||||
|
Assert.False(File.Exists(
|
||||||
|
BakePublicationGuardPaths.GetAuthorizationPath(
|
||||||
|
store.PreparedAssetPath)));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.WriteAllText(release, "release");
|
||||||
|
if (!parent.HasExited)
|
||||||
|
{
|
||||||
|
parent.Kill(entireProcessTree: false);
|
||||||
|
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orphanPid != 0 && !File.Exists(childExit))
|
||||||
|
{
|
||||||
|
TryKill(orphanPid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(
|
||||||
|
LauncherInstaller Installer,
|
||||||
|
LauncherInstallRecordStore Store,
|
||||||
|
LauncherInstallRecord Old)> CreateInstallerWithPriorRecordAsync(
|
||||||
|
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler,
|
||||||
|
bool loadExisting = true)
|
||||||
|
{
|
||||||
|
var store = new LauncherInstallRecordStore(_paths);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.PreparedAssetPath,
|
||||||
|
"previous verified package");
|
||||||
|
var old = new LauncherInstallRecord(
|
||||||
|
Path.GetFullPath(_dats),
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
new FileInfo(store.PreparedAssetPath).Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
await store.SaveAtomicallyAsync(old);
|
||||||
|
|
||||||
|
var installer = new LauncherInstaller(
|
||||||
|
_paths,
|
||||||
|
_bakeExecutable,
|
||||||
|
recordStore: store,
|
||||||
|
processRunner: new FakeBakeProcessRunner(handler));
|
||||||
|
if (loadExisting)
|
||||||
|
{
|
||||||
|
Assert.True((await installer.LoadExistingAsync()).IsVerified);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (installer, store, old);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<LauncherInstallRecord> CreatePriorRecordAsync(
|
||||||
|
LauncherInstallRecordStore store)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
store.PreparedAssetPath,
|
||||||
|
"previous verified package");
|
||||||
|
var old = new LauncherInstallRecord(
|
||||||
|
Path.GetFullPath(_dats),
|
||||||
|
Path.GetFullPath(store.PreparedAssetPath),
|
||||||
|
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
|
||||||
|
new FileInfo(store.PreparedAssetPath).Length,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
await store.SaveAtomicallyAsync(old);
|
||||||
|
return old;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForFileAsync(
|
||||||
|
string path,
|
||||||
|
Process process,
|
||||||
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource(timeout);
|
||||||
|
while (!File.Exists(path))
|
||||||
|
{
|
||||||
|
if (process.HasExited)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Lease fixture exited with {process.ExitCode}: "
|
||||||
|
+ await process.StandardError.ReadToEndAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(25, cancellation.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WaitForFileAsync(string path, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cancellation = new CancellationTokenSource(timeout);
|
||||||
|
while (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await Task.Delay(25, cancellation.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void TryKill(int processId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Process process = Process.GetProcessById(processId);
|
||||||
|
if (!process.HasExited)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
process.WaitForExit(5_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// The orphan normally exits by itself; cleanup tolerates the
|
||||||
|
// expected race with Process.GetProcessById.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetInstallLeaseFixturePath()
|
||||||
|
{
|
||||||
|
string root = FindRepositoryRoot();
|
||||||
|
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||||
|
.Parent?.Name
|
||||||
|
?? "Release";
|
||||||
|
return Path.Combine(
|
||||||
|
root,
|
||||||
|
"tests",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
|
||||||
|
"bin",
|
||||||
|
configuration,
|
||||||
|
"net10.0",
|
||||||
|
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
foreach (string start in new[]
|
||||||
|
{
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
Environment.CurrentDirectory,
|
||||||
|
})
|
||||||
|
{
|
||||||
|
for (var directory = new DirectoryInfo(start);
|
||||||
|
directory is not null;
|
||||||
|
directory = directory.Parent)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new DirectoryNotFoundException("Could not locate repository root.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CreateCompleteDatDirectory(string directory)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directory);
|
||||||
|
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(directory, fileName), "fixture");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeBakeProcessRunner(
|
||||||
|
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler)
|
||||||
|
: IBakeProcessRunner
|
||||||
|
{
|
||||||
|
private readonly Func<
|
||||||
|
BakeProcessRequest,
|
||||||
|
Action<string>,
|
||||||
|
CancellationToken,
|
||||||
|
Task<BakeProcessResult>> _handler = handler;
|
||||||
|
|
||||||
|
public Task<BakeProcessResult> RunAsync(
|
||||||
|
BakeProcessRequest request,
|
||||||
|
Action<string> onStandardOutput,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_handler(request, onStandardOutput, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ImmediateProgress<T>(Action<T> callback) : IProgress<T>
|
||||||
|
{
|
||||||
|
public void Report(T value) => callback(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,6 +71,25 @@ public sealed class LauncherProjectBoundaryTests
|
||||||
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
Assert.Equal("true", EvaluateProperty(projectPath, "PublishSingleFile"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RidPublishComposesBakeWithoutAProjectReference()
|
||||||
|
{
|
||||||
|
string project = File.ReadAllText(Path.Combine(
|
||||||
|
FindRepositoryRoot(),
|
||||||
|
"src",
|
||||||
|
"AcDream.Launcher",
|
||||||
|
"AcDream.Launcher.csproj"));
|
||||||
|
|
||||||
|
Assert.DoesNotContain(
|
||||||
|
"ProjectReference Include=\"..\\AcDream.Bake",
|
||||||
|
project,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains("PublishCoDeployedBakeTool", project, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("..\\AcDream.Bake\\AcDream.Bake.csproj", project, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("SelfContained=true", project, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("PublishSingleFile=true", project, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
public void ModalMarkupAndCodeBehindCarryKeyboardFocusAndAccessibilityGuards()
|
||||||
{
|
{
|
||||||
|
|
@ -110,12 +129,18 @@ public sealed class LauncherProjectBoundaryTests
|
||||||
|
|
||||||
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
|
Assert.Contains("src/AcDream.Launcher/**", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
|
Assert.Contains("tests/AcDream.Launcher.Tests/**", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("src/AcDream.Bake/**", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("tests/AcDream.Bake.Tests/**", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
|
Assert.Contains("portable-launcher:", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
|
Assert.Contains("tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
|
Assert.Contains("-r linux-x64", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
Assert.Contains("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
Assert.Contains("--verify-publish", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("acdream-bake.exe\" --help", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("\"$root/acdream-bake\" --help", workflow, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("test -x \"$root/acdream-bake\"", workflow, StringComparison.Ordinal);
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
"test -x src/AcDream.Headless/bin/Release/net10.0/acdream-headless",
|
||||||
workflow,
|
workflow,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using AcDream.Launcher.Core.Orchestration;
|
using AcDream.Launcher.Core.Orchestration;
|
||||||
using AcDream.Launcher.Core.Profiles;
|
using AcDream.Launcher.Core.Profiles;
|
||||||
using AcDream.Launcher.Core.Launching;
|
using AcDream.Launcher.Core.Launching;
|
||||||
|
using AcDream.Launcher.Core.Installation;
|
||||||
using AcDream.Launcher.ViewModels;
|
using AcDream.Launcher.ViewModels;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Tests;
|
namespace AcDream.Launcher.Tests;
|
||||||
|
|
@ -32,7 +33,7 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Assert.True(session.IsActive);
|
Assert.True(session.IsActive);
|
||||||
|
|
||||||
Assert.True(viewModel.IsFirstRunRequired);
|
Assert.True(viewModel.IsFirstRunRequired);
|
||||||
Assert.Contains("LA9", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||||
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
|
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
|
||||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||||
|
|
@ -325,6 +326,92 @@ public sealed class LauncherWindowViewModelTests
|
||||||
Assert.False(viewModel.EditorDialog.IsOpen);
|
Assert.False(viewModel.EditorDialog.IsOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FirstRunWizardAutoDetectsValidatesAndPublishesVerifiedInstall()
|
||||||
|
{
|
||||||
|
using var orchestrator = new FakeLauncherOrchestrator
|
||||||
|
{
|
||||||
|
Session = FakeLauncherOrchestrator.CreateSession(
|
||||||
|
LauncherActivityState.Exited,
|
||||||
|
"Exited cleanly."),
|
||||||
|
};
|
||||||
|
var installer = new FakeLauncherInstaller();
|
||||||
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||||
|
|
||||||
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
|
|
||||||
|
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
|
||||||
|
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
||||||
|
Assert.True(viewModel.FirstRunWizardShell.StartCommand.CanExecute(null));
|
||||||
|
|
||||||
|
viewModel.FirstRunWizardShell.SelectDatDirectory("incomplete-manual-path");
|
||||||
|
Assert.False(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
||||||
|
viewModel.FirstRunWizardShell.SelectDatDirectory(installer.DetectedDirectory);
|
||||||
|
Assert.True(viewModel.FirstRunWizardShell.IsDatDirectoryValid);
|
||||||
|
|
||||||
|
viewModel.FirstRunWizardShell.ThreadsText = "3";
|
||||||
|
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||||
|
|
||||||
|
Assert.Equal((installer.DetectedDirectory, 3), installer.InstallRequest);
|
||||||
|
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
|
||||||
|
Assert.False(viewModel.IsFirstRunRequired);
|
||||||
|
Assert.Equal(LauncherInstallPhase.Completed, viewModel.FirstRunWizardShell.Phase);
|
||||||
|
Assert.Equal(100, viewModel.FirstRunWizardShell.ProgressPercent);
|
||||||
|
Assert.False(viewModel.FirstRunWizardShell.HasError);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FirstRunWizardCancellationAndFailureRemainVisibleAndPublishNothing()
|
||||||
|
{
|
||||||
|
using var orchestrator = new FakeLauncherOrchestrator
|
||||||
|
{
|
||||||
|
Session = FakeLauncherOrchestrator.CreateSession(
|
||||||
|
LauncherActivityState.Exited,
|
||||||
|
"Exited cleanly."),
|
||||||
|
};
|
||||||
|
var entered = new TaskCompletionSource(
|
||||||
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
var installer = new FakeLauncherInstaller
|
||||||
|
{
|
||||||
|
InstallHandler = async (_, _, progress, cancellationToken) =>
|
||||||
|
{
|
||||||
|
progress?.Report(new LauncherInstallProgress(
|
||||||
|
LauncherInstallPhase.BakingMeshes,
|
||||||
|
"Baking mesh assets.",
|
||||||
|
1,
|
||||||
|
10));
|
||||||
|
entered.SetResult();
|
||||||
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
|
throw new InvalidOperationException("unreachable");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
using var viewModel = CreateInitialized(orchestrator, installer);
|
||||||
|
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||||
|
|
||||||
|
Task install = viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||||
|
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
Assert.True(viewModel.FirstRunWizardShell.CancelCommand.CanExecute(null));
|
||||||
|
Assert.False(viewModel.FirstRunWizardShell.CanEditInputs);
|
||||||
|
viewModel.FirstRunWizardShell.CancelCommand.Execute(null);
|
||||||
|
await install;
|
||||||
|
|
||||||
|
Assert.Equal(LauncherInstallPhase.Cancelled, viewModel.FirstRunWizardShell.Phase);
|
||||||
|
Assert.Null(orchestrator.InstalledRecord);
|
||||||
|
Assert.False(viewModel.FirstRunWizardShell.HasError);
|
||||||
|
|
||||||
|
installer.InstallHandler = (_, _, _, _) =>
|
||||||
|
Task.FromException<LauncherInstallResult>(
|
||||||
|
new LauncherInstallException("fixture bake failed"));
|
||||||
|
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
|
||||||
|
|
||||||
|
Assert.Equal(LauncherInstallPhase.Failed, viewModel.FirstRunWizardShell.Phase);
|
||||||
|
Assert.Contains(
|
||||||
|
"fixture bake failed",
|
||||||
|
viewModel.FirstRunWizardShell.Error ?? string.Empty,
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Null(orchestrator.InstalledRecord);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
|
||||||
{
|
{
|
||||||
|
|
@ -345,11 +432,13 @@ public sealed class LauncherWindowViewModelTests
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LauncherWindowViewModel CreateInitialized(
|
private static LauncherWindowViewModel CreateInitialized(
|
||||||
FakeLauncherOrchestrator orchestrator)
|
FakeLauncherOrchestrator orchestrator,
|
||||||
|
ILauncherInstaller? installer = null)
|
||||||
{
|
{
|
||||||
var viewModel = new LauncherWindowViewModel(
|
var viewModel = new LauncherWindowViewModel(
|
||||||
orchestrator,
|
orchestrator,
|
||||||
new ImmediateUiDispatcher());
|
new ImmediateUiDispatcher(),
|
||||||
|
installer);
|
||||||
viewModel.Initialize();
|
viewModel.Initialize();
|
||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
@ -425,14 +514,18 @@ public sealed class LauncherWindowViewModelTests
|
||||||
|
|
||||||
public string? StoppedSessionId { get; private set; }
|
public string? StoppedSessionId { get; private set; }
|
||||||
|
|
||||||
|
public LauncherInstallRecord? InstalledRecord { get; private set; }
|
||||||
|
|
||||||
public void LoadProfiles() => LoadCalled = true;
|
public void LoadProfiles() => LoadCalled = true;
|
||||||
|
|
||||||
public LauncherStateSnapshot GetSnapshot() => new(
|
public LauncherStateSnapshot GetSnapshot() => new(
|
||||||
[CreateServerSnapshot()],
|
[CreateServerSnapshot()],
|
||||||
[Session],
|
[Session],
|
||||||
Platform,
|
Platform,
|
||||||
IsInstallationReady: false,
|
IsInstallationReady: InstalledRecord is not null,
|
||||||
InstallationStatus: "No installed client is configured.");
|
InstallationStatus: InstalledRecord is null
|
||||||
|
? "No installed client is configured."
|
||||||
|
: "Client content verified.");
|
||||||
|
|
||||||
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
|
||||||
Platform.ForLaunchMode(mode);
|
Platform.ForLaunchMode(mode);
|
||||||
|
|
@ -448,6 +541,8 @@ public sealed class LauncherWindowViewModelTests
|
||||||
|
|
||||||
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
public void SetInstallRecord(LauncherInstallRecord? installRecord)
|
||||||
{
|
{
|
||||||
|
InstalledRecord = installRecord;
|
||||||
|
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AddServer(string name, string host, int port) =>
|
public void AddServer(string name, string host, int port) =>
|
||||||
|
|
@ -589,4 +684,83 @@ public sealed class LauncherWindowViewModelTests
|
||||||
ActivityStatus: "Connected."),
|
ActivityStatus: "Connected."),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeLauncherInstaller : ILauncherInstaller
|
||||||
|
{
|
||||||
|
public string DetectedDirectory { get; } = Path.GetFullPath("retail-dats");
|
||||||
|
|
||||||
|
public LauncherInstallRecord Record { get; }
|
||||||
|
|
||||||
|
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
|
||||||
|
|
||||||
|
public Func<
|
||||||
|
string,
|
||||||
|
int,
|
||||||
|
IProgress<LauncherInstallProgress>?,
|
||||||
|
CancellationToken,
|
||||||
|
Task<LauncherInstallResult>>? InstallHandler { get; set; }
|
||||||
|
|
||||||
|
public FakeLauncherInstaller()
|
||||||
|
{
|
||||||
|
Record = new LauncherInstallRecord(
|
||||||
|
DetectedDirectory,
|
||||||
|
Path.GetFullPath("data/pak/acdream.pak"),
|
||||||
|
new string('a', 64),
|
||||||
|
123,
|
||||||
|
LauncherInstallRecordStore.CurrentBakeToolVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DatDirectoryValidation> DetectDatDirectories() =>
|
||||||
|
[
|
||||||
|
ValidateDatDirectory(DetectedDirectory),
|
||||||
|
];
|
||||||
|
|
||||||
|
public DatDirectoryValidation ValidateDatDirectory(string? directory) =>
|
||||||
|
string.Equals(directory, DetectedDirectory, StringComparison.Ordinal)
|
||||||
|
? new DatDirectoryValidation(
|
||||||
|
DetectedDirectory,
|
||||||
|
true,
|
||||||
|
"All four required retail DAT files were found.",
|
||||||
|
[])
|
||||||
|
: new DatDirectoryValidation(
|
||||||
|
directory ?? string.Empty,
|
||||||
|
false,
|
||||||
|
"The DAT directory is incomplete.",
|
||||||
|
DatDirectoryLocator.RequiredFileNames);
|
||||||
|
|
||||||
|
public Task<InstallRecordVerification> LoadExistingAsync(
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult(new InstallRecordVerification(
|
||||||
|
InstallRecordVerificationState.Missing,
|
||||||
|
null,
|
||||||
|
"Client content is not installed."));
|
||||||
|
|
||||||
|
public Task<LauncherInstallResult> InstallAsync(
|
||||||
|
string datDirectory,
|
||||||
|
int threads,
|
||||||
|
IProgress<LauncherInstallProgress>? progress = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
InstallRequest = (datDirectory, threads);
|
||||||
|
if (InstallHandler is not null)
|
||||||
|
{
|
||||||
|
return InstallHandler(
|
||||||
|
datDirectory,
|
||||||
|
threads,
|
||||||
|
progress,
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress?.Report(new LauncherInstallProgress(
|
||||||
|
LauncherInstallPhase.BakingMeshes,
|
||||||
|
"Baking mesh assets.",
|
||||||
|
5,
|
||||||
|
10));
|
||||||
|
progress?.Report(new LauncherInstallProgress(
|
||||||
|
LauncherInstallPhase.VerifyingPackage,
|
||||||
|
"Verifying package."));
|
||||||
|
return Task.FromResult(new LauncherInstallResult(Record));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue