feat(launcher): stabilize prepared content updates
Some checks failed
CI / linux-portable (push) Failing after 3m12s
CI / windows-gate (push) Failing after 6m35s
CI / release (push) Has been skipped

This commit is contained in:
Erik 2026-08-25 19:17:13 +02:00
parent f160f3fee1
commit af9327a17b
42 changed files with 3706 additions and 147 deletions

View file

@ -258,6 +258,9 @@ public sealed class ContentEffectsAudioCompositionTests
Dependencies = new ContentEffectsAudioDependencies(
"test-dat",
"test.pak",
null,
null,
null,
ResidencyBudgetOptions.Default,
new PhysicsDataCache(),
false,
@ -386,6 +389,9 @@ public sealed class ContentEffectsAudioCompositionTests
public IDatReaderWriter OpenDatCollection(string datDirectory) => _dats;
public IPreparedAssetSource OpenPreparedAssetSource(
string path,
string? overlayPath,
uint? baseRecipeVersion,
uint? effectiveRecipeVersion,
IDatReaderWriter dats,
Action<string> diagnostic)
{

View file

@ -137,6 +137,56 @@ public sealed class RuntimeOptionsSessionConfigTests
options.PreparedAssetPath);
}
[Fact]
public void ProcessContentCarriesOptionalOverlayAndBothRecipeIdentities()
{
var config = new SessionConfiguration
{
Version = 1,
Process = new SessionProcessSettings
{
Content = new SessionContentDescriptor
{
DatDirectory = "D:\\dats",
PreparedAssetPath = "D:\\content\\acdream.pak",
PreparedAssetOverlayPath =
"D:\\content\\acdream-update-5.pak",
PreparedAssetBaseRecipeVersion = 4,
PreparedAssetEffectiveRecipeVersion = 5,
},
},
};
var session = new SessionDescriptor
{
Id = "overlay-session",
Endpoint = new SessionEndpointDescriptor
{
Host = "127.0.0.1",
Port = 9000,
},
Account = "account",
Credential = new SessionCredentialDescriptor
{
Provider = SessionCredentialProviderKind.Environment,
Reference = "X",
},
};
RuntimeOptions options = RuntimeOptions.FromSessionConfig(
"D:\\dats",
_ => null,
"session.json",
config,
session,
"password");
Assert.Equal(
"D:\\content\\acdream-update-5.pak",
options.PreparedAssetOverlayPath);
Assert.Equal(4u, options.PreparedAssetBaseRecipeVersion);
Assert.Equal(5u, options.PreparedAssetEffectiveRecipeVersion);
}
[Fact]
public void EnvironmentFlowLeavesEveryNewFieldAtItsNothingConfiguredDefault()
{
@ -149,5 +199,8 @@ public sealed class RuntimeOptionsSessionConfigTests
Assert.Null(options.Plugins);
Assert.Empty(options.LoginCommands);
Assert.Equal(500, options.LoginCommandDelayMs);
Assert.Null(options.PreparedAssetOverlayPath);
Assert.Null(options.PreparedAssetBaseRecipeVersion);
Assert.Null(options.PreparedAssetEffectiveRecipeVersion);
}
}

View file

@ -0,0 +1,209 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Content.Pak;
using AcDream.Core.Physics;
namespace AcDream.Content.Tests;
public sealed class LayeredPreparedAssetSourceTests : IDisposable
{
private static readonly PreparedAssetCatalogIdentity Identity =
new(10, 20, 30, 40, PakFormat.CurrentBakeToolVersion);
private readonly List<string> _paths = [];
[Fact]
public void OverlayWinsAndMissingRenderAndCollisionKeysFallBackToBase()
{
const uint replaced = 0x0100_0001u;
const uint baseOnly = 0x0100_0002u;
string basePath = WritePak(
(replaced, 1f),
(baseOnly, 2f));
string overlayPath = WritePak((replaced, 9f));
using var source = Open(basePath, overlayPath);
PreparedAssetReadResult overlaid =
source.Read(PreparedAssetRequest.GfxObj(replaced));
PreparedAssetReadResult fallback =
source.Read(PreparedAssetRequest.GfxObj(baseOnly));
Assert.Equal(9f, overlaid.Data!.Vertices[0].Position.X);
Assert.Equal(2f, fallback.Data!.Vertices[0].Position.X);
Assert.Equal(
PreparedAssetPresence.Available,
source.Probe(PakAssetType.GfxObjMesh, baseOnly));
PreparedCollisionReadResult<FlatSetupCollision> overlayCollision =
source.ReadSetupCollision(replaced);
PreparedCollisionReadResult<FlatSetupCollision> baseCollision =
source.ReadSetupCollision(baseOnly);
Assert.Equal(9f, overlayCollision.Data!.Height);
Assert.Equal(2f, baseCollision.Data!.Height);
// Replaced reads touch only the overlay; fallback reads touch overlay
// then base. The public counters therefore describe real mapped work.
Assert.Equal(3, source.Stats.Reads);
Assert.Equal(2, source.Stats.Loaded);
Assert.Equal(1, source.Stats.Missing);
Assert.Equal(3, source.CollisionStats.Reads);
Assert.Equal(2, source.CollisionStats.Loaded);
Assert.Equal(1, source.CollisionStats.Missing);
Assert.Equal(
new FileInfo(basePath).Length + new FileInfo(overlayPath).Length,
source.MappedVirtualBytes);
}
[Fact]
public void CorruptOverlayKeyIsAuthoritativeAndNeverFallsBack()
{
const uint fileId = 0x0100_0001u;
string basePath = WriteRenderOnlyPak((fileId, 1f));
string overlayPath = WriteRenderOnlyPak((fileId, 9f));
FlipFirstBlobByte(overlayPath);
using var source = Open(basePath, overlayPath);
PreparedAssetReadResult result =
source.Read(PreparedAssetRequest.GfxObj(fileId));
Assert.Equal(PreparedAssetReadStatus.Corrupt, result.Status);
Assert.Null(result.Data);
Assert.Equal(1, source.Stats.Reads);
Assert.Equal(1, source.Stats.Corrupt);
Assert.Equal(0, source.Stats.Loaded);
Assert.Equal(
PreparedAssetPresence.Corrupt,
source.Probe(PakAssetType.GfxObjMesh, fileId));
}
[Fact]
public void DisposeReleasesBothMappedPackagesAndIsIdempotent()
{
string basePath = WriteRenderOnlyPak((1u, 1f));
string overlayPath = WriteRenderOnlyPak((2u, 2f));
var source = Open(basePath, overlayPath);
source.Dispose();
source.Dispose();
using var baseExclusive = new FileStream(
basePath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
using var overlayExclusive = new FileStream(
overlayPath,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
Assert.True(baseExclusive.CanWrite);
Assert.True(overlayExclusive.CanWrite);
Assert.Throws<ObjectDisposedException>(() => _ = source.Stats);
}
private LayeredPreparedAssetSource Open(string basePath, string overlayPath) =>
new(
new PakPreparedAssetSource(basePath, Identity),
new PakPreparedAssetSource(overlayPath, Identity));
private string WritePak(params (uint FileId, float Marker)[] entries)
{
string path = NewPath();
using var writer = new PakWriter(path, Header());
foreach ((uint fileId, float marker) in entries)
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, fileId),
Mesh(fileId, marker));
writer.AddBlob(
PakKey.Compose(PakAssetType.SetupCollision, fileId),
FlatCollisionAssetSerializer.Serialize(Setup(marker)));
}
writer.Finish();
return path;
}
private string WriteRenderOnlyPak(params (uint FileId, float Marker)[] entries)
{
string path = NewPath();
using var writer = new PakWriter(path, Header());
foreach ((uint fileId, float marker) in entries)
{
writer.AddBlob(
PakKey.Compose(PakAssetType.GfxObjMesh, fileId),
Mesh(fileId, marker));
}
writer.Finish();
return path;
}
private string NewPath()
{
string path = Path.Combine(
Path.GetTempPath(),
$"acdream-layered-prepared-{Guid.NewGuid():N}.pak");
_paths.Add(path);
return path;
}
private static PakHeader Header() =>
new()
{
PortalIteration = Identity.PortalIteration,
CellIteration = Identity.CellIteration,
HighResIteration = Identity.HighResIteration,
LanguageIteration = Identity.LanguageIteration,
BakeToolVersion = Identity.BakeToolVersion,
};
private static ObjectMeshData Mesh(uint objectId, float marker) =>
new()
{
ObjectId = objectId,
Vertices =
[
new(
new Vector3(marker, 2, 3),
Vector3.UnitZ,
new Vector2(0.25f, 0.75f)),
],
};
private static FlatSetupCollision Setup(float marker) =>
new(
ImmutableArray<FlatCollisionCylinder>.Empty,
ImmutableArray<FlatCollisionSphere>.Empty,
marker,
marker,
marker,
marker);
private static void FlipFirstBlobByte(string path)
{
using var stream = new FileStream(
path,
FileMode.Open,
FileAccess.ReadWrite,
FileShare.None);
stream.Position = PakHeader.Size + 4;
int value = stream.ReadByte();
Assert.NotEqual(-1, value);
stream.Position = PakHeader.Size + 4;
stream.WriteByte((byte)(value ^ 0xFF));
}
public void Dispose()
{
foreach (string path in _paths)
{
try
{
File.Delete(path);
}
catch
{
// Best-effort test cleanup.
}
}
}
}

View file

@ -87,6 +87,27 @@ public sealed class SessionConfigurationSharedFixtureTests
Assert.Empty(session.Plugins);
}
[Fact]
public void HeadlessReaderAcceptsOptionalLayeredPreparedContent()
{
string json = LauncherCoreSessionConfigFixture.Compose();
json = json.Replace(
"\"preparedAssetPath\": \"composer-dats/acdream.pak\"",
"\"preparedAssetPath\": \"composer-dats/acdream.pak\",\n"
+ " \"preparedAssetOverlayPath\": \"composer-dats/update.pak\",\n"
+ " \"preparedAssetBaseRecipeVersion\": 4,\n"
+ " \"preparedAssetEffectiveRecipeVersion\": 5",
StringComparison.Ordinal);
using TemporaryFile file = TemporaryFile.Create(json);
HeadlessContentDescriptor content = Assert.IsType<HeadlessContentDescriptor>(
HeadlessConfigurationLoader.Load(file.Path).Process.Content);
Assert.Equal("composer-dats/update.pak", content.PreparedAssetOverlayPath);
Assert.Equal(4u, content.PreparedAssetBaseRecipeVersion);
Assert.Equal(5u, content.PreparedAssetEffectiveRecipeVersion);
}
[Fact]
public void HeadlessReaderAcceptsTheProductionShapedSharedFixture()
{

View file

@ -43,4 +43,27 @@ public sealed class BakeProcessRunnerTests
Assert.False(startInfo.Environment.ContainsKey(
BakePublicationGuardPaths.NonceEnvironmentVariable));
}
[Fact]
public void FilteredOverlayArgumentsUseBakeToolsPinnedHexLists()
{
var request = new BakeProcessRequest(
"acdream-bake",
"retail-dats",
"data/pak/update.pak",
4,
DatIds: [0x01000001u, 0x02000002u],
Landblocks: [0x0A, 0xFE]);
Assert.Equal(
[
"--dat-dir", "retail-dats",
"--out", "data/pak/update.pak",
"--threads", "4",
"--progress-json",
"--ids", "0x01000001,0x02000002",
"--landblocks", "0x0A,0xFE",
],
request.Arguments);
}
}

View file

@ -0,0 +1,156 @@
using System.Buffers.Binary;
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 LauncherContentStateStoreTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-content-state-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
public LauncherContentStateStoreTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task MatchingSidecarResolvesWithoutStartupHashAndForceVerifyHashesBoth()
{
int hashCalls = 0;
var store = new LauncherContentStateStore(
_paths,
async (path, cancellationToken) =>
{
hashCalls++;
return await FileIntegrity.ComputeSha256HexAsync(
path,
cancellationToken);
});
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
string overlayPath = Path.Combine(
_paths.DataDirectory,
"pak",
"acdream-update-5-test.pak");
WritePakHeader(basePath, recipe: 4);
WritePakHeader(overlayPath, recipe: 5);
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
var overlay = new LauncherContentOverlay(
Path.GetFileName(overlayPath),
await FileIntegrity.ComputeSha256HexAsync(overlayPath),
new FileInfo(overlayPath).Length,
5);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
record.PreparedAssetSha256,
5,
overlay);
await store.SaveAtomicallyAsync(record, state);
(LauncherContentState? quick, string? quickError) =
await store.LoadAsync(record);
Assert.Null(quickError);
Assert.Equal(state, quick);
Assert.Equal(0, hashCalls);
(LauncherContentState? verified, string? verifyError) =
await store.LoadAsync(record, forceFullVerification: true);
Assert.Null(verifyError);
Assert.Equal(state, verified);
Assert.Equal(2, hashCalls);
Assert.Empty(Directory.EnumerateFiles(
Path.GetDirectoryName(store.StatePath)!,
"content.current.json.*.tmp"));
}
[Theory]
[InlineData("../escape.pak")]
[InlineData("nested/escape.pak")]
[InlineData("C:\\escape.pak")]
[InlineData("not-a-pak.txt")]
public async Task OverlayPathMustBeOneContainedPakFilename(string path)
{
var store = new LauncherContentStateStore(_paths);
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
WritePakHeader(basePath, recipe: 4);
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
record.PreparedAssetSha256,
5,
new LauncherContentOverlay(path, new string('a', 64), 64, 5));
InvalidDataException error = await Assert.ThrowsAsync<InvalidDataException>(
() => store.SaveAtomicallyAsync(record, state));
Assert.Contains("filename", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(File.Exists(store.StatePath));
}
[Fact]
public async Task BaseDigestBindingRejectsSidecarFromPriorFullRebuild()
{
var store = new LauncherContentStateStore(_paths);
string basePath = Path.Combine(_paths.DataDirectory, "pak", "acdream.pak");
WritePakHeader(basePath, recipe: 4);
LauncherInstallRecord record = await RecordAsync(basePath, recipe: 4);
var state = new LauncherContentState(
LauncherContentState.CurrentSchemaVersion,
new string('b', 64),
5,
new LauncherContentOverlay(
"acdream-update-5-test.pak",
new string('a', 64),
64,
5));
InvalidDataException error = await Assert.ThrowsAsync<InvalidDataException>(
() => store.SaveAtomicallyAsync(record, state));
Assert.Contains("base pak", error.Message, StringComparison.OrdinalIgnoreCase);
}
internal static void WritePakHeader(string path, uint recipe)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
byte[] bytes = new byte[64];
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(0, 4), 0x4B504341u);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(4, 4), 1);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(8, 4), 100);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(12, 4), 200);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(16, 4), 300);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(20, 4), 400);
BinaryPrimitives.WriteUInt64LittleEndian(bytes.AsSpan(24, 8), 64);
BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(36, 4), recipe);
File.WriteAllBytes(path, bytes);
}
internal static async Task<LauncherInstallRecord> RecordAsync(
string basePath,
uint recipe,
string? datDirectory = null) =>
new(
datDirectory ?? Path.GetDirectoryName(basePath)!,
basePath,
await FileIntegrity.ComputeSha256HexAsync(basePath),
new FileInfo(basePath).Length,
recipe);
}

View file

@ -85,7 +85,7 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
}
[Fact]
public async Task StaleBakeToolVersionIsRejectedBeforeHashing()
public async Task StaleBakeToolVersionPromptsForKnownMigrationBeforeHashing()
{
int hashCalls = 0;
var store = new LauncherInstallRecordStore(
@ -112,8 +112,12 @@ public sealed class LauncherInstallRecordStoreTests : IDisposable
}));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.Equal(InstallRecordVerificationState.Invalid, verification.State);
Assert.Contains("Bake tool version", verification.Status, StringComparison.Ordinal);
Assert.Equal(
InstallRecordVerificationState.ContentUpdateRequired,
verification.State);
Assert.NotNull(verification.Record);
Assert.Equal(ContentWorkKind.FullRebuild, verification.RequiredContentWork?.Kind);
Assert.Contains("World data update", verification.Status, StringComparison.Ordinal);
Assert.Equal(0, hashCalls);
}

View file

@ -75,12 +75,14 @@ public sealed class LauncherInstallerTests : IDisposable
Assert.Equal(Path.GetFullPath(_bakeExecutable), observedRequest.ExecutablePath);
Assert.Equal(Path.GetFullPath(_dats), observedRequest.DatDirectory);
Assert.Equal(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
LauncherInstaller.GetFullRebuildCandidatePath(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak")),
observedRequest.OutputPath);
Assert.Equal(
[
"--dat-dir", Path.GetFullPath(_dats),
"--out", Path.Combine(_paths.DataDirectory, "pak", "acdream.pak"),
"--out", LauncherInstaller.GetFullRebuildCandidatePath(
Path.Combine(_paths.DataDirectory, "pak", "acdream.pak")),
"--threads", "7",
"--progress-json",
],
@ -145,6 +147,54 @@ public sealed class LauncherInstallerTests : IDisposable
StringComparison.Ordinal);
}
[Fact]
public async Task FullContentMigrationPersistsClientGateAcrossLauncherRestart()
{
var runner = new FakeBakeProcessRunner(async (request, output, _) =>
{
Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!);
await File.WriteAllTextAsync(request.OutputPath, "replacement package");
long bytes = new FileInfo(request.OutputPath).Length;
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":"
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":"
+ $"{LauncherInstallRecordStore.CurrentBakeToolVersion},"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
processRunner: runner);
var migration = new ContentMigrationPlan(
LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
LauncherInstallRecordStore.CurrentBakeToolVersion,
ContentWorkKind.FullRebuild,
"fixture full migration");
LauncherInstallResult result = await installer.ApplyContentUpdateAsync(
_dats,
2,
migration);
var stateStore = new LauncherContentStateStore(_paths);
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
Assert.True(File.Exists(stateStore.ClientCompatibilityPendingPath));
var restarted = new LauncherInstaller(
_paths,
_bakeExecutable,
processRunner: runner);
InstallRecordVerification discovered = await restarted.LoadExistingAsync();
Assert.True(discovered.IsVerified);
Assert.True(discovered.Record!.RequiresClientCompatibilityConfirmation);
restarted.ConfirmClientCompatibility();
discovered = await restarted.LoadExistingAsync();
Assert.False(discovered.Record!.RequiresClientCompatibilityConfirmation);
Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath));
}
[Fact]
public async Task FailedChildRestoresPriorVerifiedPakAndRecord()
{
@ -361,12 +411,15 @@ public sealed class LauncherInstallerTests : IDisposable
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));
await File.ReadAllTextAsync(storeA.PreparedAssetPath));
Assert.False(File.Exists(backupPath));
Assert.Equal(
"installer A in progress",
await File.ReadAllTextAsync(
LauncherInstaller.GetFullRebuildCandidatePath(
storeA.PreparedAssetPath)));
}
finally
{
@ -547,12 +600,14 @@ public sealed class LauncherInstallerTests : IDisposable
await File.ReadAllTextAsync(childPid),
System.Globalization.CultureInfo.InvariantCulture);
using Process orphan = Process.GetProcessById(orphanPid);
Assert.True(File.Exists(
Assert.False(File.Exists(
LauncherInstallRecordStore.GetBackupPath(
store.PreparedAssetPath)));
string candidatePath = LauncherInstaller.GetFullRebuildCandidatePath(
store.PreparedAssetPath);
Assert.True(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
candidatePath)));
parent.Kill(entireProcessTree: false);
await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
@ -580,7 +635,7 @@ public sealed class LauncherInstallerTests : IDisposable
recovered = await recovery.WaitAsync(TimeSpan.FromSeconds(15));
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
candidatePath)));
File.WriteAllText(release, "release");
}
@ -622,7 +677,7 @@ public sealed class LauncherInstallerTests : IDisposable
Assert.False(backupAfterRecovery);
Assert.False(File.Exists(
BakePublicationGuardPaths.GetAuthorizationPath(
store.PreparedAssetPath)));
candidatePath)));
}
finally
{

View file

@ -0,0 +1,218 @@
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 LauncherOverlayInstallerTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-overlay-installer-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
private readonly string _bakeExecutable;
public LauncherOverlayInstallerTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "dats");
foreach (string name in DatDirectoryLocator.RequiredFileNames)
{
Directory.CreateDirectory(_dats);
File.WriteAllText(Path.Combine(_dats, name), name);
}
_bakeExecutable = Path.Combine(_root, "bin", "acdream-bake");
Directory.CreateDirectory(Path.GetDirectoryName(_bakeExecutable)!);
File.WriteAllText(_bakeExecutable, "fixture");
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task FilteredBakePublishesOneOverlayWithoutRewritingBase()
{
var recordStore = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore);
byte[] baseBefore = await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath);
BakeProcessRequest? observed = null;
var runner = new FakeRunner(async (request, output, _) =>
{
observed = request;
LauncherContentStateStoreTests.WritePakHeader(
request.OutputPath,
LauncherInstallRecordStore.CurrentBakeToolVersion);
long bytes = new FileInfo(request.OutputPath).Length;
output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":5}}\n");
output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":5,"
+ $"\"outputBytes\":{bytes},\"failures\":0}}\n");
await Task.Yield();
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: recordStore,
processRunner: runner);
var migration = new ContentMigrationPlan(
4,
5,
ContentWorkKind.Overlay,
"bounded fixture update",
[0x01001234u, 0x02005678u],
[0xAB]);
LauncherInstallResult result = await installer.ApplyContentUpdateAsync(
_dats,
3,
migration);
Assert.Equal(baseBefore, await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath));
Assert.NotNull(observed);
Assert.Equal(
new[] { "--ids", "0x01001234,0x02005678" },
observed.Arguments.SkipWhile(value => value != "--ids").Take(2));
Assert.Equal(
new[] { "--landblocks", "0xAB" },
observed.Arguments.SkipWhile(value => value != "--landblocks").Take(2));
Assert.NotNull(result.Record.PreparedAssetOverlayPath);
Assert.True(File.Exists(result.Record.PreparedAssetOverlayPath));
Assert.Equal(5u, result.Record.ResolvedBakeToolVersion);
Assert.True(result.Record.RequiresClientCompatibilityConfirmation);
Assert.False(File.Exists(
new LauncherContentStateStore(_paths).OverlayCandidatePath));
var restartedInstaller = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: recordStore,
processRunner: runner);
InstallRecordVerification discovered =
await restartedInstaller.LoadExistingAsync();
Assert.True(discovered.IsVerified);
Assert.True(discovered.Record?.RequiresClientCompatibilityConfirmation);
Assert.Equal(
result.Record.PreparedAssetOverlayPath,
discovered.Record?.PreparedAssetOverlayPath);
restartedInstaller.ConfirmClientCompatibility();
discovered = await restartedInstaller.LoadExistingAsync();
Assert.True(discovered.IsVerified);
Assert.False(discovered.Record?.RequiresClientCompatibilityConfirmation);
string json = SessionConfigComposer.Serialize(
SessionConfigComposer.Compose(
new() { Name = "server", Host = "localhost", Port = 9000 },
new() { Account = "account", Password = "secret" },
new()
{
Name = "character",
LaunchMode = AcDream.Launcher.Core.Profiles.LaunchMode.Gui,
},
discovered.Record!,
_paths,
"overlay-session").Document);
Assert.Contains("preparedAssetOverlayPath", json, StringComparison.Ordinal);
Assert.Contains("preparedAssetBaseRecipeVersion", json, StringComparison.Ordinal);
Assert.DoesNotContain(baseRecord.PreparedAssetSha256, json, StringComparison.Ordinal);
}
[Fact]
public async Task CancellationLeavesBaseRecordAndSidecarUntouched()
{
var recordStore = new LauncherInstallRecordStore(_paths);
LauncherInstallRecord baseRecord = await CreateBaseRecordAsync(recordStore);
string recordBefore = await File.ReadAllTextAsync(recordStore.RecordPath);
byte[] baseBefore = await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath);
var entered = new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously);
var runner = new FakeRunner(async (request, _, cancellationToken) =>
{
LauncherContentStateStoreTests.WritePakHeader(request.OutputPath, 5);
entered.SetResult();
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return new BakeProcessResult(0, string.Empty);
});
var installer = new LauncherInstaller(
_paths,
_bakeExecutable,
recordStore: recordStore,
processRunner: runner);
using var cancellation = new CancellationTokenSource();
Task<LauncherInstallResult> operation = installer.ApplyContentUpdateAsync(
_dats,
2,
new ContentMigrationPlan(
4,
5,
ContentWorkKind.Overlay,
"bounded fixture update",
[0x01001234u]),
cancellationToken: cancellation.Token);
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => operation);
Assert.Equal(baseBefore, await File.ReadAllBytesAsync(
recordStore.PreparedAssetPath));
Assert.Equal(recordBefore, await File.ReadAllTextAsync(recordStore.RecordPath));
var stateStore = new LauncherContentStateStore(_paths);
Assert.False(File.Exists(stateStore.StatePath));
Assert.False(File.Exists(stateStore.OverlayCandidatePath));
Assert.False(File.Exists(stateStore.ClientCompatibilityPendingPath));
Assert.Equal(
InstallRecordVerificationState.ContentUpdateRequired,
(await installer.LoadExistingAsync()).State);
Assert.Equal(baseRecord, (await recordStore.LoadAndVerifyAsync()).Record);
}
private async Task<LauncherInstallRecord> CreateBaseRecordAsync(
LauncherInstallRecordStore recordStore)
{
LauncherContentStateStoreTests.WritePakHeader(
recordStore.PreparedAssetPath,
recipe: 4);
LauncherInstallRecord baseRecord =
await LauncherContentStateStoreTests.RecordAsync(
recordStore.PreparedAssetPath,
recipe: 4,
datDirectory: Path.GetFullPath(_dats));
Directory.CreateDirectory(_paths.DataDirectory);
await File.WriteAllTextAsync(
recordStore.RecordPath,
JsonSerializer.Serialize(baseRecord, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
return baseRecord;
}
private sealed class FakeRunner(
Func<BakeProcessRequest, Action<string>, CancellationToken, Task<BakeProcessResult>> handler)
: IBakeProcessRunner
{
public Task<BakeProcessResult> RunAsync(
BakeProcessRequest request,
Action<string> onStandardOutput,
CancellationToken cancellationToken = default) =>
handler(request, onStandardOutput, cancellationToken);
}
}

View file

@ -67,6 +67,21 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable
Assert.Equal(1, _hashCount);
}
[Fact]
public async Task CacheMissReportsTheExceptionalLongReadBeforeHashing()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
var statuses = new List<string>();
Assert.True((await store.LoadAndVerifyAsync(
progress: new ImmediateProgress(statuses.Add))).IsVerified);
Assert.Contains(
statuses,
status => status.Contains("whole world-data pak", StringComparison.Ordinal)
&& status.Contains("30 seconds", StringComparison.Ordinal));
}
[Fact]
public async Task ForcedFullVerificationHashesEvenWithAValidCache()
{
@ -264,4 +279,9 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable
LauncherInstallRecordStore.CurrentBakeToolVersion));
return store;
}
private sealed class ImmediateProgress(Action<string> report) : IProgress<string>
{
public void Report(string value) => report(value);
}
}

View file

@ -39,6 +39,34 @@ public sealed class SessionConfigComposerTests
LoginCommands = ["/tell someone, hi"],
};
[Fact]
public void PendingClientCompatibilityCannotComposePlayOrProbeSession()
{
LauncherInstallRecord pending = Install with
{
RequiresClientCompatibilityConfirmation = true,
};
InvalidOperationException play = Assert.Throws<InvalidOperationException>(() =>
SessionConfigComposer.Compose(
Server(),
Account(),
Character(LaunchMode.Gui),
pending,
Paths,
"pending-play"));
InvalidOperationException probe = Assert.Throws<InvalidOperationException>(() =>
SessionConfigComposer.ComposeProbe(
Server(),
Account(),
pending,
Paths,
"pending-probe"));
Assert.Contains("matching client", play.Message, StringComparison.OrdinalIgnoreCase);
Assert.Contains("matching client", probe.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GuiModeIncludesCharacterSelectorAndOmitsPolicy()
{

View file

@ -152,6 +152,27 @@ public sealed class LauncherUpdateViewModelTests
Assert.Equal(0, updater.StageCalls);
}
[Fact]
public async Task AnotherStartupQuestionDefersButDoesNotLoseTheUpdatePrompt()
{
var updater = new FakeUpdater { ClientUpdateAvailable = true };
bool canOpen = false;
using var viewModel = new LauncherUpdateViewModel(
updater,
new ImmediateUiDispatcher(),
() => { },
canOpen: () => canOpen,
canMutate: () => true);
await viewModel.StartupCheckAsync();
Assert.False(viewModel.IsOpen);
canOpen = true;
viewModel.TryOpenPendingUpdate();
Assert.True(viewModel.IsOpen);
}
[Fact]
public async Task UpdatingIsRefusedWhileASessionIsRunning()
{

View file

@ -2,6 +2,7 @@ using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
namespace AcDream.Launcher.Tests;
@ -9,7 +10,7 @@ namespace AcDream.Launcher.Tests;
public sealed class LauncherWindowViewModelTests
{
[Fact]
public void InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
public async Task InitializeProjectsHierarchySessionsAndFutureWorkflowShells()
{
using var orchestrator = new FakeLauncherOrchestrator();
using var viewModel = new LauncherWindowViewModel(
@ -35,6 +36,11 @@ public sealed class LauncherWindowViewModelTests
Assert.Equal("Character select", session.State);
Assert.True(session.IsActive);
Assert.True(viewModel.IsInstallationChecking);
Assert.True(viewModel.ShowInstallationBanner);
Assert.False(viewModel.IsFirstRunRequired);
await viewModel.StartBackgroundInitializationAsync();
Assert.False(viewModel.IsInstallationChecking);
Assert.True(viewModel.IsFirstRunRequired);
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
// LU3: the update question says nothing and shows nothing until the
@ -47,6 +53,254 @@ public sealed class LauncherWindowViewModelTests
Assert.False(viewModel.FirstRunWizardShell.IsOpen);
}
[Fact]
public async Task BackgroundStartupWaitsForWindowSignalAndOrdersContentBeforeUpdates()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var contentCompletion = new TaskCompletionSource<InstallRecordVerification>(
TaskCreationOptions.RunContinuationsAsynchronously);
var installer = new FakeLauncherInstaller
{
LoadExistingHandler = _ => contentCompletion.Task,
};
var updater = new StartupOrderUpdater();
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
// App calls the next method only from MainWindow.Opened. Constructing
// and initializing the shell cannot start pak I/O or update recovery.
Assert.Empty(installer.LoadExistingCalls);
Assert.Equal(0, updater.InitializeCalls);
Assert.Equal(0, updater.CheckCalls);
Assert.True(viewModel.IsInstallationChecking);
Task startup = viewModel.StartBackgroundInitializationAsync();
Assert.Equal([false], installer.LoadExistingCalls);
Assert.Equal(0, updater.InitializeCalls);
Assert.Equal(0, updater.CheckCalls);
Assert.False(startup.IsCompleted);
contentCompletion.SetResult(new InstallRecordVerification(
InstallRecordVerificationState.Verified,
installer.Record,
"Client content verified without a startup hash."));
await startup;
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.False(viewModel.IsInstallationChecking);
Assert.False(viewModel.ShowInstallationBanner);
Assert.Equal(1, updater.InitializeCalls);
Assert.Equal(1, updater.CheckCalls);
Assert.Same(startup, viewModel.StartBackgroundInitializationAsync());
}
[Fact]
public async Task RequiredWorldDataWorkIsExplainedAndNeverStartsWithoutConfirmation()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
var stale = installer.Record with
{
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
PreparedAssetSize = 28L * 1024 * 1024 * 1024,
};
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
stale.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
stale,
$"World data update required: {migration.Reason}.",
migration);
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
new StartupOrderUpdater());
viewModel.Initialize();
await viewModel.StartBackgroundInitializationAsync();
Assert.Same(stale, orchestrator.InstalledRecord);
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
Assert.True(viewModel.FirstRunWizardShell.IsContentUpdate);
Assert.Equal("World data update required", viewModel.InstallationBannerTitle);
Assert.Contains("complete replacement pak", viewModel.FirstRunWizardShell.Body);
Assert.Contains("existing package stays", viewModel.FirstRunWizardShell.Body);
Assert.Equal("Rebuild world data", viewModel.FirstRunWizardShell.StartActionText);
Assert.Null(installer.InstallRequest);
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
Assert.Null(installer.InstallRequest);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.NotNull(installer.InstallRequest);
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.True(viewModel.FirstRunWizardShell.IsCompleted);
}
[Fact]
public async Task PreparedWorldDataWaitsForMatchingClientAndNotNowCannotPublishIt()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
var stale = installer.Record with
{
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
};
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
stale.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
stale,
$"World data update required: {migration.Reason}.",
migration);
var updater = new StartupOrderUpdater { ClientUpdateAvailable = true };
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
await viewModel.StartBackgroundInitializationAsync();
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Null(orchestrator.InstalledRecord);
Assert.Contains(
"Play stays disabled",
viewModel.FirstRunWizardShell.CompletedBody,
StringComparison.Ordinal);
Assert.Contains(
"matching game update",
orchestrator.InstallationStatus,
StringComparison.OrdinalIgnoreCase);
viewModel.FirstRunWizardShell.AcknowledgeCompletionCommand.Execute(null);
Assert.True(viewModel.UpdatePrompt.IsOpen);
viewModel.UpdatePrompt.NotNowCommand.Execute(null);
Assert.Null(orchestrator.InstalledRecord);
Assert.Equal("Game update required", viewModel.InstallationBannerTitle);
viewModel.UpdatePrompt.TryOpenPendingUpdate();
await viewModel.UpdatePrompt.UpdateCommand.ExecuteAsync();
Assert.Equal(1, updater.InstallCalls);
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.False(viewModel.ShowInstallationBanner);
}
[Fact]
public async Task ContentBuiltDuringStartupWaitsForCompatibilityCheckResult()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
var stale = installer.Record with
{
BakeToolVersion = LauncherInstallRecordStore.CurrentBakeToolVersion - 1,
};
ContentMigrationPlan migration = ContentMigrationCatalog.Resolve(
stale.BakeToolVersion,
LauncherInstallRecordStore.CurrentBakeToolVersion);
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.ContentUpdateRequired,
stale,
$"World data update required: {migration.Reason}.",
migration);
var checkGate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var updater = new StartupOrderUpdater { CheckGate = checkGate };
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
Task startup = viewModel.StartBackgroundInitializationAsync();
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
Assert.False(startup.IsCompleted);
await viewModel.FirstRunWizardShell.StartCommand.ExecuteAsync();
Assert.Null(orchestrator.InstalledRecord);
checkGate.SetResult(true);
await startup;
Assert.Equal(installer.Record, orchestrator.InstalledRecord);
Assert.Contains(
"play now",
viewModel.FirstRunWizardShell.CompletedBody,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RestartRestoresPendingClientGateUntilClientInstallCompletes()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller();
LauncherInstallRecord gatedRecord = installer.Record with
{
RequiresClientCompatibilityConfirmation = true,
};
installer.NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.Verified,
gatedRecord,
"World data is verified; matching client confirmation is pending.");
var updater = new StartupOrderUpdater { ClientUpdateAvailable = true };
using var viewModel = new LauncherWindowViewModel(
orchestrator,
new ImmediateUiDispatcher(),
installer,
updater);
viewModel.Initialize();
await viewModel.StartBackgroundInitializationAsync();
Assert.Null(orchestrator.InstalledRecord);
Assert.Equal("Game update required", viewModel.InstallationBannerTitle);
Assert.True(viewModel.UpdatePrompt.IsOpen);
await viewModel.UpdatePrompt.UpdateCommand.ExecuteAsync();
Assert.Equal(1, installer.ConfirmCompatibilityCalls);
Assert.NotNull(orchestrator.InstalledRecord);
Assert.False(
orchestrator.InstalledRecord!.RequiresClientCompatibilityConfirmation);
}
[Fact]
public void ProfileCommandsExposeServerAccountCharacterCrudDialogsAndClearPasswords()
{
@ -336,8 +590,8 @@ public sealed class LauncherWindowViewModelTests
var installer = new FakeLauncherInstaller();
using var viewModel = CreateInitialized(orchestrator, installer);
// LU1: nothing has asked for a verification yet. Startup's own
// verification happens in the composition root, not here.
// Content discovery is armed by MainWindow.Opened, which this focused
// view-model setup deliberately has not signalled.
Assert.Empty(installer.LoadExistingCalls);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
@ -659,6 +913,9 @@ public sealed class LauncherWindowViewModelTests
public LauncherInstallRecord? InstalledRecord { get; private set; }
public string InstallationStatus { get; private set; } =
"No installed client is configured.";
public void LoadProfiles() => LoadCalled = true;
public LauncherStateSnapshot GetSnapshot() => new(
@ -666,9 +923,7 @@ public sealed class LauncherWindowViewModelTests
[Session],
Platform,
IsInstallationReady: InstalledRecord is not null,
InstallationStatus: InstalledRecord is null
? "No installed client is configured."
: "Client content verified.");
InstallationStatus);
public LauncherCapability GetLaunchCapability(LaunchMode mode) =>
Platform.ForLaunchMode(mode);
@ -685,6 +940,18 @@ public sealed class LauncherWindowViewModelTests
public void SetInstallRecord(LauncherInstallRecord? installRecord)
{
InstalledRecord = installRecord;
InstallationStatus = installRecord is null
? "No installed client is configured."
: "Client content verified.";
StateChanged?.Invoke(this, EventArgs.Empty);
}
public void SetInstallationState(
LauncherInstallRecord? installRecord,
string installationStatus)
{
InstalledRecord = installRecord;
InstallationStatus = installationStatus;
StateChanged?.Invoke(this, EventArgs.Empty);
}
@ -836,6 +1103,8 @@ public sealed class LauncherWindowViewModelTests
public (string DatDirectory, int Threads)? InstallRequest { get; private set; }
public int ConfirmCompatibilityCalls { get; private set; }
public Func<
string,
int,
@ -884,12 +1153,17 @@ public sealed class LauncherWindowViewModelTests
/// called with, oldest first.</summary>
public List<bool> LoadExistingCalls { get; } = [];
public Func<CancellationToken, Task<InstallRecordVerification>>?
LoadExistingHandler { get; set; }
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
LoadExistingCalls.Add(forceFullVerification);
return Task.FromResult(NextVerification);
return LoadExistingHandler is null
? Task.FromResult(NextVerification)
: LoadExistingHandler(cancellationToken);
}
public Task<LauncherInstallResult> InstallAsync(
@ -919,5 +1193,88 @@ public sealed class LauncherWindowViewModelTests
"Verifying package."));
return Task.FromResult(new LauncherInstallResult(Record));
}
public void ConfirmClientCompatibility() => ConfirmCompatibilityCalls++;
}
private sealed class StartupOrderUpdater : ILauncherUpdater
{
private static readonly LauncherVersion Version = LauncherVersion.Parse("1.0.0");
private readonly ClientVersionResolution _resolution = new(
ClientVersionState.Missing,
"No versioned client is installed.",
null,
null,
null,
null);
public int InitializeCalls { get; private set; }
public int CheckCalls { get; private set; }
public int InstallCalls { get; private set; }
public bool ClientUpdateAvailable { get; init; }
public TaskCompletionSource<bool>? CheckGate { get; init; }
public ClientVersionResolution CurrentClient => _resolution;
public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default)
{
InitializeCalls++;
return Task.FromResult(_resolution);
}
public async Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default)
{
CheckCalls++;
if (CheckGate is not null)
{
_ = await CheckGate.Task.WaitAsync(cancellationToken);
}
var artifact = new ReleaseArtifact(
new Uri("https://updates.example.test/acdream.zip"),
new string('a', 64),
1);
var manifest = new ReleaseManifest(
Version,
Version,
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
return new LauncherUpdateCheckResult(
manifest,
"win-x64",
Version,
Version,
IsClientUpdateAvailable: ClientUpdateAvailable,
IsLauncherUpdateAvailable: false,
IsLauncherMinimumSatisfied: true,
"Everything is current.");
}
public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
InstallCalls++;
return Task.FromResult(_resolution);
}
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
}