Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
bc0f001
feat(gameprofiles): enable runtime content hot-swapping for active ga…
undead2146 Aug 15, 2026
d265e07
fix: address review feedback for runtime hotswap and user data integrity
undead2146 Aug 15, 2026
11ba704
fix: atomic live sync before profile update and block removal when ac…
undead2146 Aug 15, 2026
22647cd
fix: roll back live synchronization if profile persistence fails
undead2146 Aug 15, 2026
df45273
fix: report missing original manifests on rollback and record rollbac…
undead2146 Aug 15, 2026
a3f66f1
fix(userdata,gameprofiles): handle CAS materialization exceptions, us…
undead2146 Aug 15, 2026
b6e4311
fix(reconciliation): guard manifest removal on failed profiles, hoist…
undead2146 Aug 15, 2026
a6366f4
fix(ui): add edit pencil and action buttons to running profile card o…
undead2146 Aug 15, 2026
2fcf6a8
fix(userdata): return failure when live user data activation fails in…
undead2146 Aug 15, 2026
7c7d7f5
fix(userdata): enable patch hotswapping, deactivate lingering profile…
undead2146 Aug 15, 2026
1a03ba7
fix(userdata): restore previous live content and guard FirstError whe…
undead2146 Aug 15, 2026
116885c
fix(gameprofiles): prevent redundant game installation auto-resolve a…
undead2146 Aug 15, 2026
0f3267f
refactor(gameprofiles): use helper for installation dependency check …
undead2146 Aug 15, 2026
ea53217
fix(hotswap): address review comments across linker rollback, classif…
undead2146 Aug 15, 2026
bbd7cc1
fix(userdata): check return results of install and uninstall in Profi…
undead2146 Aug 16, 2026
77733e2
fix(userdata): rollback partial installations and uninstalls during l…
undead2146 Aug 16, 2026
9124914
fix(hotswap): serialize user-data sync per game and gate hotswap by m…
undead2146 Aug 16, 2026
675ffec
fix(gameprofiles): use CurrentProfileId in rollback log and fix style…
undead2146 Aug 18, 2026
ce881ee
fix(deepsource): resolve all 38 C# code review findings and rebase on…
undead2146 Aug 18, 2026
add1750
fix(test): resolve launcher harness timeout and remove redundant asyn…
undead2146 Aug 19, 2026
ceec442
test(userdata): mock CopyFromCasAsync in exception restoration test
undead2146 Aug 19, 2026
dc8f35b
fix(review): address review feedback and CI check findings
Aug 20, 2026
a349f73
fix(review): address Kilo Code review suggestions and strengthen test…
Aug 20, 2026
7f641e8
refactor(profiles): merge nested if conditions for game client valida…
Aug 20, 2026
7edc10c
fix(review): address review comments across linker, viewmodel, and tests
Aug 20, 2026
306d70e
refactor(profiles): decompose running profile validation into focused…
Aug 20, 2026
9279cd2
fix(review): resolve rebase conflicts and update review resolution in…
undead2146 Aug 26, 2026
01290aa
fix(workspace): guard IsHotswappable against manifests with unresolva…
undead2146 Aug 26, 2026
83a084b
feat(content): configure patch content to target userdata and allow h…
undead2146 Aug 26, 2026
b076871
fix(review): resolve 29 SonarCloud static analysis issues and update …
undead2146 Aug 26, 2026
942d349
fix(review): resolve remaining static analysis issues and refactor us…
undead2146 Aug 26, 2026
192c9ea
fix(content): route patch content to workspace, lock patches by defau…
undead2146 Aug 26, 2026
10a08f7
fix(userdata): ensure cleanup preserves restored original files on de…
undead2146 Aug 26, 2026
9501b93
fix(launching): fallback to launch attempt timestamp for short-lived …
undead2146 Aug 26, 2026
82e9ec6
fix(hotswap): address review feedback on live sync, launcher, setting…
undead2146 Aug 30, 2026
432c208
refactor(gameprofiles): simplify hotswap state refresh and reduce cog…
undead2146 Aug 30, 2026
ee386d2
fix(gameprofiles): remove redundant null-forgiving operator in profil…
undead2146 Aug 30, 2026
d78204d
fix(ui): backport PR 400 glassmorphic styling and vector icons withou…
undead2146 Aug 30, 2026
f6a72c9
fix(deliverer): use OpenArchive for SharpCompress 0.48.0 compatibility
undead2146 Aug 30, 2026
da31e50
fix(ui): address Kilo and CodeRabbit review findings and restore sema…
undead2146 Aug 30, 2026
59e427e
fix(ui): backport PR #400 translucent glass styling for Profile Setti…
undead2146 Aug 30, 2026
9e73a19
fix(reconciliation,userdata): fail bulk removal on blocked manifests …
undead2146 Aug 30, 2026
1666a52
fix(github,ui,wizard): resolve setup wizard cursor, info changelog ra…
undead2146 Aug 30, 2026
317bca6
refactor(github,update): simplify null check patterns and lambda expr…
undead2146 Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions GenHub/GenHub.Core/Constants/GitHubConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ public static class GitHubConstants
/// <summary>Default rate limit reset period in hours.</summary>
public const int DefaultRateLimitResetHours = 1;

/// <summary>Environment variable name for standard GitHub token.</summary>
public const string GitHubTokenEnvVar = "GITHUB_TOKEN";

/// <summary>Environment variable name for GenHub-specific GitHub token.</summary>
public const string GenHubTokenEnvVar = "GENHUB_GITHUB_TOKEN";

// Build parsing constants

/// <summary>String identifier for Zero Hour game variant.</summary>
Expand Down
10 changes: 10 additions & 0 deletions GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ public interface IGitHubApiClient
/// </summary>
bool IsAuthenticated { get; }

/// <summary>
/// Gets a value indicating whether the GitHub API rate limit is reached.
/// </summary>
bool IsRateLimited { get; }

/// <summary>
/// Gets the latest release from the specified repository.
/// </summary>
Expand Down Expand Up @@ -121,6 +126,11 @@ Task DownloadArtifactAsync(
/// <param name="token">The GitHub token.</param>
void SetAuthenticationToken(SecureString token);

/// <summary>
/// Clears any configured authentication token.
/// </summary>
void ClearAuthenticationToken();

/// <summary>
/// Gets the currently authenticated user.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ Task<OperationResult<bool>> UpdateProfileUserDataAsync(
/// <returns>The active profile ID, or null if no profile is active.</returns>
string? GetActiveProfileId();

/// <summary>
/// Gets the currently active profile ID for the specified game type (if any).
/// </summary>
/// <param name="targetGame">The target game type.</param>
/// <returns>The active profile ID for the specified game, or null if none is active.</returns>
string? GetActiveProfileId(GameType targetGame);

/// <summary>
/// Checks if a profile has its user data currently active.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Linq;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;

namespace GenHub.Core.Models.Workspace;

/// <summary>
/// Classifies content types based on whether they can be safely hot-swapped during an active game session.
/// Hotswappable content is deployed to the user Documents directory and read dynamically by the game engine.
/// Locked content modifies game process executables, BIG archives in the workspace, or memory-sensitive assets.
/// </summary>
public static class ContentHotswapClassification
{
/// <summary>
/// Determines whether the specified content type can be hot-swapped while the game is running.
/// </summary>
/// <param name="contentType">The content type to evaluate.</param>
/// <returns><c>true</c> if the content type is hotswappable; otherwise, <c>false</c>.</returns>
public static bool IsHotswappable(ContentType contentType)
{
return contentType switch
{
ContentType.Map => true,
ContentType.MapPack => true,
ContentType.Replay => true,
Comment thread
undead2146 marked this conversation as resolved.
_ => false,
};
}
Comment thread
undead2146 marked this conversation as resolved.

/// <summary>
/// Determines whether the specified manifest can be hot-swapped while the game is running.
/// </summary>
/// <param name="manifest">The manifest to evaluate.</param>
/// <returns><c>true</c> if the manifest is hotswappable; otherwise, <c>false</c>.</returns>
public static bool IsHotswappable(ContentManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);

if (!IsHotswappable(manifest.ContentType))
{
return false;
}

var files = ManifestVariantResolver.ResolveFiles(manifest);
if (files.Count == 0 && (manifest.Variants.Count > 0 || manifest.Files.Count > 0))
{
return false;
}

return files.All(f =>
f.InstallTarget != ContentInstallTarget.Workspace &&
f.InstallTarget != ContentInstallTarget.System);
}
Comment thread
undead2146 marked this conversation as resolved.

/// <summary>
/// Determines whether the specified content type is locked and cannot be modified during an active game session.
/// </summary>
/// <param name="contentType">The content type to evaluate.</param>
/// <returns><c>true</c> if the content type is locked during active sessions; otherwise, <c>false</c>.</returns>
public static bool IsLocked(ContentType contentType)
{
return !IsHotswappable(contentType);
}

/// <summary>
/// Determines whether the specified manifest is locked and cannot be modified during an active game session.
/// </summary>
/// <param name="manifest">The manifest to evaluate.</param>
/// <returns><c>true</c> if the manifest is locked during active sessions; otherwise, <c>false</c>.</returns>
public static bool IsLocked(ContentManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
return !IsHotswappable(manifest);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,118 @@ public void SetAuthenticationToken_ThrowsWithMockClient()
// Act & Assert
Assert.Throws<InvalidOperationException>(() => api.SetAuthenticationToken(secureToken));
}

/// <summary>
/// Verifies that credentials are automatically loaded from IGitHubTokenStorage.
/// </summary>
[Fact]
public void EnsureCredentialsLoaded_LoadsFromTokenStorage()
{
// Arrange
var concreteClient = new GitHubClient(new ProductHeaderValue("test"));
var secureToken = new SecureString();
foreach (char c in "stored-secret-pat")
{
secureToken.AppendChar(c);
}

var tokenStorageMock = new Mock<GenHub.Core.Interfaces.GitHub.IGitHubTokenStorage>();
tokenStorageMock.Setup(x => x.HasToken()).Returns(true);
tokenStorageMock.Setup(x => x.LoadTokenAsync()).ReturnsAsync(secureToken);

var api = new OctokitGitHubApiClient(
concreteClient,
Mock.Of<IHttpClientFactory>(),
Mock.Of<ILogger<OctokitGitHubApiClient>>(),
Mock.Of<IMemoryCache>(),
tokenStorageMock.Object);

// Act & Assert
api.IsAuthenticated.Should().BeTrue();
concreteClient.Credentials.Should().NotBeNull();
concreteClient.Credentials.Password.Should().Be("stored-secret-pat");
}

/// <summary>
/// Verifies that ClearAuthenticationToken resets credentials to Anonymous.
/// </summary>
[Fact]
public void ClearAuthenticationToken_ResetsCredentialsToAnonymous()
{
// Arrange
var concreteClient = new GitHubClient(new ProductHeaderValue("test"));
var api = new OctokitGitHubApiClient(
concreteClient,
Mock.Of<IHttpClientFactory>(),
Mock.Of<ILogger<OctokitGitHubApiClient>>(),
Mock.Of<IMemoryCache>());

var secureToken = new SecureString();
foreach (char c in "test-token")
{
secureToken.AppendChar(c);
}

api.SetAuthenticationToken(secureToken);
api.IsAuthenticated.Should().BeTrue();

// Act
api.ClearAuthenticationToken();

// Assert
api.IsAuthenticated.Should().BeFalse();
concreteClient.Credentials.Should().Be(Credentials.Anonymous);
}

/// <summary>
/// Verifies that rate limit tracker is updated when RateLimitExceededException occurs.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Fact]
public async Task GetLatestReleaseAsync_WhenRateLimitExceeded_UpdatesTrackerAsync()
{
// Arrange
var resetEpoch = ((DateTimeOffset)DateTime.UtcNow.AddMinutes(30)).ToUnixTimeSeconds();
var headers = new Dictionary<string, string>
{
["X-RateLimit-Reset"] = resetEpoch.ToString(),
};
var responseMock = new Mock<Octokit.IResponse>();
responseMock.SetupGet(x => x.Headers).Returns(headers);
var rateLimit = new Octokit.RateLimit(60, 0, resetEpoch);
var apiInfo = new Octokit.ApiInfo(new Dictionary<string, Uri>(), new List<string>(), new List<string>(), "etag", rateLimit);
responseMock.SetupGet(x => x.ApiInfo).Returns(apiInfo);

var rateLimitException = new RateLimitExceededException(responseMock.Object);

var releasesClientMock = new Mock<Octokit.IReleasesClient>();
releasesClientMock
.Setup(x => x.GetLatest(It.IsAny<string>(), It.IsAny<string>()))
.ThrowsAsync(rateLimitException);

var repositoriesClientMock = new Mock<Octokit.IRepositoriesClient>();
repositoriesClientMock
.SetupGet(x => x.Release)
.Returns(releasesClientMock.Object);

var gitHubClientMock = new Mock<Octokit.IGitHubClient>();
gitHubClientMock.SetupGet(x => x.Repository).Returns(repositoriesClientMock.Object);

var tracker = new GitHubRateLimitTracker(Mock.Of<ILogger<GitHubRateLimitTracker>>());

var api = new OctokitGitHubApiClient(
gitHubClientMock.Object,
Mock.Of<IHttpClientFactory>(),
Mock.Of<ILogger<OctokitGitHubApiClient>>(),
Mock.Of<IMemoryCache>(),
rateLimitTracker: tracker);

// Act
var result = await api.GetLatestReleaseAsync("owner", "repo");

// Assert
result.Should().BeNull();
api.IsRateLimited.Should().BeTrue();
tracker.IsAtLimit.Should().BeTrue();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,9 @@ public static LauncherHarness Create(
// Batch has no $$. PowerShell's own parent is the batch host, so it can report the
// PID the harness needs. If PowerShell is unavailable the loop simply writes
// nothing and Dispose falls back to leaving the launcher alone.
var recordPid = $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";
var recordPid = exitImmediately
? string.Empty
: $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";

// Leave the working directory afterwards: a batch host holds its current directory
// open, which would defeat the cleanup delete for the launcher's whole lifetime.
Expand All @@ -554,9 +556,10 @@ public static LauncherHarness Create(
var spawn = spawnChild ? $"\"{childPath}\" {LauncherLifetimeSeconds} &\n" : string.Empty;
var linger = exitImmediately ? string.Empty : $"sleep {LauncherLifetimeSeconds}\n";
var complain = stderrMessage is null ? string.Empty : $"echo \"{stderrMessage}\" >&2\n";
var recordPid = exitImmediately ? string.Empty : $"echo $$ > \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";

// The harness does not start the launcher, so the launcher reports its own PID.
script = $"#!/bin/bash\necho $$ > \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n{complain}{spawn}{linger}";
script = $"#!/bin/bash\n{recordPid}{complain}{spawn}{linger}";
}

File.WriteAllText(launcherPath, script);
Expand Down
Loading
Loading