feat(launcher): add verified first-run installer

This commit is contained in:
Erik 2026-08-14 20:06:37 +02:00
parent 60f627998c
commit ff6ebb6a6a
28 changed files with 3259 additions and 125 deletions

View file

@ -0,0 +1,105 @@
using System.Text.Json;
using AcDream.Content.Pak;
namespace AcDream.Bake.Tests;
public sealed class BakeProgressCliTests
{
[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()));
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}
}

View file

@ -0,0 +1,178 @@
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;
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 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");
}
}
}

View file

@ -0,0 +1,336 @@
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.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);
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 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));
}
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 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);
}
}

View file

@ -110,8 +110,11 @@ public sealed class LauncherProjectBoundaryTests
Assert.Contains("src/AcDream.Launcher/**", 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("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("-getProperty:SelfContained", workflow, StringComparison.Ordinal);
Assert.Contains("DOTNET_ROOT", workflow, StringComparison.Ordinal);

View file

@ -1,6 +1,7 @@
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
@ -32,7 +33,7 @@ public sealed class LauncherWindowViewModelTests
Assert.True(session.IsActive);
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);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
@ -325,6 +326,92 @@ public sealed class LauncherWindowViewModelTests
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]
public async Task ActiveSessionStopAndFinishedSessionClearUseCoreOwnership()
{
@ -345,11 +432,13 @@ public sealed class LauncherWindowViewModelTests
}
private static LauncherWindowViewModel CreateInitialized(
FakeLauncherOrchestrator orchestrator)
FakeLauncherOrchestrator orchestrator,
ILauncherInstaller? installer = null)
{
var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher());
new ImmediateUiDispatcher(),
installer);
viewModel.Initialize();
return viewModel;
}
@ -425,14 +514,18 @@ public sealed class LauncherWindowViewModelTests
public string? StoppedSessionId { get; private set; }
public LauncherInstallRecord? InstalledRecord { get; private set; }
public void LoadProfiles() => LoadCalled = true;
public LauncherStateSnapshot GetSnapshot() => new(
[CreateServerSnapshot()],
[Session],
Platform,
IsInstallationReady: false,
InstallationStatus: "No installed client is configured.");
IsInstallationReady: InstalledRecord is not null,
InstallationStatus: InstalledRecord is null
? "No installed client is configured."
: "Client content verified.");
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
Platform.ForLaunchMode(mode);
@ -448,6 +541,8 @@ public sealed class LauncherWindowViewModelTests
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
InstalledRecord = installRecord;
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void AddServer(string name, string host, int port) =>
@ -589,4 +684,83 @@ public sealed class LauncherWindowViewModelTests
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));
}
}
}