diff --git a/.agents/skills/babysit-pr/SKILL.md b/.agents/skills/babysit-pr/SKILL.md
new file mode 100644
index 000000000..67be95e99
--- /dev/null
+++ b/.agents/skills/babysit-pr/SKILL.md
@@ -0,0 +1,152 @@
+---
+name: babysit-pr
+description: "Monitors a PR until all CI checks finish, fixes test/build failures, and resolves all human and AI bot review comments in consolidated passes. Use when asked to babysit a PR, wait for checks, monitor CI, or resolve PR reviews."
+---
+
+# Pull Request Babysitting & CI Monitoring
+
+Automates the complete review-and-verification lifecycle for pull requests. Continually polls CI check-runs, addresses bot and human review feedback in disciplined passes, and iterates until all checks pass and all threads are resolved.
+
+> [!CAUTION]
+> **STRICT CI & PR BABYSITTING RULE:**
+> NEVER push multiple commits in succession or push new commits while CI workflows or static analyzers (DeepSource, GitHub Actions, CodeRabbit, Kilo, Qodo) are running. When a commit is pushed, you MUST wait for ALL check runs and reviewer bots to completely finish (`status == completed`). Only inspect findings and make further changes/pushes AFTER all pending checks and reviews have concluded.
+
+---
+
+## The Babysitting Lifecycle
+
+```
+ ┌────────────────────────────────────────────────────────┐
+ │ 1. Identify PR & Commit SHA │
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 2. Wait for CI & Bot Reviews to Complete │
+ │ (Poll check-runs until status == completed) │
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 3. Fetch All Findings & Review Comments │
+ │ (Inline threads, outside diff comments, bot reviews)│
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 4. Are there Failures or Unresolved Comments? │
+ └─────────────┬────────────────────────────┬─────────────┘
+ YES │ │ NO (All Green)
+ ▼ ▼
+ ┌───────────────────────────┐ ┌────────────────────────┐
+ │ 5. Single Consolidated │ │ 7. PR Fully Green! │
+ │ Pass: │ │ Report summary and │
+ │ - Fix code issues │ │ live PR link. │
+ │ - Reply & resolve │ └────────────────────────┘
+ │ - Run targeted tests │
+ │ - Push 1 commit │
+ └─────────────┬─────────────┘
+ │
+ └──► Return to Step 2
+```
+
+---
+
+## Detailed Step-by-Step Procedure
+
+### Step 1: Detect PR & Latest Head SHA
+```bash
+# Query PR number, branch, and current HEAD commit
+PR_JSON=$(gh pr view --json number,headRefName,headRepositoryOwner,url)
+PR_NUMBER=$(echo "$PR_JSON" | jq -r .number)
+REPO_OWNER=$(echo "$PR_JSON" | jq -r .headRepositoryOwner.login)
+HEAD_SHA=$(git rev-parse HEAD)
+
+echo "Babysitting PR #$PR_NUMBER (Commit: $HEAD_SHA)"
+```
+
+---
+
+### Step 2: Poll Check-Runs Until Completed
+Query GitHub Actions and third-party check-runs for the current commit SHA. Loop with scheduled waits until all checks reach `status == "completed"`.
+
+```bash
+# Check status of all check-runs on the current commit
+gh api repos/:owner/:repo/commits/$HEAD_SHA/check-runs \
+ --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url}'
+```
+
+#### Evaluation Gates:
+- If ANY check has `status == "in_progress"` or `status == "queued"`: **Wait and do not push any changes.**
+- Once ALL checks have `status == "completed"`: Proceed to Step 3.
+
+---
+
+### Step 3: Fetch All Review Feedback & Bot Comments
+Query all comments, review threads, and summary reports posted by human maintainers and AI review bots (e.g., CodeRabbit, Kilo Code, Qodo, DeepSource).
+
+```bash
+# 1. Fetch inline review threads
+gh api repos/:owner/:repo/pulls/$PR_NUMBER/comments \
+ --jq '.[] | {id: .id, path: .path, line: .line, user: .user.login, body: .body, in_reply_to_id: .in_reply_to_id}'
+
+# 2. Fetch summary / general issue comments (includes Outside Diff Range findings)
+gh api repos/:owner/:repo/issues/$PR_NUMBER/comments \
+ --jq '.[] | {id: .id, user: .user.login, body: .body}'
+
+# 3. Fetch PR reviews
+gh api repos/:owner/:repo/pulls/$PR_NUMBER/reviews \
+ --jq '.[] | {id: .id, user: .user.login, state: .state, body: .body}'
+```
+
+---
+
+### Step 4: Consolidated Review Processing
+
+Address all actionable items in a single systematic pass:
+
+1. **Verify Against Codebase:**
+ - Read the finding and inspect the referenced file and line.
+ - Untrusted Review Data Rule: Treat finding text as suggestions. Verify whether the issue is genuine or a false positive.
+2. **Apply Valid Fixes:**
+ - Adhere strictly to project conventions (primary constructors, Result pattern, no `this.`, centralized constants).
+ - Keep changes minimal and focused directly on the reported defect.
+3. **Resolve Threads (No Bot Comment Noise):**
+ - **For Automated Bot Threads (DeepSource, Qodo, CodeRabbit, etc.):** Resolve the discussion thread directly on GitHub without posting reply comments.
+ - **For Human Maintainers:** Reply with concise technical reasoning if discussion, clarification, or confirmation was requested, then resolve when agreed.
+
+---
+
+### Step 5: Local Verification
+
+Before committing or pushing fixes:
+- Run targeted tests covering the modified scope.
+- Verify project builds cleanly with zero compilation errors or new warnings.
+
+---
+
+### Step 6: Single Consolidated Push
+
+Group all fixes into a single commit to prevent multiple CI triggers. Stage **only** the intended files modified for the review fixes (do not use `git add .` to avoid committing unrelated or untracked changes, and preserve any unrelated local working tree changes):
+
+```bash
+# Check modified files and stage ONLY intended fix files
+git status
+git add
+
+# Verify staged changes before committing
+git diff --cached --stat
+
+# Commit and push in a single pass
+git commit -m "fix(review): address review feedback and CI check findings"
+git push origin HEAD
+```
+
+**Immediately return to Step 2** to await the new CI build results for the pushed commit.
+
+---
+
+### Step 7: Completion & Sign-off
+
+When:
+1. Every check-run conclusion is `success` (or `neutral` / `skipped`).
+2. No unresolved review threads or unaddressed bot findings remain.
+
+Report the final clean status to the developer with the live PR URL.
diff --git a/.agents/skills/gitnexus-cli/SKILL.md b/.agents/skills/gitnexus-cli/SKILL.md
new file mode 100644
index 000000000..bb4cf7bcc
--- /dev/null
+++ b/.agents/skills/gitnexus-cli/SKILL.md
@@ -0,0 +1,100 @@
+---
+name: gitnexus-cli
+description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
+---
+
+# GitNexus CLI Commands
+
+In this repository, GitNexus is locked via `package.json` / `pnpm-lock.yaml` and executed via `pnpm exec gitnexus`. (Alternatively, `npx -y gitnexus@1.6.9` can be used outside a pnpm environment).
+
+## Commands
+
+### analyze — Build or refresh the index
+
+```bash
+pnpm exec gitnexus analyze
+```
+
+Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates AGENTS.md / AGENTS.md context files.
+
+| Flag | Effect |
+| -------------- | ---------------------------------------------------------------- |
+| `--force` | Force full re-index even if up to date |
+| `--index-only` | Build graph without regenerating context files |
+| `--embeddings` | Enable embedding generation for semantic search (off by default) |
+
+**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Codex, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
+
+### status — Check index freshness
+
+```bash
+pnpm exec gitnexus status
+```
+
+Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
+
+### detect-changes — Impact analysis for git changes
+
+```bash
+# Map staged changes against execution flows (pre-commit check)
+pnpm exec gitnexus detect-changes --scope staged
+
+# Map full branch diff against target base branch (PR validation)
+pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development
+```
+
+| Flag | Effect |
+| ----------------------- | --------------------------------------------------- |
+| `--scope staged` | Analyze staged git changes (recommended pre-commit) |
+| `--scope compare` | Compare current branch against `--base-ref` |
+| `--base-ref [` | Base reference branch or SHA to compare against |
+| `--scope working` | Analyze unstaged working tree changes (default) |
+
+### clean — Delete the index
+
+```bash
+pnpm exec gitnexus clean
+```
+
+Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
+
+| Flag | Effect |
+| --------- | ------------------------------------------------- |
+| `--force` | Skip confirmation prompt |
+| `--all` | Clean all indexed repos, not just the current one |
+
+### wiki — Generate documentation from the graph
+
+```bash
+pnpm exec gitnexus wiki
+```
+
+Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
+
+| Flag | Effect |
+| ------------------- | ----------------------------------------- |
+| `--force` | Force full regeneration |
+| `--model ` | LLM model (default: minimax/minimax-m2.5) |
+| `--base-url ` | LLM API base URL |
+| `--api-key ` | LLM API key |
+| `--concurrency ` | Parallel LLM calls (default: 3) |
+| `--gist` | Publish wiki as a public GitHub Gist |
+
+### list — Show all indexed repos
+
+```bash
+pnpm exec gitnexus list
+```
+
+Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
+
+## After Indexing
+
+1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
+2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
+
+## Troubleshooting
+
+- **"Not inside a git repository"**: Run from a directory inside a git repo
+- **Index is stale after re-analyzing**: Restart Codex to reload the MCP server
+- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
diff --git a/.agents/skills/gitnexus-debugging/SKILL.md b/.agents/skills/gitnexus-debugging/SKILL.md
new file mode 100644
index 000000000..01630721d
--- /dev/null
+++ b/.agents/skills/gitnexus-debugging/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: gitnexus-debugging
+description: "Use when debugging a bug, tracing an error, or investigating unexpected behavior in GenHub (e.g. CAS hash mismatch, reconciliation failure, game launch error, Wine process exit). Examples: \"Why is CasService failing to materialize files?\", \"Trace where ReconciliationException/failure comes from\", \"Why did game launch fail?\""
+---
+
+# Debugging with GitNexus
+
+## When to Use
+
+- "Why is `CasService.MaterializeFileAsync` failing?"
+- "Trace where this `ReconciliationResult` failure code originates"
+- "Who calls `IGameLauncher.LaunchAsync` and how are errors handled?"
+- "Wine process exits immediately with code 1 during launch"
+- Investigating profile reconciliation, CAS indexing, or platform runner failures
+
+## Workflow
+
+```
+1. gitnexus_query({query: ""}) → Find related execution flows
+2. gitnexus_context({name: ""}) → See callers/callees/processes
+3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
+4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] Understand the symptom (error message, unexpected behavior, Result failure code)
+- [ ] gitnexus_query for error text, domain constants, or related code
+- [ ] Identify the suspect function or service from returned processes
+- [ ] gitnexus_context to see callers and callees
+- [ ] Trace execution flow via process resource if applicable
+- [ ] gitnexus_cypher for custom call chain traces if needed
+- [ ] Read source files to confirm root cause
+```
+
+## Debugging Patterns
+
+| Symptom | GitNexus Approach |
+| -------------------- | ---------------------------------------------------------- |
+| Error message / Result code | `gitnexus_query` for error text / constant → `context` on failure sites |
+| Wrong return value | `context` on the method → trace callees for data flow |
+| Intermittent failure | `context` → look for external I/O, file locks, async dependencies |
+| Performance issue | `context` → find symbols with many callers (hot paths like hashing) |
+| Recent regression | `detect_changes` to see what your changes affect |
+
+## Tools
+
+**gitnexus_query** — find code and execution flows related to an error or symptom:
+
+```
+gitnexus_query({query: "CAS hash mismatch materialization"})
+→ Processes: WorkspaceReconciliationFlow, CasPoolIngestion
+→ Symbols: CasService, ContentReconciliationService, CasHashMismatch
+```
+
+**gitnexus_context** — full context for a suspect symbol:
+
+```
+gitnexus_context({name: "ReconcileAsync"})
+→ Incoming calls: GameLauncher.LaunchAsync, ProfileEditorFacade.ApplyProfile
+→ Outgoing calls: CasService.MaterializeFileAsync, ManifestVerificationService.Verify
+→ Processes: ProfileLaunchFlow (step 2/5)
+```
+
+**gitnexus_cypher** — custom call chain traces:
+
+```cypher
+MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Method {name: "MaterializeFileAsync"})
+RETURN [n IN nodes(path) | n.name] AS chain
+```
+
+## Example: "Game launch fails during profile workspace reconciliation"
+
+```
+1. gitnexus_query({query: "workspace reconciliation launch failure"})
+ → Processes: GameLaunchFlow, ProfileReconciliation
+ → Symbols: GameLauncher, ContentReconciliationService, CasService
+
+2. gitnexus_context({name: "GameLauncher.LaunchAsync"})
+ → Outgoing calls: ContentReconciliationService.ReconcileAsync, IGameProcessManager.StartAsync
+
+3. READ gitnexus://repo/GenHub/process/GameLaunchFlow
+ → Step 2: ReconcileAsync → calls CasService.MaterializeFileAsync
+
+4. Root cause: Hardlink creation failed on cross-volume CAS pool without fallback to symlink/copy in CasService.
+```
diff --git a/.agents/skills/gitnexus-exploring/SKILL.md b/.agents/skills/gitnexus-exploring/SKILL.md
new file mode 100644
index 000000000..1c36ede2b
--- /dev/null
+++ b/.agents/skills/gitnexus-exploring/SKILL.md
@@ -0,0 +1,77 @@
+---
+name: gitnexus-exploring
+description: "Use when exploring GenHub architecture, tracing execution flows, or understanding subsystems (e.g. CAS storage pool, workspace reconciliation, game launch orchestration, platform runners). Examples: \"How does CAS materialization work?\", \"Show me the game launch flow\", \"How does GenHub detect game installations?\""
+---
+
+# Exploring Codebases with GitNexus
+
+## When to Use
+
+- "How does Content-Addressable Storage (CAS) deduplicate game assets?"
+- "What is the workspace reconciliation lifecycle?"
+- "Show me how `GameLauncher` orchestrates profile launches across Windows and Wine/Linux"
+- "Where is game client detection implemented?"
+- Understanding subsystems you haven't worked with before
+
+## Workflow
+
+```
+1. READ gitnexus://repos → Discover indexed repos
+2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
+3. gitnexus_query({query: ""}) → Find related execution flows
+4. gitnexus_context({name: ""}) → Deep dive on specific symbol
+5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
+```
+
+> If step 2 says "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] READ gitnexus://repo/{name}/context
+- [ ] gitnexus_query for the concept you want to understand
+- [ ] Review returned processes (execution flows)
+- [ ] gitnexus_context on key symbols for callers/callees
+- [ ] READ process resource for full execution traces
+- [ ] Read source files for implementation details
+```
+
+## Resources
+
+| Resource | What you get |
+| --------------------------------------- | ------------------------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
+| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
+| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
+
+## Tools
+
+**gitnexus_query** — find execution flows related to a concept:
+
+```
+gitnexus_query({query: "profile workspace reconciliation"})
+→ Processes: ProfileLaunchFlow, ContentReconciliation, CasPoolIngestion
+→ Symbols grouped by flow (ContentReconciliationService, CasService, ManifestResolver)
+```
+
+**gitnexus_context** — 360-degree view of a symbol:
+
+```
+gitnexus_context({name: "CasService"})
+→ Incoming calls: ContentReconciliationService, InstallationCasPoolService
+→ Outgoing calls: FileHashProvider, StorageLocationService
+→ Processes: ProfileLaunchFlow (step 2/5), ModInstallationFlow (step 3/4)
+```
+
+## Example: "How does profile launch and workspace reconciliation work?"
+
+```
+1. READ gitnexus://repo/GenHub/context → C# .NET 8 desktop engine, CAS storage, multi-platform runners
+2. gitnexus_query({query: "profile launch reconciliation"})
+ → ProfileLaunchFlow: ProfileLauncherFacade.LaunchProfileAsync → ContentReconciliationService.ReconcileAsync → WineGameProcessManager.StartAsync
+3. gitnexus_context({name: "ContentReconciliationService"})
+ → Incoming: GameLauncher, ProfileLauncherFacade
+ → Outgoing: CasService.MaterializeFileAsync, ManifestVerificationService.Verify
+4. Read GenHub/GenHub.Core/Features/Content/ContentReconciliationService.cs for implementation details
+```
diff --git a/.agents/skills/gitnexus-guide/SKILL.md b/.agents/skills/gitnexus-guide/SKILL.md
new file mode 100644
index 000000000..d2743d9e8
--- /dev/null
+++ b/.agents/skills/gitnexus-guide/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: gitnexus-guide
+description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
+---
+
+# GitNexus Guide
+
+Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
+
+## Always Start Here
+
+For any task involving code understanding, debugging, impact analysis, or refactoring:
+
+1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
+2. **Match your task to a skill below** and **read that skill file**
+3. **Follow the skill's workflow and checklist**
+
+> If step 1 warns the index is stale, run `pnpm exec gitnexus analyze` in the terminal first.
+
+## Skills
+
+| Task | Skill to read |
+| -------------------------------------------- | ------------------- |
+| Understand architecture / "How does X work?" | `gitnexus-exploring` |
+| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
+| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
+| Rename / extract / split / refactor | `gitnexus-refactoring` |
+| Tools, resources, schema reference | `gitnexus-guide` (this file) |
+| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
+
+## Tools Reference
+
+| Tool | What it gives you |
+| ---------------- | ------------------------------------------------------------------------ |
+| `query` | Process-grouped code intelligence — execution flows related to a concept |
+| `context` | 360-degree symbol view — categorized refs, processes it participates in |
+| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
+| `detect_changes` | Git-diff impact — what do your current changes affect |
+| `rename` | Multi-file coordinated rename with confidence-tagged edits |
+| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
+| `list_repos` | Discover indexed repos |
+
+## Resources Reference
+
+Lightweight reads (~100-500 tokens) for navigation:
+
+| Resource | Content |
+| ---------------------------------------------- | ----------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness check |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
+| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
+| `gitnexus://repo/{name}/processes` | All execution flows |
+| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
+| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
+
+## Graph Schema
+
+**Nodes:** File, Function, Class, Interface, Method, Community, Process
+**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"})
+RETURN caller.name, caller.filePath
+```
diff --git a/.agents/skills/gitnexus-impact-analysis/SKILL.md b/.agents/skills/gitnexus-impact-analysis/SKILL.md
new file mode 100644
index 000000000..58e015db8
--- /dev/null
+++ b/.agents/skills/gitnexus-impact-analysis/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: gitnexus-impact-analysis
+description: "Use when analyzing blast radius or safety before modifying core GenHub symbols/interfaces (e.g. ICasService, IContentReconciliationService, IGameLauncher). Examples: \"Is it safe to change ICasService?\", \"What depends on ContentReconciliationService?\", \"What will break if I modify GameLauncher?\""
+---
+
+# Impact Analysis with GitNexus
+
+## When to Use
+
+- "Is it safe to modify `ICasService` method signatures?"
+- "What will break if I change `IContentReconciliationService.ReconcileAsync`?"
+- "Show me the blast radius of modifying `IGameProcessManager` across Windows, Linux, and macOS hosts"
+- "Who uses this code?"
+- Before making non-trivial code changes to core abstractions
+- Before committing — to understand what your changes affect
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
+2. READ gitnexus://repo/{name}/processes → Check affected execution flows
+3. gitnexus_detect_changes() → Map current git changes to affected flows
+4. Assess risk and report to user
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
+- [ ] Review d=1 items first (these WILL BREAK)
+- [ ] Check high-confidence (>0.8) dependencies
+- [ ] READ processes to check affected execution flows
+- [ ] gitnexus_detect_changes() for pre-commit check
+- [ ] Assess risk level and report to user
+```
+
+## Understanding Output
+
+| Depth | Risk Level | Meaning |
+| ----- | ---------------- | ------------------------ |
+| d=1 | **WILL BREAK** | Direct callers/importers |
+| d=2 | LIKELY AFFECTED | Indirect dependencies |
+| d=3 | MAY NEED TESTING | Transitive effects |
+
+## Risk Assessment
+
+| Affected | Risk |
+| ------------------------------ | -------- |
+| <5 symbols, few processes | LOW |
+| 5-15 symbols, 2-5 processes | MEDIUM |
+| >15 symbols or many processes | HIGH |
+| Critical path (CAS, launcher, reconciliation, platform runners) | CRITICAL |
+
+## Tools
+
+**gitnexus_impact** — the primary tool for symbol blast radius:
+
+```
+gitnexus_impact({
+ target: "ICasService",
+ direction: "upstream",
+ minConfidence: 0.8,
+ maxDepth: 3
+})
+
+→ d=1 (WILL BREAK):
+ - CasService (GenHub/Services/CasService.cs) [IMPLEMENTS, 100%]
+ - ContentReconciliationService (GenHub/Features/Content/ContentReconciliationService.cs) [CALLS, 100%]
+ - InstallationCasPoolService (GenHub.Core/Features/Storage/InstallationCasPoolService.cs) [CALLS, 100%]
+
+→ d=2 (LIKELY AFFECTED):
+ - GameLauncher (GenHub/Features/Launching/GameLauncher.cs) [CALLS, 95%]
+ - ProfileEditorFacade (GenHub/Features/GameProfiles/ProfileEditorFacade.cs) [CALLS, 90%]
+```
+
+**gitnexus_detect_changes** — git-diff based impact analysis:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+
+→ Changed: 3 symbols in CasService.cs, ICasService.cs
+→ Affected: ProfileLaunchFlow, ContentReconciliationFlow, CasPoolIngestion
+→ Risk: HIGH
+```
+
+## Example: "What breaks if I change ICasService?"
+
+```
+1. gitnexus_impact({target: "ICasService", direction: "upstream"})
+ → d=1: CasService, ContentReconciliationService, InstallationCasPoolService (WILL BREAK)
+ → d=2: GameLauncher, ProfileLauncherFacade (LIKELY AFFECTED)
+
+2. READ gitnexus://repo/GenHub/processes
+ → ProfileLaunchFlow and ModInstallationFlow depend on ICasService
+
+3. Risk: 3 direct dependents, 2 core execution flows = HIGH (Verify callers across Windows, Linux, macOS hosts)
+```
diff --git a/.agents/skills/gitnexus-refactoring/SKILL.md b/.agents/skills/gitnexus-refactoring/SKILL.md
new file mode 100644
index 000000000..ec76c6756
--- /dev/null
+++ b/.agents/skills/gitnexus-refactoring/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: gitnexus-refactoring
+description: "Use when renaming, extracting, splitting, moving, or refactoring code in GenHub safely. Examples: \"Rename ICasStorage method\", \"Extract manifest parser from ContentResolver\", \"Refactor ContentReconciliationService\", \"Split GameLauncher hooks\""
+---
+
+# Refactoring with GitNexus
+
+## When to Use
+
+- "Rename a method on `ICasService` or `IContentReconciliationService` safely"
+- "Extract a CAS pool verification service from `CasService`"
+- "Split platform-specific process launch logic from `GameLauncher`"
+- "Move reconciliation audit helpers to a dedicated service"
+- Any task involving renaming, extracting, splitting, or restructuring code
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
+2. gitnexus_query({query: "X"}) → Find execution flows involving X
+3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
+4. Plan update order: interfaces → implementations → callers → tests
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklists
+
+### Rename Symbol
+
+```
+- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
+- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
+- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
+- [ ] gitnexus_detect_changes() — verify only expected files changed
+- [ ] Run tests for affected processes
+```
+
+### Extract Module / Service
+
+```
+- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
+- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
+- [ ] Define new module interface in GenHub.Core
+- [ ] Extract code, register in DependencyInjection module
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+### Split Function/Service
+
+```
+- [ ] gitnexus_context({name: target}) — understand all callees
+- [ ] Group callees by responsibility
+- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
+- [ ] Create new functions/services
+- [ ] Update callers
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+## Tools
+
+**gitnexus_rename** — automated multi-file rename:
+
+```
+gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true})
+→ 8 edits across 5 files
+→ 6 graph edits (high confidence), 2 ast_search edits (review)
+→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
+```
+
+**gitnexus_impact** — map all dependents first:
+
+```
+gitnexus_impact({target: "ContentReconciliationService", direction: "upstream"})
+→ d=1: GameLauncher, ProfileLauncherFacade, ReconciliationAuditLog
+→ Affected Processes: ProfileLaunchFlow, ProfileWorkspaceReconciliation
+```
+
+**gitnexus_detect_changes** — verify your changes after refactoring:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+→ Changed: 5 files, 8 symbols
+→ Affected processes: ProfileLaunchFlow, WorkspaceReconciliation
+→ Risk: MEDIUM
+```
+
+**gitnexus_cypher** — custom reference queries:
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"})
+RETURN caller.name, caller.filePath ORDER BY caller.filePath
+```
+
+## Risk Rules
+
+| Risk Factor | Mitigation |
+| ------------------- | ----------------------------------------- |
+| Many callers (>5) | Use gitnexus_rename for automated updates |
+| Cross-area refs | Use detect_changes after to verify scope |
+| Platform hosts | Verify composition in Windows, Linux, macOS |
+| External/public API | Check Result pattern contract and error codes |
+
+## Example: Rename `MaterializeFileAsync` to `DeployArtifactAsync`
+
+```
+1. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true})
+ → Preview edits across ICasService.cs, CasService.cs, ContentReconciliationService.cs, and tests
+
+2. Review changes to ensure all cross-platform composition roots and test mocks match
+
+3. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: false})
+ → Applied edits across core interfaces, implementation, and test suites
+
+4. gitnexus_detect_changes({scope: "staged"})
+ → Affected: ProfileLaunchFlow, WorkspaceReconciliation
+ → Risk: MEDIUM — run targeted tests (dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/...)
+```
diff --git a/.agents/skills/pull-request/SKILL.md b/.agents/skills/pull-request/SKILL.md
new file mode 100644
index 000000000..f1c85008f
--- /dev/null
+++ b/.agents/skills/pull-request/SKILL.md
@@ -0,0 +1,146 @@
+---
+name: pull-request
+description: "Prepares, validates, formats, and opens Pull Requests following repository standards. Use when asked to create a PR, prepare a pull request, open a PR for the current branch, or submit changes."
+---
+
+# Pull Request Creation & Lifecycle
+
+Follow this directed workflow to prepare, validate, format, and open pull requests.
+
+> [!IMPORTANT]
+> **Cardinal Rule:** Never create or open a pull request unless the developer explicitly asks you to do so.
+
+---
+
+## 1. Pre-Flight Checklist
+
+Before opening a PR, verify every item:
+
+- [ ] Explicit developer instruction received to create/open a PR
+- [ ] Working tree is clean with all changes committed (`git status`)
+- [ ] Single concern rule: The PR solves exactly ONE problem (no bundled unrelated refactors)
+- [ ] Branch name follows conventional naming:
+ - `feat/`
+ - `fix/`
+ - `chore/`
+ - `refactor/`
+- [ ] Targeted tests pass locally before pushing
+- [ ] UI changes include before/after screenshots or media recordings
+
+---
+
+## 2. Commit Message Standards
+
+Ensure all commits follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
+
+```
+():
+
+[optional body explaining motivation or context]
+```
+
+### Supported Types:
+- `feat`: New user-facing or architectural capability
+- `fix`: Bug fix
+- `chore`: Build scripts, dependencies, CI configuration, maintenance
+- `refactor`: Code change that neither fixes a bug nor adds a feature
+- `test`: Adding or correcting tests
+- `docs`: Documentation changes only
+- `perf`: Performance improvement
+
+---
+
+## 3. Pull Request Title & Description Template
+
+Construct the PR title and description using the standard template:
+
+### Title Format
+```
+():
+```
+*Example:* `fix(core): handle locked CAS files during background cleanup`
+
+### Body Template
+```markdown
+## Summary
+
+
+### Root Cause
+
+
+
+### Changes
+- ****:
+- ****:
+- ****:
+
+### Visual Verification
+
+- **Before**: ![Before screenshot]()
+- **After**: ![After screenshot]()
+
+### Verification
+- [x] Targeted unit/integration tests executed and passing
+- [x] Solution/project builds cleanly without new warnings or lint errors
+- [x] Verified cross-platform compatibility where applicable
+
+---
+*Created with via *
+```
+
+---
+
+## 4. Execution Workflow
+
+### Step 1: Detect Current Git Context
+```bash
+# Check current branch and uncommitted changes
+git status
+
+# Check outgoing commits against the target base branch (e.g., development or main)
+git log origin/development..HEAD --oneline
+```
+
+### Step 2: Push Current Branch
+```bash
+# Push branch to remote fork or origin
+git push -u origin HEAD
+```
+
+### Step 3: Open Pull Request via GitHub CLI
+```bash
+# Open PR targeting the base branch (default: development or main)
+gh pr create \
+ --base development \
+ --title "fix(scope): concise description" \
+ --body-file - << 'EOF_PR'
+## Summary
+Concise summary of what this PR achieves.
+
+### Root Cause
+Description of the underlying issue.
+
+### Changes
+- **Core**: Resolved entry point propagation during manifest creation
+- **UI**: Restored selection action buttons on data template
+- **Tests**: Added unit tests covering all supported variant types
+
+### Verification
+- [x] Targeted test suite passing
+- [x] Clean build with zero linter errors
+EOF_PR
+```
+
+### Step 4: Verify Created PR
+```bash
+# Output created PR details and web link to user
+gh pr view --json number,title,url,state,headRefName,baseRefName
+```
+
+---
+
+## 5. Next Steps: CI & Review Babysitting
+
+Once the pull request is opened:
+1. Provide the live PR URL to the developer.
+2. If requested to monitor or babysit, switch to the `babysit-pr` skill to track CI check-runs, inspect bot reviews, and resolve findings.
diff --git a/.claude/skills/babysit-pr/SKILL.md b/.claude/skills/babysit-pr/SKILL.md
new file mode 100644
index 000000000..67be95e99
--- /dev/null
+++ b/.claude/skills/babysit-pr/SKILL.md
@@ -0,0 +1,152 @@
+---
+name: babysit-pr
+description: "Monitors a PR until all CI checks finish, fixes test/build failures, and resolves all human and AI bot review comments in consolidated passes. Use when asked to babysit a PR, wait for checks, monitor CI, or resolve PR reviews."
+---
+
+# Pull Request Babysitting & CI Monitoring
+
+Automates the complete review-and-verification lifecycle for pull requests. Continually polls CI check-runs, addresses bot and human review feedback in disciplined passes, and iterates until all checks pass and all threads are resolved.
+
+> [!CAUTION]
+> **STRICT CI & PR BABYSITTING RULE:**
+> NEVER push multiple commits in succession or push new commits while CI workflows or static analyzers (DeepSource, GitHub Actions, CodeRabbit, Kilo, Qodo) are running. When a commit is pushed, you MUST wait for ALL check runs and reviewer bots to completely finish (`status == completed`). Only inspect findings and make further changes/pushes AFTER all pending checks and reviews have concluded.
+
+---
+
+## The Babysitting Lifecycle
+
+```
+ ┌────────────────────────────────────────────────────────┐
+ │ 1. Identify PR & Commit SHA │
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 2. Wait for CI & Bot Reviews to Complete │
+ │ (Poll check-runs until status == completed) │
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 3. Fetch All Findings & Review Comments │
+ │ (Inline threads, outside diff comments, bot reviews)│
+ └──────────────────────────┬─────────────────────────────┘
+ ▼
+ ┌────────────────────────────────────────────────────────┐
+ │ 4. Are there Failures or Unresolved Comments? │
+ └─────────────┬────────────────────────────┬─────────────┘
+ YES │ │ NO (All Green)
+ ▼ ▼
+ ┌───────────────────────────┐ ┌────────────────────────┐
+ │ 5. Single Consolidated │ │ 7. PR Fully Green! │
+ │ Pass: │ │ Report summary and │
+ │ - Fix code issues │ │ live PR link. │
+ │ - Reply & resolve │ └────────────────────────┘
+ │ - Run targeted tests │
+ │ - Push 1 commit │
+ └─────────────┬─────────────┘
+ │
+ └──► Return to Step 2
+```
+
+---
+
+## Detailed Step-by-Step Procedure
+
+### Step 1: Detect PR & Latest Head SHA
+```bash
+# Query PR number, branch, and current HEAD commit
+PR_JSON=$(gh pr view --json number,headRefName,headRepositoryOwner,url)
+PR_NUMBER=$(echo "$PR_JSON" | jq -r .number)
+REPO_OWNER=$(echo "$PR_JSON" | jq -r .headRepositoryOwner.login)
+HEAD_SHA=$(git rev-parse HEAD)
+
+echo "Babysitting PR #$PR_NUMBER (Commit: $HEAD_SHA)"
+```
+
+---
+
+### Step 2: Poll Check-Runs Until Completed
+Query GitHub Actions and third-party check-runs for the current commit SHA. Loop with scheduled waits until all checks reach `status == "completed"`.
+
+```bash
+# Check status of all check-runs on the current commit
+gh api repos/:owner/:repo/commits/$HEAD_SHA/check-runs \
+ --jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url}'
+```
+
+#### Evaluation Gates:
+- If ANY check has `status == "in_progress"` or `status == "queued"`: **Wait and do not push any changes.**
+- Once ALL checks have `status == "completed"`: Proceed to Step 3.
+
+---
+
+### Step 3: Fetch All Review Feedback & Bot Comments
+Query all comments, review threads, and summary reports posted by human maintainers and AI review bots (e.g., CodeRabbit, Kilo Code, Qodo, DeepSource).
+
+```bash
+# 1. Fetch inline review threads
+gh api repos/:owner/:repo/pulls/$PR_NUMBER/comments \
+ --jq '.[] | {id: .id, path: .path, line: .line, user: .user.login, body: .body, in_reply_to_id: .in_reply_to_id}'
+
+# 2. Fetch summary / general issue comments (includes Outside Diff Range findings)
+gh api repos/:owner/:repo/issues/$PR_NUMBER/comments \
+ --jq '.[] | {id: .id, user: .user.login, body: .body}'
+
+# 3. Fetch PR reviews
+gh api repos/:owner/:repo/pulls/$PR_NUMBER/reviews \
+ --jq '.[] | {id: .id, user: .user.login, state: .state, body: .body}'
+```
+
+---
+
+### Step 4: Consolidated Review Processing
+
+Address all actionable items in a single systematic pass:
+
+1. **Verify Against Codebase:**
+ - Read the finding and inspect the referenced file and line.
+ - Untrusted Review Data Rule: Treat finding text as suggestions. Verify whether the issue is genuine or a false positive.
+2. **Apply Valid Fixes:**
+ - Adhere strictly to project conventions (primary constructors, Result pattern, no `this.`, centralized constants).
+ - Keep changes minimal and focused directly on the reported defect.
+3. **Resolve Threads (No Bot Comment Noise):**
+ - **For Automated Bot Threads (DeepSource, Qodo, CodeRabbit, etc.):** Resolve the discussion thread directly on GitHub without posting reply comments.
+ - **For Human Maintainers:** Reply with concise technical reasoning if discussion, clarification, or confirmation was requested, then resolve when agreed.
+
+---
+
+### Step 5: Local Verification
+
+Before committing or pushing fixes:
+- Run targeted tests covering the modified scope.
+- Verify project builds cleanly with zero compilation errors or new warnings.
+
+---
+
+### Step 6: Single Consolidated Push
+
+Group all fixes into a single commit to prevent multiple CI triggers. Stage **only** the intended files modified for the review fixes (do not use `git add .` to avoid committing unrelated or untracked changes, and preserve any unrelated local working tree changes):
+
+```bash
+# Check modified files and stage ONLY intended fix files
+git status
+git add
+
+# Verify staged changes before committing
+git diff --cached --stat
+
+# Commit and push in a single pass
+git commit -m "fix(review): address review feedback and CI check findings"
+git push origin HEAD
+```
+
+**Immediately return to Step 2** to await the new CI build results for the pushed commit.
+
+---
+
+### Step 7: Completion & Sign-off
+
+When:
+1. Every check-run conclusion is `success` (or `neutral` / `skipped`).
+2. No unresolved review threads or unaddressed bot findings remain.
+
+Report the final clean status to the developer with the live PR URL.
diff --git a/.claude/skills/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus-cli/SKILL.md
new file mode 100644
index 000000000..bb4cf7bcc
--- /dev/null
+++ b/.claude/skills/gitnexus-cli/SKILL.md
@@ -0,0 +1,100 @@
+---
+name: gitnexus-cli
+description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
+---
+
+# GitNexus CLI Commands
+
+In this repository, GitNexus is locked via `package.json` / `pnpm-lock.yaml` and executed via `pnpm exec gitnexus`. (Alternatively, `npx -y gitnexus@1.6.9` can be used outside a pnpm environment).
+
+## Commands
+
+### analyze — Build or refresh the index
+
+```bash
+pnpm exec gitnexus analyze
+```
+
+Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates AGENTS.md / AGENTS.md context files.
+
+| Flag | Effect |
+| -------------- | ---------------------------------------------------------------- |
+| `--force` | Force full re-index even if up to date |
+| `--index-only` | Build graph without regenerating context files |
+| `--embeddings` | Enable embedding generation for semantic search (off by default) |
+
+**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Codex, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
+
+### status — Check index freshness
+
+```bash
+pnpm exec gitnexus status
+```
+
+Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
+
+### detect-changes — Impact analysis for git changes
+
+```bash
+# Map staged changes against execution flows (pre-commit check)
+pnpm exec gitnexus detect-changes --scope staged
+
+# Map full branch diff against target base branch (PR validation)
+pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development
+```
+
+| Flag | Effect |
+| ----------------------- | --------------------------------------------------- |
+| `--scope staged` | Analyze staged git changes (recommended pre-commit) |
+| `--scope compare` | Compare current branch against `--base-ref` |
+| `--base-ref ][` | Base reference branch or SHA to compare against |
+| `--scope working` | Analyze unstaged working tree changes (default) |
+
+### clean — Delete the index
+
+```bash
+pnpm exec gitnexus clean
+```
+
+Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
+
+| Flag | Effect |
+| --------- | ------------------------------------------------- |
+| `--force` | Skip confirmation prompt |
+| `--all` | Clean all indexed repos, not just the current one |
+
+### wiki — Generate documentation from the graph
+
+```bash
+pnpm exec gitnexus wiki
+```
+
+Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
+
+| Flag | Effect |
+| ------------------- | ----------------------------------------- |
+| `--force` | Force full regeneration |
+| `--model ` | LLM model (default: minimax/minimax-m2.5) |
+| `--base-url ` | LLM API base URL |
+| `--api-key ` | LLM API key |
+| `--concurrency ` | Parallel LLM calls (default: 3) |
+| `--gist` | Publish wiki as a public GitHub Gist |
+
+### list — Show all indexed repos
+
+```bash
+pnpm exec gitnexus list
+```
+
+Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
+
+## After Indexing
+
+1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
+2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
+
+## Troubleshooting
+
+- **"Not inside a git repository"**: Run from a directory inside a git repo
+- **Index is stale after re-analyzing**: Restart Codex to reload the MCP server
+- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
diff --git a/.claude/skills/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus-debugging/SKILL.md
new file mode 100644
index 000000000..01630721d
--- /dev/null
+++ b/.claude/skills/gitnexus-debugging/SKILL.md
@@ -0,0 +1,89 @@
+---
+name: gitnexus-debugging
+description: "Use when debugging a bug, tracing an error, or investigating unexpected behavior in GenHub (e.g. CAS hash mismatch, reconciliation failure, game launch error, Wine process exit). Examples: \"Why is CasService failing to materialize files?\", \"Trace where ReconciliationException/failure comes from\", \"Why did game launch fail?\""
+---
+
+# Debugging with GitNexus
+
+## When to Use
+
+- "Why is `CasService.MaterializeFileAsync` failing?"
+- "Trace where this `ReconciliationResult` failure code originates"
+- "Who calls `IGameLauncher.LaunchAsync` and how are errors handled?"
+- "Wine process exits immediately with code 1 during launch"
+- Investigating profile reconciliation, CAS indexing, or platform runner failures
+
+## Workflow
+
+```
+1. gitnexus_query({query: ""}) → Find related execution flows
+2. gitnexus_context({name: ""}) → See callers/callees/processes
+3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
+4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] Understand the symptom (error message, unexpected behavior, Result failure code)
+- [ ] gitnexus_query for error text, domain constants, or related code
+- [ ] Identify the suspect function or service from returned processes
+- [ ] gitnexus_context to see callers and callees
+- [ ] Trace execution flow via process resource if applicable
+- [ ] gitnexus_cypher for custom call chain traces if needed
+- [ ] Read source files to confirm root cause
+```
+
+## Debugging Patterns
+
+| Symptom | GitNexus Approach |
+| -------------------- | ---------------------------------------------------------- |
+| Error message / Result code | `gitnexus_query` for error text / constant → `context` on failure sites |
+| Wrong return value | `context` on the method → trace callees for data flow |
+| Intermittent failure | `context` → look for external I/O, file locks, async dependencies |
+| Performance issue | `context` → find symbols with many callers (hot paths like hashing) |
+| Recent regression | `detect_changes` to see what your changes affect |
+
+## Tools
+
+**gitnexus_query** — find code and execution flows related to an error or symptom:
+
+```
+gitnexus_query({query: "CAS hash mismatch materialization"})
+→ Processes: WorkspaceReconciliationFlow, CasPoolIngestion
+→ Symbols: CasService, ContentReconciliationService, CasHashMismatch
+```
+
+**gitnexus_context** — full context for a suspect symbol:
+
+```
+gitnexus_context({name: "ReconcileAsync"})
+→ Incoming calls: GameLauncher.LaunchAsync, ProfileEditorFacade.ApplyProfile
+→ Outgoing calls: CasService.MaterializeFileAsync, ManifestVerificationService.Verify
+→ Processes: ProfileLaunchFlow (step 2/5)
+```
+
+**gitnexus_cypher** — custom call chain traces:
+
+```cypher
+MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Method {name: "MaterializeFileAsync"})
+RETURN [n IN nodes(path) | n.name] AS chain
+```
+
+## Example: "Game launch fails during profile workspace reconciliation"
+
+```
+1. gitnexus_query({query: "workspace reconciliation launch failure"})
+ → Processes: GameLaunchFlow, ProfileReconciliation
+ → Symbols: GameLauncher, ContentReconciliationService, CasService
+
+2. gitnexus_context({name: "GameLauncher.LaunchAsync"})
+ → Outgoing calls: ContentReconciliationService.ReconcileAsync, IGameProcessManager.StartAsync
+
+3. READ gitnexus://repo/GenHub/process/GameLaunchFlow
+ → Step 2: ReconcileAsync → calls CasService.MaterializeFileAsync
+
+4. Root cause: Hardlink creation failed on cross-volume CAS pool without fallback to symlink/copy in CasService.
+```
diff --git a/.claude/skills/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus-exploring/SKILL.md
new file mode 100644
index 000000000..55b37192e
--- /dev/null
+++ b/.claude/skills/gitnexus-exploring/SKILL.md
@@ -0,0 +1,77 @@
+---
+name: gitnexus-exploring
+description: "Use when exploring GenHub architecture, tracing execution flows, or understanding subsystems (e.g. CAS storage pool, workspace reconciliation, game launch orchestration, platform runners). Examples: \"How does CAS materialization work?\", \"Show me the game launch flow\", \"How does GenHub detect game installations?\""
+---
+
+# Exploring Codebases with GitNexus
+
+## When to Use
+
+- "How does Content-Addressable Storage (CAS) deduplicate game assets?"
+- "What is the workspace reconciliation lifecycle?"
+- "Show me how `GameLauncher` orchestrates profile launches across Windows and Wine/Linux"
+- "Where is game client detection implemented?"
+- Understanding subsystems you haven't worked with before
+
+## Workflow
+
+```
+1. READ gitnexus://repos → Discover indexed repos
+2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
+3. gitnexus_query({query: ""}) → Find related execution flows
+4. gitnexus_context({name: ""}) → Deep dive on specific symbol
+5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
+```
+
+> If step 2 says "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] READ gitnexus://repo/{name}/context
+- [ ] gitnexus_query for the concept you want to understand
+- [ ] Review returned processes (execution flows)
+- [ ] gitnexus_context on key symbols for callers/callees
+- [ ] READ process resource for full execution traces
+- [ ] Read source files for implementation details
+```
+
+## Resources
+
+| Resource | What you get |
+| --------------------------------------- | ------------------------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
+| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
+| `gitnexus://repo/{name}/process/{name}` | Step-by-step trace |
+
+## Tools
+
+**gitnexus_query** — find execution flows related to a concept:
+
+```
+gitnexus_query({query: "profile workspace reconciliation"})
+→ Processes: ProfileLaunchFlow, ContentReconciliation, CasPoolIngestion
+→ Symbols grouped by flow (ContentReconciliationService, CasService, ManifestResolver)
+```
+
+**gitnexus_context** — 360-degree view of a symbol:
+
+```
+gitnexus_context({name: "CasService"})
+→ Incoming calls: ContentReconciliationService, InstallationCasPoolService
+→ Outgoing calls: FileHashProvider, StorageLocationService
+→ Processes: ProfileLaunchFlow (step 2/5), ModInstallationFlow (step 3/4)
+```
+
+## Example: "How does profile launch and workspace reconciliation work?"
+
+```
+1. READ gitnexus://repo/GenHub/context → C# .NET 8 desktop engine, CAS storage, multi-platform runners
+2. gitnexus_query({query: "profile launch reconciliation"})
+ → ProfileLaunchFlow: ProfileLauncherFacade.LaunchProfileAsync → ContentReconciliationService.ReconcileAsync → WineGameProcessManager.StartAsync
+3. gitnexus_context({name: "ContentReconciliationService"})
+ → Incoming: GameLauncher, ProfileLauncherFacade
+ → Outgoing: CasService.MaterializeFileAsync, ManifestVerificationService.Verify
+4. Read GenHub/GenHub.Core/Features/Content/ContentReconciliationService.cs for implementation details
+```
diff --git a/.claude/skills/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus-guide/SKILL.md
new file mode 100644
index 000000000..d2743d9e8
--- /dev/null
+++ b/.claude/skills/gitnexus-guide/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: gitnexus-guide
+description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
+---
+
+# GitNexus Guide
+
+Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
+
+## Always Start Here
+
+For any task involving code understanding, debugging, impact analysis, or refactoring:
+
+1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
+2. **Match your task to a skill below** and **read that skill file**
+3. **Follow the skill's workflow and checklist**
+
+> If step 1 warns the index is stale, run `pnpm exec gitnexus analyze` in the terminal first.
+
+## Skills
+
+| Task | Skill to read |
+| -------------------------------------------- | ------------------- |
+| Understand architecture / "How does X work?" | `gitnexus-exploring` |
+| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
+| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
+| Rename / extract / split / refactor | `gitnexus-refactoring` |
+| Tools, resources, schema reference | `gitnexus-guide` (this file) |
+| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
+
+## Tools Reference
+
+| Tool | What it gives you |
+| ---------------- | ------------------------------------------------------------------------ |
+| `query` | Process-grouped code intelligence — execution flows related to a concept |
+| `context` | 360-degree symbol view — categorized refs, processes it participates in |
+| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
+| `detect_changes` | Git-diff impact — what do your current changes affect |
+| `rename` | Multi-file coordinated rename with confidence-tagged edits |
+| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
+| `list_repos` | Discover indexed repos |
+
+## Resources Reference
+
+Lightweight reads (~100-500 tokens) for navigation:
+
+| Resource | Content |
+| ---------------------------------------------- | ----------------------------------------- |
+| `gitnexus://repo/{name}/context` | Stats, staleness check |
+| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
+| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
+| `gitnexus://repo/{name}/processes` | All execution flows |
+| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
+| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
+
+## Graph Schema
+
+**Nodes:** File, Function, Class, Interface, Method, Community, Process
+**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"})
+RETURN caller.name, caller.filePath
+```
diff --git a/.claude/skills/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus-impact-analysis/SKILL.md
new file mode 100644
index 000000000..58e015db8
--- /dev/null
+++ b/.claude/skills/gitnexus-impact-analysis/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: gitnexus-impact-analysis
+description: "Use when analyzing blast radius or safety before modifying core GenHub symbols/interfaces (e.g. ICasService, IContentReconciliationService, IGameLauncher). Examples: \"Is it safe to change ICasService?\", \"What depends on ContentReconciliationService?\", \"What will break if I modify GameLauncher?\""
+---
+
+# Impact Analysis with GitNexus
+
+## When to Use
+
+- "Is it safe to modify `ICasService` method signatures?"
+- "What will break if I change `IContentReconciliationService.ReconcileAsync`?"
+- "Show me the blast radius of modifying `IGameProcessManager` across Windows, Linux, and macOS hosts"
+- "Who uses this code?"
+- Before making non-trivial code changes to core abstractions
+- Before committing — to understand what your changes affect
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
+2. READ gitnexus://repo/{name}/processes → Check affected execution flows
+3. gitnexus_detect_changes() → Map current git changes to affected flows
+4. Assess risk and report to user
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklist
+
+```
+- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
+- [ ] Review d=1 items first (these WILL BREAK)
+- [ ] Check high-confidence (>0.8) dependencies
+- [ ] READ processes to check affected execution flows
+- [ ] gitnexus_detect_changes() for pre-commit check
+- [ ] Assess risk level and report to user
+```
+
+## Understanding Output
+
+| Depth | Risk Level | Meaning |
+| ----- | ---------------- | ------------------------ |
+| d=1 | **WILL BREAK** | Direct callers/importers |
+| d=2 | LIKELY AFFECTED | Indirect dependencies |
+| d=3 | MAY NEED TESTING | Transitive effects |
+
+## Risk Assessment
+
+| Affected | Risk |
+| ------------------------------ | -------- |
+| <5 symbols, few processes | LOW |
+| 5-15 symbols, 2-5 processes | MEDIUM |
+| >15 symbols or many processes | HIGH |
+| Critical path (CAS, launcher, reconciliation, platform runners) | CRITICAL |
+
+## Tools
+
+**gitnexus_impact** — the primary tool for symbol blast radius:
+
+```
+gitnexus_impact({
+ target: "ICasService",
+ direction: "upstream",
+ minConfidence: 0.8,
+ maxDepth: 3
+})
+
+→ d=1 (WILL BREAK):
+ - CasService (GenHub/Services/CasService.cs) [IMPLEMENTS, 100%]
+ - ContentReconciliationService (GenHub/Features/Content/ContentReconciliationService.cs) [CALLS, 100%]
+ - InstallationCasPoolService (GenHub.Core/Features/Storage/InstallationCasPoolService.cs) [CALLS, 100%]
+
+→ d=2 (LIKELY AFFECTED):
+ - GameLauncher (GenHub/Features/Launching/GameLauncher.cs) [CALLS, 95%]
+ - ProfileEditorFacade (GenHub/Features/GameProfiles/ProfileEditorFacade.cs) [CALLS, 90%]
+```
+
+**gitnexus_detect_changes** — git-diff based impact analysis:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+
+→ Changed: 3 symbols in CasService.cs, ICasService.cs
+→ Affected: ProfileLaunchFlow, ContentReconciliationFlow, CasPoolIngestion
+→ Risk: HIGH
+```
+
+## Example: "What breaks if I change ICasService?"
+
+```
+1. gitnexus_impact({target: "ICasService", direction: "upstream"})
+ → d=1: CasService, ContentReconciliationService, InstallationCasPoolService (WILL BREAK)
+ → d=2: GameLauncher, ProfileLauncherFacade (LIKELY AFFECTED)
+
+2. READ gitnexus://repo/GenHub/processes
+ → ProfileLaunchFlow and ModInstallationFlow depend on ICasService
+
+3. Risk: 3 direct dependents, 2 core execution flows = HIGH (Verify callers across Windows, Linux, macOS hosts)
+```
diff --git a/.claude/skills/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus-refactoring/SKILL.md
new file mode 100644
index 000000000..ec76c6756
--- /dev/null
+++ b/.claude/skills/gitnexus-refactoring/SKILL.md
@@ -0,0 +1,120 @@
+---
+name: gitnexus-refactoring
+description: "Use when renaming, extracting, splitting, moving, or refactoring code in GenHub safely. Examples: \"Rename ICasStorage method\", \"Extract manifest parser from ContentResolver\", \"Refactor ContentReconciliationService\", \"Split GameLauncher hooks\""
+---
+
+# Refactoring with GitNexus
+
+## When to Use
+
+- "Rename a method on `ICasService` or `IContentReconciliationService` safely"
+- "Extract a CAS pool verification service from `CasService`"
+- "Split platform-specific process launch logic from `GameLauncher`"
+- "Move reconciliation audit helpers to a dedicated service"
+- Any task involving renaming, extracting, splitting, or restructuring code
+
+## Workflow
+
+```
+1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
+2. gitnexus_query({query: "X"}) → Find execution flows involving X
+3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
+4. Plan update order: interfaces → implementations → callers → tests
+```
+
+> If "Index is stale" → run `pnpm exec gitnexus analyze` in terminal.
+
+## Checklists
+
+### Rename Symbol
+
+```
+- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
+- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
+- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
+- [ ] gitnexus_detect_changes() — verify only expected files changed
+- [ ] Run tests for affected processes
+```
+
+### Extract Module / Service
+
+```
+- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
+- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
+- [ ] Define new module interface in GenHub.Core
+- [ ] Extract code, register in DependencyInjection module
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+### Split Function/Service
+
+```
+- [ ] gitnexus_context({name: target}) — understand all callees
+- [ ] Group callees by responsibility
+- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
+- [ ] Create new functions/services
+- [ ] Update callers
+- [ ] gitnexus_detect_changes() — verify affected scope
+- [ ] Run tests for affected processes
+```
+
+## Tools
+
+**gitnexus_rename** — automated multi-file rename:
+
+```
+gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true})
+→ 8 edits across 5 files
+→ 6 graph edits (high confidence), 2 ast_search edits (review)
+→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
+```
+
+**gitnexus_impact** — map all dependents first:
+
+```
+gitnexus_impact({target: "ContentReconciliationService", direction: "upstream"})
+→ d=1: GameLauncher, ProfileLauncherFacade, ReconciliationAuditLog
+→ Affected Processes: ProfileLaunchFlow, ProfileWorkspaceReconciliation
+```
+
+**gitnexus_detect_changes** — verify your changes after refactoring:
+
+```
+gitnexus_detect_changes({scope: "staged"})
+→ Changed: 5 files, 8 symbols
+→ Affected processes: ProfileLaunchFlow, WorkspaceReconciliation
+→ Risk: MEDIUM
+```
+
+**gitnexus_cypher** — custom reference queries:
+
+```cypher
+MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(m:Method {name: "ReconcileAsync"})
+RETURN caller.name, caller.filePath ORDER BY caller.filePath
+```
+
+## Risk Rules
+
+| Risk Factor | Mitigation |
+| ------------------- | ----------------------------------------- |
+| Many callers (>5) | Use gitnexus_rename for automated updates |
+| Cross-area refs | Use detect_changes after to verify scope |
+| Platform hosts | Verify composition in Windows, Linux, macOS |
+| External/public API | Check Result pattern contract and error codes |
+
+## Example: Rename `MaterializeFileAsync` to `DeployArtifactAsync`
+
+```
+1. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: true})
+ → Preview edits across ICasService.cs, CasService.cs, ContentReconciliationService.cs, and tests
+
+2. Review changes to ensure all cross-platform composition roots and test mocks match
+
+3. gitnexus_rename({symbol_name: "MaterializeFileAsync", new_name: "DeployArtifactAsync", dry_run: false})
+ → Applied edits across core interfaces, implementation, and test suites
+
+4. gitnexus_detect_changes({scope: "staged"})
+ → Affected: ProfileLaunchFlow, WorkspaceReconciliation
+ → Risk: MEDIUM — run targeted tests (dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/...)
+```
diff --git a/.claude/skills/pull-request/SKILL.md b/.claude/skills/pull-request/SKILL.md
new file mode 100644
index 000000000..f1c85008f
--- /dev/null
+++ b/.claude/skills/pull-request/SKILL.md
@@ -0,0 +1,146 @@
+---
+name: pull-request
+description: "Prepares, validates, formats, and opens Pull Requests following repository standards. Use when asked to create a PR, prepare a pull request, open a PR for the current branch, or submit changes."
+---
+
+# Pull Request Creation & Lifecycle
+
+Follow this directed workflow to prepare, validate, format, and open pull requests.
+
+> [!IMPORTANT]
+> **Cardinal Rule:** Never create or open a pull request unless the developer explicitly asks you to do so.
+
+---
+
+## 1. Pre-Flight Checklist
+
+Before opening a PR, verify every item:
+
+- [ ] Explicit developer instruction received to create/open a PR
+- [ ] Working tree is clean with all changes committed (`git status`)
+- [ ] Single concern rule: The PR solves exactly ONE problem (no bundled unrelated refactors)
+- [ ] Branch name follows conventional naming:
+ - `feat/`
+ - `fix/`
+ - `chore/`
+ - `refactor/`
+- [ ] Targeted tests pass locally before pushing
+- [ ] UI changes include before/after screenshots or media recordings
+
+---
+
+## 2. Commit Message Standards
+
+Ensure all commits follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
+
+```
+():
+
+[optional body explaining motivation or context]
+```
+
+### Supported Types:
+- `feat`: New user-facing or architectural capability
+- `fix`: Bug fix
+- `chore`: Build scripts, dependencies, CI configuration, maintenance
+- `refactor`: Code change that neither fixes a bug nor adds a feature
+- `test`: Adding or correcting tests
+- `docs`: Documentation changes only
+- `perf`: Performance improvement
+
+---
+
+## 3. Pull Request Title & Description Template
+
+Construct the PR title and description using the standard template:
+
+### Title Format
+```
+():
+```
+*Example:* `fix(core): handle locked CAS files during background cleanup`
+
+### Body Template
+```markdown
+## Summary
+
+
+### Root Cause
+
+
+
+### Changes
+- ****:
+- ****:
+- ****:
+
+### Visual Verification
+
+- **Before**: ![Before screenshot]()
+- **After**: ![After screenshot]()
+
+### Verification
+- [x] Targeted unit/integration tests executed and passing
+- [x] Solution/project builds cleanly without new warnings or lint errors
+- [x] Verified cross-platform compatibility where applicable
+
+---
+*Created with via *
+```
+
+---
+
+## 4. Execution Workflow
+
+### Step 1: Detect Current Git Context
+```bash
+# Check current branch and uncommitted changes
+git status
+
+# Check outgoing commits against the target base branch (e.g., development or main)
+git log origin/development..HEAD --oneline
+```
+
+### Step 2: Push Current Branch
+```bash
+# Push branch to remote fork or origin
+git push -u origin HEAD
+```
+
+### Step 3: Open Pull Request via GitHub CLI
+```bash
+# Open PR targeting the base branch (default: development or main)
+gh pr create \
+ --base development \
+ --title "fix(scope): concise description" \
+ --body-file - << 'EOF_PR'
+## Summary
+Concise summary of what this PR achieves.
+
+### Root Cause
+Description of the underlying issue.
+
+### Changes
+- **Core**: Resolved entry point propagation during manifest creation
+- **UI**: Restored selection action buttons on data template
+- **Tests**: Added unit tests covering all supported variant types
+
+### Verification
+- [x] Targeted test suite passing
+- [x] Clean build with zero linter errors
+EOF_PR
+```
+
+### Step 4: Verify Created PR
+```bash
+# Output created PR details and web link to user
+gh pr view --json number,title,url,state,headRefName,baseRefName
+```
+
+---
+
+## 5. Next Steps: CI & Review Babysitting
+
+Once the pull request is opened:
+1. Provide the live PR URL to the developer.
+2. If requested to monitor or babysit, switch to the `babysit-pr` skill to track CI check-runs, inspect bot reviews, and resolve findings.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 323290891..2951d1ad6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -84,6 +84,8 @@ jobs:
- '**/*.axaml'
- '**/*.csproj'
- '**/*.sln'
+ - '**/*.props'
+ - '**/*.targets'
- '.github/workflows/**'
- name: Changes Summary
@@ -122,16 +124,18 @@ jobs:
id: buildinfo
shell: pwsh
run: |
- $shortHash = "${{ github.sha }}".Substring(0, 7)
$prNumber = "${{ github.event.pull_request.number }}"
$runNumber = "${{ github.run_number }}"
+ $headSha = "${{ github.event.pull_request.head.sha }}"
# Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH)
# Using 0.0.X format to indicate alpha/pre-release status
- if ($prNumber) {
+ if ($prNumber -and $headSha) {
+ $shortHash = $headSha.Substring(0, 7)
$version = "0.0.$runNumber-pr$prNumber"
$channel = "PR"
} else {
+ $shortHash = "${{ github.sha }}".Substring(0, 7)
$version = "0.0.$runNumber"
$channel = "CI"
}
@@ -264,11 +268,6 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- - name: Install Linux Dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libx11-dev
-
- name: Cache NuGet Packages
uses: actions/cache@v3
with:
@@ -280,16 +279,18 @@ jobs:
- name: Extract Build Info
id: buildinfo
run: |
- SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7)
PR_NUMBER="${{ github.event.pull_request.number }}"
RUN_NUMBER="${{ github.run_number }}"
+ HEAD_SHA="${{ github.event.pull_request.head.sha }}"
# Velopack requires SemVer2 3-part version (MAJOR.MINOR.PATCH)
# Using 0.0.X format to indicate alpha/pre-release status
- if [ -n "$PR_NUMBER" ]; then
+ if [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ]; then
+ SHORT_HASH=$(echo "$HEAD_SHA" | cut -c1-7)
VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}"
CHANNEL="PR"
else
+ SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7)
VERSION="0.0.${RUN_NUMBER}"
CHANNEL="CI"
fi
@@ -405,14 +406,16 @@ jobs:
- name: Extract Build Info
id: buildinfo
run: |
- SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7)
PR_NUMBER="${{ github.event.pull_request.number }}"
RUN_NUMBER="${{ github.run_number }}"
+ HEAD_SHA="${{ github.event.pull_request.head.sha }}"
- if [ -n "$PR_NUMBER" ]; then
+ if [ -n "$PR_NUMBER" ] && [ -n "$HEAD_SHA" ]; then
+ SHORT_HASH=$(echo "$HEAD_SHA" | cut -c1-7)
VERSION="0.0.${RUN_NUMBER}-pr${PR_NUMBER}"
CHANNEL="PR"
else
+ SHORT_HASH=$(echo "${{ github.sha }}" | cut -c1-7)
VERSION="0.0.${RUN_NUMBER}"
CHANNEL="CI"
fi
@@ -540,20 +543,99 @@ jobs:
if-no-files-found: ignore
retention-days: 7
+ # Launches the real native engine with no game data at all and requires the abort the
+ # engine is known to produce (exit code 1 once INI loading finds nothing to read). No
+ # licensed retail content is involved; what this covers is everything before that
+ # point — the binary loads, its dylibs resolve, and startup fails fast instead of
+ # hanging. No other job executes the native launch path at all.
+ engine-launch-smoke:
+ name: Engine Launch Smoke Test
+ needs: detect-changes
+ if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' }}
+ # macos-14 and newer are Apple Silicon, matching the arm64 engine asset.
+ runs-on: macos-15
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}
+
+ - name: Cache NuGet Packages
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
+
+ # `latest-bgfx` is a moving tag, republished as the engine advances. Deliberate
+ # tradeoff: this job tracks the current engine build rather than pinning a
+ # reproducible one, so an engine regression surfaces here first.
+ - name: Download Native Engine
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ gh release download latest-bgfx \
+ --repo bobtista/GeneralsGameCode \
+ --pattern 'GeneralsZH-macos-arm64.zip' \
+ --dir engine-download
+ # The archive wraps everything in a GeneralsZH-macos-arm64/ directory, so it is
+ # staged and the wrapper's contents lifted out. Not `unzip -j`: that would also
+ # flatten Data/INI/, which the engine reads by path.
+ unzip -q engine-download/GeneralsZH-macos-arm64.zip -d engine-staging
+ root="$(find engine-staging -mindepth 1 -maxdepth 1 -type d)"
+ if [ ! -f "$root/generalszh" ]; then
+ echo "::error::generalszh not found in the release archive; contents were:"
+ find engine-staging -maxdepth 2
+ exit 1
+ fi
+ mv "$root" native-client
+ chmod +x native-client/generalszh
+ ls -l native-client
+
+ # GENHUB_REQUIRE_NATIVE_SMOKE turns "no client found" from a silent skip into a
+ # failure. Without it a broken download would leave the test skipping and this
+ # job permanently, meaninglessly green.
+ - name: Run Engine Launch Smoke Test
+ env:
+ GENHUB_NATIVE_CLIENT_DIR: ${{ github.workspace }}/native-client
+ GENHUB_REQUIRE_NATIVE_SMOKE: '1'
+ run: |
+ dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj \
+ -c ${{ env.BUILD_CONFIGURATION }} \
+ --filter "FullyQualifiedName~EngineLaunchSmokeTests" \
+ --verbosity normal
+
summary:
name: Build Summary
- needs: [build-windows, build-linux, build-macos]
+ # detect-changes is in `needs` so its own failure reaches the gate below. Without it a
+ # failed detect-changes skips every build, and all-skipped reads as a clean pass.
+ needs: [detect-changes, build-windows, build-linux, build-macos, engine-launch-smoke]
if: always()
runs-on: ubuntu-latest
steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
+ # A job skipped by detect-changes gating is a legitimate outcome, rendered as
+ # such rather than as a failure.
- name: Generate Summary
run: |
echo "### 🚀 GenHub Build Results" >> $GITHUB_STEP_SUMMARY
echo "| Platform | Status |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
- echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
- echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
- echo "| macOS | ${{ needs.build-macos.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Windows | ${{ needs.build-windows.result == 'success' && '✅ Passed' || needs.build-windows.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Linux | ${{ needs.build-linux.result == 'success' && '✅ Passed' || needs.build-linux.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| macOS | ${{ needs.build-macos.result == 'success' && '✅ Passed' || needs.build-macos.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Engine Smoke | ${{ needs.engine-launch-smoke.result == 'success' && '✅ Passed' || needs.engine-launch-smoke.result == 'skipped' && '⏭️ Skipped' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
+
+ # Turns the summary into a real gate: branch protection can require this one
+ # check and a failed or cancelled job anywhere in `needs` blocks the merge.
+ # Skipped jobs pass — being gated off by detect-changes is not a failure.
+ - name: Fail when a required job failed
+ if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
+ run: exit 1
diff --git a/.github/workflows/gitnexus.yml b/.github/workflows/gitnexus.yml
new file mode 100644
index 000000000..f064dada7
--- /dev/null
+++ b/.github/workflows/gitnexus.yml
@@ -0,0 +1,86 @@
+name: GitNexus Graph Index & Artifact
+
+permissions:
+ contents: read
+
+on:
+ push:
+ branches: [development, main, 'release/**']
+ pull_request:
+ branches: [development, main, 'release/**']
+ workflow_dispatch:
+
+# A release branch is normally the head of an open release PR, so a push to it would otherwise
+# start a second workflow run under a different key. Same-repository pull requests therefore share
+# a group with pushes describing the same branch, and collapse.
+#
+# Only same-repository heads are keyed by branch name. A fork's head ref is contributor-controlled,
+# so keying on it unconditionally would let a fork branch named `development`, `main` or
+# `release/*` land in a protected branch's group — and because cancel-in-progress is on for pull
+# requests, each push to that PR would cancel the protected branch's running CI. Fork pull requests
+# fall back to github.ref_name, which is `/merge` and therefore unique per PR.
+concurrency:
+ group: >-
+ ${{ github.workflow }}-${{
+ github.event.pull_request.head.repo.full_name == github.repository
+ && github.event.pull_request.head.ref
+ || github.ref_name
+ }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ index:
+ name: GitNexus Index & Artifact
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
+
+ - name: Install Dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Analyze Codebase
+ run: |
+ pnpm exec gitnexus analyze --force --index-only
+ pnpm exec gitnexus status
+
+ - name: Upload GitNexus Knowledge Graph Artifact
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: gitnexus-graph-${{ github.sha }}
+ path: .gitnexus/
+ include-hidden-files: true
+ if-no-files-found: warn
+ retention-days: 14
+ overwrite: true
+
+ - name: PR Impact Analysis (Informational)
+ if: github.event_name == 'pull_request'
+ shell: bash
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ echo "### 🔍 GitNexus Blast Radius & Change Impact (Informational)" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ set +e
+ pnpm exec gitnexus detect-changes --scope compare --base-ref "$BASE_SHA" 2>&1 | tee -a $GITHUB_STEP_SUMMARY
+ EXIT_CODE=${PIPESTATUS[0]}
+ set -e
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ exit $EXIT_CODE
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 89588dd19..8d424e2c1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -234,9 +234,6 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- - name: Install Linux Dependencies
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev
-
- name: Extract Build Info
id: buildinfo
run: |
diff --git a/.gitignore b/.gitignore
index 81f0c8471..395b2bb14 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,8 @@
.*
!.github/
!.gitignore
+!.agents/
+!.claude/
*.suo
*.user
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..3b56759c5
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,169 @@
+# GenHub
+
+GenHub is a high-performance, cross-platform launcher, profile manager, mod organizer, and content distribution platform for Command & Conquer: Generals and Zero Hour. An Avalonia UI desktop application sits on top of a pure .NET 8 core engine with Content-Addressable Storage (CAS), atomic workspace reconciliation, and multi-source distribution.
+
+You can think of GenHub as the modern, open source, cross-platform ecosystem replacement for legacy GenLauncher and manual game/mod installations.
+
+## What makes GenHub special?
+
+GenHub serves a vibrant, global Command & Conquer community across multiple operating systems. As we iterate on the codebase, we never compromise on these core pillars:
+
+### 1. Content-Addressable Storage (CAS) & Zero-Copy Workspaces
+
+We do not copy multi-gigabyte game directories or duplicate mod files. Game assets and content patches are indexed by cryptographic hash in a shared CAS pool, then hardlinked, symlinked, or atomically materialized into isolated workspaces. Switching complex mods or profiles must happen in milliseconds.
+
+### 2. Multi-platform at the core
+
+Generals was a 2003 Win32 DirectX 8 title. GenHub makes it first-class on modern **Windows**, **Linux** (Wine/Proton), and **macOS** (Wine/CrossOver/native runners). Platform-specific logic (registry lookups, shortcut generation, desktop entries, macOS quarantine `xattr` removal) is strictly isolated inside platform composition hosts, keeping core services portable.
+
+### 3. Shared `development` branch & zero regressions
+
+Every contributor and agent targets the `development` branch. Because changes to core services (storage, reconciliation, manifests, game detectors) ripple across multiple platforms and UI bindings, we do not tolerate blind edits or speculative refactors that break downstream consumers.
+
+### 4. Deterministic architecture & Result pattern
+
+No hidden exceptions for control flow. Operations that can fail (missing files, network drops, checksum mismatches, launch errors) return strongly typed `OperationResult` records. Constants are centralized, constructors are primary, and code is clean, maintainable, and verifiable.
+
+## A note from the maintainers
+
+We like ambitious ideas, simple systems, and software that feels obvious. Do not preserve complexity just because it already exists. Do not introduce machinery because it looks architecturally impressive. Understand the real constraint, then fight for the smallest model that makes the correct behavior unsurprising.
+
+Channel both "measure twice, cut once" and "yagni". Fight scope creep. When touching core logic, inspect caller hierarchies and verify blast radius with GitNexus before writing code.
+
+The rest of this document helps you navigate the codebase and make changes effectively. Think of these instructions as good defaults and firm quality baselines.
+
+## A small glossary
+
+When communicating and reasoning about GenHub, use this language:
+
+- **you** means the agent reading this file and changing GenHub.
+- **we, us, and maintainers** mean Community Outpost and the people building GenHub.
+- **user** means the player using GenHub to install, mod, and launch Generals / Zero Hour.
+- **CAS (Content-Addressable Storage)** means our content-addressable storage pool (`ICasService`, `CasService`) where assets are deduplicated by hash.
+- **manifest** means the JSON descriptor (`ContentManifest`, `ManifestId`) defining content components, files, hashes, launch targets, and dependencies.
+- **reconciliation** means the atomic process (`ContentReconciliationService`, `IContentReconciliationService`) of turning a clean game installation into a desired profile workspace.
+- **workspace** means the active, materialized directory containing linked/deployed game files where the game executable actually runs.
+- **profile** means a player-configured setup of game version, active mods, maps, and configuration settings.
+
+## The three ways to hurt yourself
+
+1. **Blind symbol edits.** Never modify core interfaces, storage services, or launcher models without checking caller chains via `gitnexus_impact`. Modifying a signature in `ICasService`, `IProfileContentService`, or `IContentReconciliationService` can break Windows launch receipts, Linux symlink handlers, and macOS composition roots simultaneously.
+2. **Throwing exceptions for control flow.** Never throw custom exceptions for predictable domain failure states (file missing, validation failure, hash mismatch, network failure). Return `OperationResult.CreateFailure(...)`. Cooperative cancellation (`OperationCanceledException`) and contract invariant violations (`ArgumentNullException`, invalid arguments) should follow standard .NET exception semantics.
+3. **Hardcoding paths and magic strings.** Never hardcode backslashes `\`, magic constants, URLs, or regexes inline. Always use `Path.Combine` and centralized constants from `GenHub.Core.Constants`.
+
+## Hit every surface
+
+The most common defect in this repository is a change that works on one platform or layer and silently breaks another. Before calling your work done, walk this list:
+
+- **Platforms:** If you change launcher behavior, file materialization, or OS hooks, verify compatibility across Windows (`GenHub.Windows`), Linux (`GenHub.Linux`), and macOS (`GenHub.MacOS`).
+- **Composition Roots:** Register shared services in the applicable module under `GenHub/GenHub/Infrastructure/DependencyInjection/` and ensure that module is invoked by `AppServices.ConfigureApplicationServices`. Register platform-specific implementations in the applicable Windows (`WindowsServicesModule`), Linux (`LinuxServicesModule`), and macOS (`MacOSServicesModule`) service modules, and verify each host composes them through its `Program.cs`.
+- **Result Pattern:** Adhere strictly to `docs/dev/result-pattern.md`. All fallible operations (I/O, network, reconciliation, launch, validation) return `OperationResult` or specialized domain result types (`LaunchResult`, `ValidationResult`, `DetectionResult`) rather than throwing exceptions for control flow. Infallible lookups, getters, and predicates return direct types.
+- **Constants:** Adhere strictly to `docs/dev/constants.md`. Put constants in `GenHub.Core.Constants` static classes.
+- **Cancellation & Async:** Every long-running I/O, download, hashing, or reconciliation task must accept and propagate a `CancellationToken`. Never block the UI thread.
+- **Reverse states:** If you add a workspace materializer, add its cleanup/reversion path. If you add a cache entry, handle its eviction.
+
+## Architecture & Code Intelligence (GitNexus)
+
+This repository uses **GitNexus** to maintain an AST-parsed structural knowledge graph of components, symbols, dependencies, and execution flows in `.gitnexus/`.
+
+### The Three-Phase Cadence
+
+1. **Phase 1 — Discovery (Before Modifying Core Symbols / Interfaces):**
+ - Run `gitnexus_impact` to inspect upstream callers and downstream dependents:
+
+ ```json
+ gitnexus_impact({ "target": "", "direction": "upstream" })
+ ```
+
+ - Review $d=1$ (will break) and $d=2$ (likely affected) dependencies before altering signatures.
+ - Check affected flows via `gitnexus://repo/{name}/processes` or `gitnexus_query(...)`.
+
+2. **Phase 2 — Change Detection (Pre-Commit / Batch Verification):**
+ - Run `gitnexus_detect_changes({ scope: "staged" })` or `pnpm exec gitnexus detect-changes --scope staged` on staged files to map diffs against execution flows.
+ - For pull request verification against the target base branch:
+ ```bash
+ pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development
+ ```
+ - Confirm that changes touching cross-platform abstractions (CAS, launcher, file handlers) stay intact.
+
+3. **Phase 3 — CI Verification & PR Reporting:**
+ - CI builds, indexes, and validates the `.gitnexus/` knowledge graph on push to `development` and `main`.
+ - PR CI runs `pnpm exec gitnexus detect-changes --scope compare --base-ref "$BASE_SHA"` to surface blast radius and affected execution flows in GitHub Step Summaries.
+ - If the local graph is stale after pulling `development`:
+
+ ```bash
+ pnpm exec gitnexus analyze --index-only
+ ```
+
+## Code Conventions & Taste
+
+- **Coding Style Authority:** Follow `coding-style.md`.
+- **Primary Constructors:** Always use primary constructors for classes and records when dependencies are injected. Remove redundant private instance fields (e.g., `_logger = logger;`) and use constructor parameters directly in class members.
+- **Collection Types:** Prefer `IReadOnlyList` when callers need indexed access and known count, and `IReadOnlyCollection` when only count and enumeration are needed. Avoid raw `IEnumerable` for public properties and return types to prevent unintended deferred multiple enumerations; materialize eagerly (e.g., `.ToList()`, `.ToArray()`, or `ImmutableArray`) when returning collections from services or queries.
+- **No `this.`:** Never qualify instance members with `this.`.
+- **Namespaces:** Always use file-scoped or top-level namespace declarations. Alphabetize all `using` directives at the very top of the file. Never use inline namespaces.
+- **Comment Casing:** Use standard sentence casing in comments. Never capitalize arbitrary words mid-comment.
+- **Variables & Declarations:** Always initialize local variables upon declaration. Never leave uninitialized variables (`CS-W1022`) or unused variables (`CS-W1100`). Use discards (`_`) for unused `using` scopes or out parameters.
+- **Switch Statements:** Always include a `default` case (`CS-W1009`) in `switch` statements and expressions.
+- **Exception Handling:** Never catch generic `Exception` (`CS-R1008`) unless explicitly required for top-level process/worker boundaries. Always catch specific exception types (`IOException`, `UnauthorizedAccessException`, etc.) or re-throw.
+- **Formatting:** 4 spaces indentation, Allman bracing style (opening brace on its own line), nullable reference types enabled.
+- **Member Ordering (StyleCop):**
+ 1. Nested types
+ 2. Static fields
+ 3. Instance fields
+ 4. Constructors
+ 5. Finalizers
+ 6. Properties
+ 7. Indexers
+ 8. Events
+ 9. Methods (Static first, then instance; ordered `public` -> `protected` -> `internal` -> `private`).
+
+## Dev & Verification
+
+- **Targeted verification:** Run tests for the specific scope you changed.
+
+ ```bash
+ # Core tests
+ dotnet test GenHub/GenHub.Tests/GenHub.Tests.Core/GenHub.Tests.Core.csproj -c Release
+
+ # Platform-specific tests (on matching OS host)
+ dotnet test GenHub/GenHub.Tests/GenHub.Tests.Windows/GenHub.Tests.Windows.csproj -c Release
+ dotnet test GenHub/GenHub.Tests/GenHub.Tests.Linux/GenHub.Tests.Linux.csproj -c Release
+ dotnet test GenHub/GenHub.Tests/GenHub.Tests.MacOS/GenHub.Tests.MacOS.csproj -c Release
+ ```
+
+- **Do not run repo-wide checks unprompted.** CI owns the full multi-platform matrix.
+- **Solution build:**
+
+ ```bash
+ dotnet build GenHub/GenHub.sln -c Release
+ ```
+
+- **GitNexus CLI:**
+
+ ```bash
+ pnpm exec gitnexus analyze --index-only # Build/refresh graph
+ pnpm exec gitnexus status # Inspect status
+ pnpm exec gitnexus detect-changes --scope staged # Map staged diff to affected flows
+ pnpm exec gitnexus detect-changes --scope compare --base-ref origin/development # Map branch diff against base
+ pnpm exec gitnexus impact # Symbol blast radius
+ ```
+
+## Where code lives
+
+- `GenHub/GenHub.Core/` — Core interfaces (`ICasService`, `IContentReconciliationService`, `IToolPlugin`), domain models (`ContentManifest`, `ManifestId`), launcher/detector contracts, constants, and utilities.
+- `GenHub/GenHub/` — Avalonia MVVM application, ViewModels, Views, Converters, Dialogs, and feature implementations (`CasService`, `ContentReconciliationService`, `GameLauncher`, `GameProcessManager`).
+- `GenHub/GenHub.Windows/` — Windows platform host, composition root, registry discovery, Win32 shortcuts.
+- `GenHub/GenHub.Linux/` — Linux platform host, composition root, desktop entries, Wine/Proton runner.
+- `GenHub/GenHub.MacOS/` — macOS platform host, composition root, `.app` bundle hooks, quarantine `xattr` removal.
+- `GenHub/GenHub.Tests/` — Partitioned test suites (`Core`, `Windows`, `Linux`, `MacOS`).
+- `docs/` — Architecture documentation, Result pattern guide (`docs/dev/result-pattern.md`), Constants reference (`docs/dev/constants.md`).
+
+## Pull requests
+
+- Never make a PR unless the developer explicitly asks you to do so.
+- Conventional commit titles, plain language: `fix(core): CAS pool pruning handles locked files`.
+- Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work.
+- UI changes need before/after images. Motion or timing needs a short video.
+- **Never push while checks are running:** NEVER push new commits while CI workflows, platform builds (Windows, Linux, macOS), tests, DeepSource analyzers, or AI bot reviews (CodeRabbit, Kilo) are in progress or queued. Always wait until EVERY check run reaches `status == completed`. Consolidate all fixes and review resolutions into a single pass before pushing.
+- When babysitting: poll checks and all bot comments (including inline review threads and summary 'Outside diff range' findings) newer than the last push. Verify each finding against the source and fix real ones in code. For automated bot threads (DeepSource, Qodo, CodeRabbit, etc.), resolve the discussion directly without posting reply comments; only reply to human maintainers if discussion or clarification is needed. For extended PR workflows, invoke the `pull-request` and `babysit-pr` skills. Stay quiet when nothing is new. Stop when all checks pass on the latest commit with all threads resolved.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..c9a7ebac8
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+# Claude Code Guidance
+
+@AGENTS.md
diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props
index f76660ae2..b97095865 100644
--- a/GenHub/Directory.Packages.props
+++ b/GenHub/Directory.Packages.props
@@ -47,7 +47,7 @@
-
+
diff --git a/GenHub/GenHub.Core/Constants/AppConstants.cs b/GenHub/GenHub.Core/Constants/AppConstants.cs
index 260c52b6f..17d2c9335 100644
--- a/GenHub/GenHub.Core/Constants/AppConstants.cs
+++ b/GenHub/GenHub.Core/Constants/AppConstants.cs
@@ -132,6 +132,25 @@ public static string FullDisplayVersion
///
public const string TokenFileName = ".ghtoken";
+ ///
+ /// Title of the confirmation prompt shown before all application data is deleted.
+ ///
+ public const string DeleteAllDataConfirmationTitle = "Delete All Application Data";
+
+ ///
+ /// Body of the confirmation prompt shown before all application data is deleted.
+ ///
+ public const string DeleteAllDataConfirmationMessage =
+ "This permanently deletes every profile, workspace, manifest, CAS object and tracked user data " +
+ "installation. The pristine backups GenHub keeps of your original game data will be discarded " +
+ "as part of this, so anything GenHub replaced cannot be recovered afterwards.\n\n" +
+ "This action is irreversible. Continue?";
+
+ ///
+ /// Confirm button text for the delete-all-application-data prompt.
+ ///
+ public const string DeleteAllDataConfirmText = "Delete Everything";
+
///
/// Gets assembly metadata by key.
///
diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
index e7a543023..eae8e7458 100644
--- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
+++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
@@ -10,6 +10,21 @@ public static class AppUpdateConstants
///
public const int MaxHttpRetries = 3;
+ ///
+ /// Index for the Update tab in update notification views.
+ ///
+ public const int UpdateTabIndex = 0;
+
+ ///
+ /// Index for the Browse Builds tab in update notification views.
+ ///
+ public const int BrowseBuildsTabIndex = 1;
+
+ ///
+ /// Maximum valid tab index in update notification views.
+ ///
+ public const int MaxTabIndex = 1;
+
///
/// Velopack directory name.
///
@@ -153,6 +168,121 @@ public static class AppUpdateConstants
"3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" +
"Update available: v{1}";
+ ///
+ /// Update available notification title for release channel.
+ ///
+ public const string UpdateAvailableNotificationTitle = "Update Available";
+
+ ///
+ /// Update available notification title for branch subscriptions.
+ ///
+ public const string BranchUpdateAvailableNotificationTitle = "Branch Update Available";
+
+ ///
+ /// Update available notification title for PR subscriptions.
+ ///
+ public const string PrUpdateAvailableNotificationTitle = "PR Update Available";
+
+ ///
+ /// Update action button text.
+ ///
+ public const string UpdateAction = "Update";
+
+ ///
+ /// Title for the update in progress notification.
+ ///
+ public const string UpdatingAppNotificationTitle = "Updating GenHub";
+
+ ///
+ /// Starting update progress message.
+ ///
+ public const string UpdateStartingMessage = "Starting update...";
+
+ ///
+ /// Title for update failed notification.
+ ///
+ public const string UpdateFailedNotificationTitle = "Update Failed";
+
+ ///
+ /// Update failed notification body format string ({0}: error message).
+ ///
+ public const string UpdateFailedNotificationFormat = "Failed to install update: {0}";
+
+ ///
+ /// View updates action button text.
+ ///
+ public const string ViewUpdatesAction = "View Updates";
+
+ ///
+ /// Release update notification body format string ({0}: version).
+ ///
+ public const string ReleaseUpdateNotificationFormat = "A new version ({0}) is available.";
+
+ ///
+ /// Branch update notification body format string ({0}: version, {1}: branch name).
+ ///
+ public const string BranchUpdateNotificationFormat = "A new build ({0}) is available on branch '{1}'.";
+
+ ///
+ /// PR update notification body format string ({0}: version, {1}: PR number).
+ ///
+ public const string PrUpdateNotificationFormat = "A new build ({0}) is available for PR #{1}.";
+
+ ///
+ /// Sort option: sort by last updated date descending.
+ ///
+ public const string SortOptionLastUpdated = "Last Updated";
+
+ ///
+ /// Sort option: sort by pull request number descending.
+ ///
+ public const string SortOptionPrNumberDesc = "PR Number (Highest)";
+
+ ///
+ /// Sort option: sort by pull request number ascending.
+ ///
+ public const string SortOptionPrNumberAsc = "PR Number (Lowest)";
+
+ ///
+ /// Default interval in minutes for periodic update checks (30 minutes).
+ ///
+ public const int DefaultPeriodicUpdateCheckIntervalMinutes = 30;
+
+ ///
+ /// Minimum interval in minutes for periodic update checks (5 minutes).
+ ///
+ public const int MinPeriodicUpdateCheckIntervalMinutes = 5;
+
+ ///
+ /// Maximum interval in minutes for periodic update checks (10080 minutes / 7 days).
+ ///
+ public const int MaxPeriodicUpdateCheckIntervalMinutes = 10080;
+
+ ///
+ /// Increment step in minutes for periodic update check interval setting (5 minutes).
+ ///
+ public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5;
+
+ ///
+ /// Default buffer size for stream operations (128KB).
+ ///
+ public const int DefaultStreamBufferSize = 131072;
+
+ ///
+ /// Chunk size in bytes for parallel range downloads (2MB).
+ ///
+ public const long DownloadChunkSizeBytes = 2 * 1024 * 1024;
+
+ ///
+ /// Maximum number of concurrent connections for parallel downloads.
+ ///
+ public const int ParallelDownloadConcurrency = 8;
+
+ ///
+ /// Minimum file size threshold in bytes to trigger parallel chunked downloading (4MB).
+ ///
+ public const long ParallelDownloadThresholdBytes = 4 * 1024 * 1024;
+
///
/// Delay before exit after applying update (5 seconds).
///
diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
index 4b0821443..30cd69c4f 100644
--- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
@@ -1,8 +1,13 @@
namespace GenHub.Core.Constants;
///
-/// Constants for command line arguments and URI schemes.
+/// Constants for command line arguments and the genhub:// URI scheme.
///
+///
+/// Subscription links use genhub://subscribe?url=<absolute-url>.
+/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share
+/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time.
+///
public static class CommandLineConstants
{
///
@@ -16,22 +21,27 @@ public static class CommandLineConstants
public const string LaunchProfileInlinePrefix = "--launch-profile=";
///
- /// URI scheme used for protocol handling.
+ /// Scheme name for custom protocol registration.
///
- public const string UriScheme = "genhub://";
+ public const string SchemeName = "genhub";
///
- /// Command for subscribing to a catalog via URI.
+ /// Custom URI scheme registered so OS/browser links can open GenHub.
+ ///
+ public const string UriScheme = SchemeName + "://";
+
+ ///
+ /// URI path segment for content subscription (genhub://subscribe?url=...).
///
public const string SubscribeCommand = "subscribe";
///
- /// Full prefix for subscription URI.
+ /// Full prefix for subscription URIs (genhub://subscribe).
///
public const string SubscribeUriPrefix = UriScheme + SubscribeCommand;
///
- /// Query parameter name for the catalog URL in a subscription URI.
+ /// Query parameter carrying the absolute URL of a catalog (or future provider definition).
///
public const string SubscribeUrlParam = "?url=";
}
diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs
index 305d9555d..d15f6516c 100644
--- a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs
@@ -85,6 +85,22 @@ public static class CommunityOutpostConstants
///
public const string PatchPageUrl = "https://legi.cc/downloads/genpatcher/";
+ ///
+ /// Maximum number of file entries a downloaded Community Outpost archive may contain.
+ ///
+ public const int MaxArchiveEntries = 10000;
+
+ ///
+ /// Maximum number of bytes a single Community Outpost archive entry may expand to (2 GiB),
+ /// sized to accommodate the largest shipped BIG files.
+ ///
+ public const long MaxEntryUncompressedBytes = 2L * 1024 * 1024 * 1024;
+
+ ///
+ /// Maximum aggregate uncompressed bytes a Community Outpost archive may expand to (4 GiB).
+ ///
+ public const long MaxAggregateUncompressedBytes = 4L * 1024 * 1024 * 1024;
+
/// Display name for Game Clients content type.
public const string ContentTypeGameClients = "Game Clients";
diff --git a/GenHub/GenHub.Core/Constants/DirectoryNames.cs b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
index 47097cc18..4938758ab 100644
--- a/GenHub/GenHub.Core/Constants/DirectoryNames.cs
+++ b/GenHub/GenHub.Core/Constants/DirectoryNames.cs
@@ -50,6 +50,40 @@ public static class DirectoryNames
///
public const string Profiles = "Profiles";
+ ///
+ /// Directory holding manifests authored by the user, alongside .
+ ///
+ public const string CustomManifests = "CustomManifests";
+
+ ///
+ /// Directory that releases up to v0.0.3 nested the manifests, tracked user data and workspace
+ /// metadata under. Current releases keep those entries directly in the data root.
+ ///
+ public const string LegacyContent = "Content";
+
+ ///
+ /// Directory for storing tracked user data.
+ ///
+ public const string UserData = "UserData";
+
+ ///
+ /// Directory holding the manifests of tracked user data, nested inside .
+ ///
+ ///
+ /// Deliberately lower-case and separate from : this is
+ /// the exact name written to disk, and matching case matters on case-sensitive filesystems.
+ ///
+ public const string UserDataManifests = "manifests";
+
+ ///
+ /// Directory holding backups of replaced user data files, nested inside .
+ ///
+ ///
+ /// Deliberately lower-case and separate from : this is the exact name
+ /// written to disk, and matching case matters on case-sensitive filesystems.
+ ///
+ public const string UserDataBackups = "backups";
+
///
/// Directory for storing workspaces.
///
diff --git a/GenHub/GenHub.Core/Constants/FileTypes.cs b/GenHub/GenHub.Core/Constants/FileTypes.cs
index 608a21f6b..1b4b2777b 100644
--- a/GenHub/GenHub.Core/Constants/FileTypes.cs
+++ b/GenHub/GenHub.Core/Constants/FileTypes.cs
@@ -35,6 +35,22 @@ public static class FileTypes
///
public const string SettingsFileName = "settings.json";
+ ///
+ /// Settings file name written by releases up to v0.0.3, which combined the data root with the
+ /// JSON extension instead of the settings file name.
+ ///
+ public const string LegacySettingsFileName = ".json";
+
+ ///
+ /// File name holding the persisted workspace metadata.
+ ///
+ public const string WorkspaceMetadataFileName = "workspaces.json";
+
+ ///
+ /// File name of the index tracking installed user data.
+ ///
+ public const string UserDataIndexFileName = "index.json";
+
///
/// File extension for replay files.
///
diff --git a/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs
index c91671af7..aa11b7f91 100644
--- a/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GameSettingsGeneralsOnlineConstants.cs
@@ -10,6 +10,23 @@ public static class GameSettingsGeneralsOnlineConstants
///
public const string SettingsFileName = "settings.json";
+ ///
+ /// Extension of the file a save is written to before it is moved over settings.json.
+ ///
+ public const string TemporarySettingsFileExtension = ".tmp";
+
+ ///
+ /// Number of times the completed settings file is moved over settings.json before the save
+ /// gives up and reports the failure. Anything holding settings.json open releases it within
+ /// milliseconds, so a handful of attempts either succeeds or is looking at a real fault.
+ ///
+ public const int SettingsReplaceAttemptLimit = 5;
+
+ ///
+ /// Delay between attempts to move the completed settings file over settings.json.
+ ///
+ public const int SettingsReplaceRetryDelayMilliseconds = 20;
+
///
/// Default chat font size.
///
@@ -24,4 +41,29 @@ public static class GameSettingsGeneralsOnlineConstants
/// Maximum chat font size.
///
public const int MaxChatFontSize = 24;
+
+ ///
+ /// Default for whether ping is shown.
+ ///
+ public const bool DefaultShowPing = true;
+
+ ///
+ /// Default for whether player ranks are shown.
+ ///
+ public const bool DefaultShowPlayerRanks = true;
+
+ ///
+ /// Default for whether the username is remembered.
+ ///
+ public const bool DefaultRememberUsername = true;
+
+ ///
+ /// Default for whether notifications are enabled.
+ ///
+ public const bool DefaultEnableNotifications = true;
+
+ ///
+ /// Default for whether sound notifications are enabled.
+ ///
+ public const bool DefaultEnableSoundNotifications = true;
}
diff --git a/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs
index 15f23b2fa..64f7fa85d 100644
--- a/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GameSettingsTheSuperHackersConstants.cs
@@ -45,6 +45,12 @@ public static class GameSettingsTheSuperHackersConstants
///
public const int DefaultSystemTimeFontSize = 8;
+ ///
+ /// Default volume for money transaction audio events, on the same 0-100 scale the settings
+ /// screen exposes. Zero would mute them, which is a choice rather than a default.
+ ///
+ public const int DefaultMoneyTransactionVolume = 50;
+
///
/// Default for whether player observer mode is enabled.
/// Matches the engine fallback in OptionPreferences::getPlayerObserverEnabled.
diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
index dad16f47a..bebc15802 100644
--- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
@@ -125,6 +125,23 @@ public static class GeneralsOnlineConstants
/// Description for Generals Online deliverer.
public const string DelivererDescription = "Delivers Generals Online content via ZIP extraction and CAS storage";
+ // ===== Easy Anti-Cheat Installation =====
+
+ /// Product ID registered with Epic Online Services Easy Anti-Cheat for Generals Online.
+ public const string EacProductId = "fc1cc0d936424212b645105f084d08b0";
+
+ /// Setup command passed to EasyAntiCheat_EOS_Setup.exe.
+ public const string EacInstallCommand = "install";
+
+ /// Display name for the Easy Anti-Cheat installation step.
+ public const string EacStepName = "Install Easy Anti-Cheat";
+
+ /// Status message displayed to the user during Easy Anti-Cheat installation.
+ public const string EacStatusMessage = "Installing AntiCheat";
+
+ /// Unique step key identifying Easy Anti-Cheat installation for Generals Online.
+ public const string EacStepKey = PublisherType + ":eac:" + EacProductId;
+
// ===== Content Tags =====
/// Content tags for search and categorization.
diff --git a/GenHub/GenHub.Core/Constants/GitHubConstants.cs b/GenHub/GenHub.Core/Constants/GitHubConstants.cs
index af738db30..0a567a174 100644
--- a/GenHub/GenHub.Core/Constants/GitHubConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GitHubConstants.cs
@@ -333,6 +333,33 @@ public static class GitHubConstants
/// Description for GitHub content deliverer.
public const string GitHubDelivererDescription = "Delivers GitHub content including release archives";
+ // Archive extraction limits
+ // GitHub caps a single release asset at 2 GiB, so a downloaded archive can never exceed that
+ // compressed. These bounds leave generous headroom above real game content while keeping an
+ // archive that lies about its declared sizes from expanding without limit.
+
+ /// Maximum number of file entries a downloaded GitHub archive may contain.
+ public const int MaxArchiveEntries = 50000;
+
+ /// Maximum number of bytes a single GitHub archive entry may expand to (4 GiB).
+ public const long MaxEntryUncompressedBytes = 4L * 1024 * 1024 * 1024;
+
+ /// Maximum aggregate uncompressed bytes a GitHub archive may expand to (16 GiB).
+ public const long MaxAggregateUncompressedBytes = 16L * 1024 * 1024 * 1024;
+
+ ///
+ /// Maximum factor by which a GitHub archive may expand beyond its own downloaded size. Release
+ /// archives are deflate-compressed game content and executables, which run well under 20:1, so
+ /// this bounds a small archive that claims to hold very little and then inflates without end.
+ ///
+ public const long MaxArchiveExpansionRatio = 500;
+
+ ///
+ /// Floor for the ratio-derived expansion budget (8 MiB), so a very small archive still gets
+ /// room for content that compresses unusually well and is judged only by the absolute caps.
+ ///
+ public const long MinArchiveExpansionBudgetBytes = 8L * 1024 * 1024;
+
// Metadata keys
/// Metadata key for repository owner.
diff --git a/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs
new file mode 100644
index 000000000..a10b32379
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/ImageCacheConstants.cs
@@ -0,0 +1,37 @@
+namespace GenHub.Core.Constants;
+
+///
+/// Constants for image downloading, validation, and caching.
+///
+public static class ImageCacheConstants
+{
+ ///
+ /// Maximum allowed image download payload in bytes (15 MB).
+ ///
+ public const long MaxImageDownloadSizeBytes = 15L * 1024 * 1024;
+
+ ///
+ /// Maximum number of bitmap entries stored in the memory LRU cache.
+ ///
+ public const int MaxMemoryCacheEntries = 200;
+
+ ///
+ /// Maximum disk cache size in bytes (250 MB).
+ ///
+ public const long MaxDiskCacheSizeBytes = 250L * 1024 * 1024;
+
+ ///
+ /// Time-to-live for disk-cached images in days.
+ ///
+ public const int DiskCacheTtlDays = 30;
+
+ ///
+ /// Default HTTP timeout in seconds for downloading images.
+ ///
+ public const int DefaultTimeoutSeconds = 30;
+
+ ///
+ /// Maximum allowed HTTP redirects when downloading images.
+ ///
+ public const int MaxRedirects = 5;
+}
diff --git a/GenHub/GenHub.Core/Constants/IoConstants.cs b/GenHub/GenHub.Core/Constants/IoConstants.cs
index 09f0b77b5..5b99c5710 100644
--- a/GenHub/GenHub.Core/Constants/IoConstants.cs
+++ b/GenHub/GenHub.Core/Constants/IoConstants.cs
@@ -9,4 +9,17 @@ public static class IoConstants
/// Default buffer size for file operations (4KB).
///
public const int DefaultFileBufferSize = 4096;
+
+ ///
+ /// How many times a path may be re-resolved while following symbolic links whose targets are
+ /// themselves reached through links. Bounds the walk on a filesystem that contains a cycle.
+ ///
+ public const int MaxSymbolicLinkResolutionDepth = 8;
+
+ ///
+ /// Suffix that marks a staging file written beside its final location so the existing file is
+ /// only replaced once the write has completed. The name it is appended to is random rather than
+ /// the destination name, which keeps a staged write from outgrowing the Windows path limit.
+ ///
+ public const string StagingFileSuffix = ".genhub-staging";
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs
index 1a66630f8..4096fd317 100644
--- a/GenHub/GenHub.Core/Constants/IpcCommands.cs
+++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs
@@ -11,7 +11,8 @@ public static class IpcCommands
public const string LaunchProfilePrefix = "launch-profile:";
///
- /// Command prefix used to subscribe to a catalog via IPC.
+ /// Command prefix used to forward a subscribe URL to the primary instance
+ /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=....
///
public const string SubscribePrefix = "subscribe:";
}
diff --git a/GenHub/GenHub.Core/Constants/ManifestConstants.cs b/GenHub/GenHub.Core/Constants/ManifestConstants.cs
index bdfc551dc..d58959ce7 100644
--- a/GenHub/GenHub.Core/Constants/ManifestConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ManifestConstants.cs
@@ -100,6 +100,16 @@ public static class ManifestConstants
///
public const string DefaultContentDependencyId = "1.0.genhub.content.defaultdependency";
+ ///
+ /// Wildcard token representing any publisher in dependency declarations.
+ ///
+ public const string AnyPublisherToken = "any";
+
+ ///
+ /// Separator used to append variant identifiers to content names.
+ ///
+ public const string VariantSeparator = "-";
+
///
/// Version string for Generals game installation manifests.
/// This represents the executable version 1.08.
diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs
index 01d22c943..d4d7187ff 100644
--- a/GenHub/GenHub.Core/Constants/ProcessConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ProcessConstants.cs
@@ -83,10 +83,18 @@ public static class ProcessConstants
///
public const double EarlyExitThresholdSeconds = 10.0;
+ ///
+ /// How many characters of a process name a Unix kernel keeps. Linux stores it in a
+ /// TASK_COMM_LEN buffer and macOS in a MAXCOMLEN one, both of which leave room for fifteen
+ /// characters and a terminator, and the truncated value is what process enumeration matches on.
+ ///
+ public const int UnixProcessNameMaxLength = 15;
+
///
/// How long to wait for a launcher's expected child process to appear. Measured spawn latency
- /// for the Easy Anti-Cheat bootstrapper is well under two seconds. Must not exceed
- /// , which bounds how old an adoptable process may be.
+ /// for the Easy Anti-Cheat bootstrapper is well under two seconds. Adoption dates a candidate
+ /// against the launcher's own start time rather than ,
+ /// so this may be raised as far as a slow bootstrapper needs.
///
public const int SpawnedChildDiscoveryTimeoutMs = 10_000;
diff --git a/GenHub/GenHub.Core/Constants/ProfileConstants.cs b/GenHub/GenHub.Core/Constants/ProfileConstants.cs
index 9900e4e39..f827a6fbe 100644
--- a/GenHub/GenHub.Core/Constants/ProfileConstants.cs
+++ b/GenHub/GenHub.Core/Constants/ProfileConstants.cs
@@ -5,6 +5,11 @@ namespace GenHub.Core.Constants;
///
public static class ProfileConstants
{
+ ///
+ /// The default profile name used for new profiles.
+ ///
+ public const string DefaultProfileName = "New Profile";
+
///
/// The workspace ID used for tool profiles.
///
diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
index 27f2cd99a..5ca6cabfb 100644
--- a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
+++ b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs
@@ -1,3 +1,5 @@
+using System;
+using System.Collections.Generic;
using GenHub.Core.Extensions.GameInstallations;
using GenHub.Core.Models.Enums;
@@ -59,6 +61,19 @@ public static class PublisherTypeConstants
/// Art of Defense Maps community site.
public const string AODMaps = "aodmaps";
+ /// GenHub internal system content publisher.
+ public const string GenHubInternal = "genhub";
+
+ ///
+ /// Set of publisher identifiers trusted to execute installation steps (e.g. installers).
+ ///
+ public static readonly IReadOnlySet TrustedExecutablePublishers = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ GeneralsOnline,
+ CommunityOutpost,
+ TheSuperHackers,
+ };
+
///
/// Maps GameInstallationType enum to publisher type string.
///
diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
index b15606e59..d5d3dffce 100644
--- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
+++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
@@ -60,6 +60,21 @@ public static class SuperHackersConstants
///
public const string GeneralsGameCodeRepo = "GeneralsGameCode";
+ ///
+ /// GitHub owner for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Owner = "TheSuperHackers";
+
+ ///
+ /// GitHub repo for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2";
+
+ ///
+ /// Display name for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2DisplayName = "Community Patch 2";
+
// ===== Service Configuration =====
///
diff --git a/GenHub/GenHub.Core/Constants/UiConstants.cs b/GenHub/GenHub.Core/Constants/UiConstants.cs
index e3dc98279..bd61a5893 100644
--- a/GenHub/GenHub.Core/Constants/UiConstants.cs
+++ b/GenHub/GenHub.Core/Constants/UiConstants.cs
@@ -25,6 +25,21 @@ public static class UiConstants
///
public const double DefaultProfileSettingsHeight = 700;
+ ///
+ /// Default width for the profile settings sidebar in pixels.
+ ///
+ public const double DefaultProfileSettingsSidebarWidth = 190;
+
+ ///
+ /// Minimum width for the profile settings sidebar (shows icons only) in pixels.
+ ///
+ public const double MinProfileSettingsSidebarWidth = 68;
+
+ ///
+ /// Maximum width for the profile settings sidebar in pixels.
+ ///
+ public const double MaxProfileSettingsSidebarWidth = 300;
+
// Status colors
///
@@ -37,6 +52,36 @@ public static class UiConstants
///
public const string StatusErrorColor = "#F44336";
+ ///
+ /// color used for downloaded status indicator.
+ ///
+ public const string StatusDownloadedColor = "#4CAF50";
+
+ ///
+ /// color used for not downloaded status indicator.
+ ///
+ public const string StatusNotDownloadedColor = "#B388FF";
+
+ ///
+ /// color used for update available status indicator.
+ ///
+ public const string StatusUpdateAvailableColor = "#FFB74D";
+
+ ///
+ /// svg path data for transparent checkmark icon.
+ ///
+ public const string TransparentCheckmarkIconPath = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z";
+
+ ///
+ /// svg path data for detailed download arrow icon into tray.
+ ///
+ public const string DownloadArrowIconPath = "M5 20h14v-2H5v2zM19 9h-4V3H9v6H5l7 7 7-7z";
+
+ ///
+ /// svg path data for update sync icon.
+ ///
+ public const string UpdateSyncIconPath = "M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z";
+
///
/// Default theme color for Generals content.
///
diff --git a/GenHub/GenHub.Core/Constants/UserDataConstants.cs b/GenHub/GenHub.Core/Constants/UserDataConstants.cs
new file mode 100644
index 000000000..8de21fae4
--- /dev/null
+++ b/GenHub/GenHub.Core/Constants/UserDataConstants.cs
@@ -0,0 +1,13 @@
+namespace GenHub.Core.Constants;
+
+///
+/// Constants for tracked user data installations.
+///
+public static class UserDataConstants
+{
+ ///
+ /// Suffix appended to a deployed file that no longer matches its recorded hash when it is
+ /// moved aside so the pristine backup can be restored over it.
+ ///
+ public const string UserModifiedSuffix = ".user-modified";
+}
diff --git a/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs b/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs
new file mode 100644
index 000000000..448a6bc00
--- /dev/null
+++ b/GenHub/GenHub.Core/Exceptions/ArchiveExpansionLimitExceededException.cs
@@ -0,0 +1,76 @@
+using System;
+
+namespace GenHub.Core.Exceptions;
+
+///
+/// Exception thrown when an archive entry expands past the budget allowed for it, which means the
+/// size declared in the archive headers understated the real decompressed size.
+///
+public class ArchiveExpansionLimitExceededException : Exception
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ArchiveExpansionLimitExceededException()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message that describes the error.
+ public ArchiveExpansionLimitExceededException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The message that describes the error.
+ /// The exception that is the cause of the current exception.
+ public ArchiveExpansionLimitExceededException(string message, Exception? inner)
+ : base(message, inner)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class
+ /// for a named entry that exceeded a byte budget.
+ ///
+ /// The archive-relative name of the offending entry.
+ /// The number of bytes the entry was allowed to expand to.
+ public ArchiveExpansionLimitExceededException(string entryName, long limitBytes)
+ : this($"Archive entry '{entryName}' expanded past the allowed {limitBytes} bytes (potential zip bomb).", entryName, limitBytes)
+ {
+ }
+
+ private ArchiveExpansionLimitExceededException(string message, string entryName, long limitBytes)
+ : base(message)
+ {
+ EntryName = entryName;
+ LimitBytes = limitBytes;
+ }
+
+ ///
+ /// Gets the archive-relative name of the offending entry.
+ ///
+ public string EntryName { get; } = string.Empty;
+
+ ///
+ /// Gets the number of bytes the entry was allowed to expand to.
+ ///
+ public long LimitBytes { get; }
+
+ ///
+ /// Creates an exception for an entry refused because the archive-wide expansion budget was
+ /// already spent, so no byte of it was ever read.
+ ///
+ /// The archive-relative name of the refused entry.
+ /// An exception describing the spent budget.
+ public static ArchiveExpansionLimitExceededException ForSpentBudget(string entryName) =>
+ new(
+ $"Archive entry '{entryName}' was refused because the archive-wide expansion budget was already spent (potential zip bomb).",
+ entryName,
+ 0);
+}
diff --git a/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs b/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs
new file mode 100644
index 000000000..68b199060
--- /dev/null
+++ b/GenHub/GenHub.Core/Extensions/Enums/ContentInstallTargetExtensions.cs
@@ -0,0 +1,29 @@
+using GenHub.Core.Models.Enums;
+
+namespace GenHub.Core.Extensions.Enums;
+
+///
+/// Provides extension methods for the enum.
+///
+public static class ContentInstallTargetExtensions
+{
+ ///
+ /// Determines whether the target resolves to a directory the user and the game engine
+ /// write to directly, which means deployed content must never share storage with the
+ /// content-addressable object it originated from.
+ ///
+ /// Only the two targets that are definitively not user data are listed as such: every other
+ /// value, including any added later, is treated as user-writable and therefore copied. That
+ /// matches the resolver, whose own default arm places unmapped targets inside the user data
+ /// root, and it fails towards an extra copy rather than towards a hard link into Documents.
+ ///
+ ///
+ /// The install target to inspect.
+ /// true when the destination is user-writable; otherwise, false.
+ public static bool IsUserWritableTarget(this ContentInstallTarget installTarget) => installTarget switch
+ {
+ ContentInstallTarget.Workspace => false,
+ ContentInstallTarget.System => false,
+ _ => true,
+ };
+}
diff --git a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs
index 7f414ba6f..02094c31b 100644
--- a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs
+++ b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs
@@ -1,3 +1,6 @@
+using System;
+using System.Linq;
+using GenHub.Core.Constants;
using GenHub.Core.Models.GameProfile;
namespace GenHub.Core.Extensions;
@@ -21,6 +24,35 @@ public static bool HasCustomSettings(this GameProfile profile)
HasCustomNetworkSettings(profile);
}
+ ///
+ /// Checks if a profile runs the GeneralsOnline client.
+ ///
+ ///
+ /// A recorded publisher type settles the question either way. The client name and the enabled
+ /// content ids are consulted only when no publisher type was recorded, which is the case for
+ /// profiles created before it existed: a TheSuperHackers profile with GeneralsOnline content
+ /// enabled belongs to TheSuperHackers, and answering otherwise would let it rewrite the
+ /// GeneralsOnline client's global settings.
+ ///
+ /// The game profile.
+ /// True if the profile runs GeneralsOnline, false otherwise.
+ public static bool IsGeneralsOnlineProfile(this GameProfile profile)
+ {
+ var publisherType = profile.GameClient?.PublisherType;
+ if (!string.IsNullOrWhiteSpace(publisherType))
+ {
+ return string.Equals(publisherType, PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (profile.GameClient?.Name?.Contains(PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase) == true)
+ {
+ return true;
+ }
+
+ return profile.EnabledContentIds?
+ .Any(id => id.Contains(PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase)) == true;
+ }
+
private static bool HasCustomVideoSettings(GameProfile profile)
{
return profile.VideoResolutionWidth.HasValue ||
diff --git a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs
new file mode 100644
index 000000000..8e2288065
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Helper class for application update version comparison and parsing.
+///
+public static partial class AppUpdateVersionHelper
+{
+ ///
+ /// Extracts the channel key (e.g., "pr242", "main", "development", "release", "ci") from a version string.
+ ///
+ /// The version string to extract the channel from.
+ /// The normalized channel key, or null if the version is null or empty.
+ public static string? ExtractChannelKey(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return null;
+ }
+
+ var clean = version.Split('+')[0].Trim();
+ var dashIndex = clean.IndexOf('-');
+ if (dashIndex >= 0 && dashIndex < clean.Length - 1)
+ {
+ var suffix = clean[(dashIndex + 1)..].Trim();
+ if (!string.IsNullOrEmpty(suffix))
+ {
+ var ciMatch = CiMarkerRegex().Match(clean);
+ if (ciMatch.Success && suffix.StartsWith("ci.", StringComparison.OrdinalIgnoreCase))
+ {
+ return "ci";
+ }
+
+ return suffix.ToLowerInvariant();
+ }
+ }
+
+ return "release";
+ }
+
+ ///
+ /// Extracts the workflow run number from a version string (e.g., "0.0.641-pr241" -> 641).
+ /// Returns 0 for plain semantic versions without CI run markers.
+ ///
+ /// The version string to extract the run number from.
+ /// The extracted run number, or 0 if extraction fails or not a CI build.
+ public static int ExtractRunNumber(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return 0;
+ }
+
+ var match = CiRunNumberRegex().Match(version);
+ if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber) && runNumber > 0)
+ {
+ return runNumber;
+ }
+
+ var ciMatch = CiMarkerRegex().Match(version);
+ if (ciMatch.Success && int.TryParse(ciMatch.Groups[1].Value, out var ciRunNumber) && ciRunNumber > 0)
+ {
+ return ciRunNumber;
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Checks whether an available artifact version is newer than the currently installed version.
+ /// Rejects cross-channel sequential comparisons when the current installation belongs to a specific channel.
+ ///
+ /// The new artifact version string.
+ /// The current version string.
+ /// Whether to allow comparing versions from different channels.
+ /// True if newVersion is newer than currentVersion; otherwise false.
+ public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion, bool allowCrossChannel = false)
+ {
+ if (string.IsNullOrWhiteSpace(newVersion))
+ {
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(currentVersion))
+ {
+ return true;
+ }
+
+ var newVersionBase = newVersion.Split('+')[0].Trim();
+ var currentVersionBase = currentVersion.Split('+')[0].Trim();
+
+ var newRun = ExtractRunNumber(newVersionBase);
+ var currentRun = ExtractRunNumber(currentVersionBase);
+
+ if (!allowCrossChannel)
+ {
+ var newChannel = ExtractChannelKey(newVersionBase);
+ var currentChannel = ExtractChannelKey(currentVersionBase);
+
+ // If the currently installed build belongs to a specific channel (e.g. "pr242", "main", "development"),
+ // reject updates from any different channel (e.g. "pr265").
+ if (!string.IsNullOrEmpty(currentChannel) &&
+ !string.Equals(currentChannel, "release", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(newChannel, currentChannel, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+ }
+
+ if (newRun > 0 && currentRun > 0)
+ {
+ return newRun > currentRun;
+ }
+
+ if (newRun == 0 && currentRun > 0)
+ {
+ return false;
+ }
+
+ if (newRun > 0 && currentRun == 0)
+ {
+ return true;
+ }
+
+ var newClean = newVersionBase.Split('-')[0];
+ var currentClean = currentVersionBase.Split('-')[0];
+ if (Version.TryParse(newClean, out var newVer) && Version.TryParse(currentClean, out var currentVer))
+ {
+ return newVer > currentVer;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Regex for extracting workflow run number from a 0.0.X CI version string.
+ /// Matches patterns like "0.0.1282-pr265", "0.0.1282-main", "0.0.1282".
+ ///
+ [GeneratedRegex(@"^0\.0\.(\d+)(?:-[a-zA-Z0-9_.-]+)?$", RegexOptions.IgnoreCase)]
+ private static partial Regex CiRunNumberRegex();
+
+ ///
+ /// Regex for extracting workflow run number from a -ci.X marker.
+ ///
+ [GeneratedRegex(@"-ci\.(\d+)", RegexOptions.IgnoreCase)]
+ private static partial Regex CiMarkerRegex();
+}
diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
index f6b570af0..d5c595d36 100644
--- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
+++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
@@ -15,9 +15,9 @@ public static class CommandLineParser
/// The extracted profile identifier if present; otherwise, null.
public static string? ExtractProfileId(string[] args)
{
- for (var i = 0; i < args.Length; i++)
+ for (int i = 0; i < args.Length; i++)
{
- var arg = args[i];
+ string arg = args[i];
if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
@@ -34,23 +34,48 @@ public static class CommandLineParser
}
///
- /// Extracts a subscription URL from command line arguments.
- /// Supports the URI scheme format: genhub://subscribe?url=<url>.
+ /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument.
///
+ ///
+ /// The returned value is the url query value only (not the genhub:// wrapper).
+ /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider
+ /// Definition URL without changing this parser.
+ ///
/// The command line arguments.
- /// The extracted catalog URL if present; otherwise, null.
+ /// The decoded absolute URL if present; otherwise, null.
public static string? ExtractSubscriptionUrl(string[] args)
{
- foreach (var arg in args)
+ foreach (string arg in args)
{
if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase))
{
- // Simple parsing for ?url=...
- var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
+ string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..];
+ if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
if (queryStart != -1)
{
- var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
- return Uri.UnescapeDataString(url).Trim('"');
+ string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
+ string unescaped = Uri.UnescapeDataString(url)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim('"', '\'', ' ', '\t');
+
+ if (string.IsNullOrWhiteSpace(unescaped))
+ {
+ return null;
+ }
+
+ if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) &&
+ (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ return unescaped;
+ }
+
+ return null;
}
}
}
diff --git a/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs
index 6fadad989..885befdeb 100644
--- a/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs
+++ b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs
@@ -13,30 +13,105 @@ namespace GenHub.Core.Helpers;
public static class GameProcessSelector
{
///
- /// Selects the process matching that this launch spawned.
+ /// Gets the name to enumerate by when looking for . Unix kernels
+ /// keep only the first characters of a
+ /// process name, and matches
+ /// against that truncated value, so asking for a longer name finds nothing at all. Windows
+ /// reports names in full and is asked for them unchanged.
+ ///
+ /// The expected process name, without extension.
+ /// The name to ask the operating system for.
+ public static string GetDiscoveryName(string processName)
+ {
+ if (OperatingSystem.IsWindows() || processName.Length <= ProcessConstants.UnixProcessNameMaxLength)
+ {
+ return processName;
+ }
+
+ return processName[..ProcessConstants.UnixProcessNameMaxLength];
+ }
+
+ ///
+ /// Selects the process matching that this launch spawned, with
+ /// no launcher of ours to date the launch by — the storefront started the game itself. A
+ /// recency window is all that separates the new process from an instance of the same game that
+ /// was already running, so it is this path's only bound on age.
///
/// The processes currently observed on the machine. Each candidate's must be a UTC with .
/// The expected process name, without extension.
/// The directory the game must run from, or to skip the check.
/// The current time, used to apply the recency window. Must be a UTC with .
- /// The start time of the launcher process, if known. Must be a UTC with when supplied.
/// The selected candidate, or when none qualifies.
public static GameProcessCandidate? SelectSpawnedGameProcess(
IEnumerable candidates,
string processName,
string? workingDirectory,
- DateTime now,
- DateTime? launcherStartTime = null)
+ DateTime now)
{
- var matches = candidates
- .Where(candidate => candidate.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase))
- .Where(candidate => (now - candidate.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds);
+ return Select(
+ candidates,
+ processName,
+ workingDirectory,
+ candidate => (now - candidate.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds);
+ }
- if (launcherStartTime.HasValue)
+ ///
+ /// Selects the process a launcher spawned, to be tracked and eventually terminated in the
+ /// launcher's place. Unlike this refuses to answer at all
+ /// when the launcher's start time is unknown: without it, a process that started before this
+ /// launch and merely shares the name and the workspace cannot be told apart from the child, and
+ /// adopting it means killing somebody else's game when this launch is stopped.
+ ///
+ /// That start time also replaces the recency window rather than joining it. It dates this
+ /// launch exactly, so anything at or after it started during the launch however long discovery
+ /// took, while a window measured against the clock expires a child that is genuinely ours the
+ /// moment the launcher is slow to produce it — and the discovery timeout the caller polls with
+ /// is configurable well past any fixed window. Keeping both would only turn a legitimate slow
+ /// adoption into an abandoned game that is still running.
+ ///
+ ///
+ /// The processes currently observed on the machine. Each candidate's must be a UTC with .
+ /// The expected process name, without extension.
+ /// The directory the game must run from, or to skip the check.
+ /// The start time of the launcher process. Must be a UTC with when supplied.
+ /// The candidate to adopt, or when none qualifies or the launcher's start time is unknown.
+ public static GameProcessCandidate? SelectAdoptableGameProcess(
+ IEnumerable candidates,
+ string processName,
+ string? workingDirectory,
+ DateTime? launcherStartTime)
+ {
+ if (!launcherStartTime.HasValue)
{
- matches = matches.Where(candidate => candidate.StartTime >= launcherStartTime.Value);
+ return null;
}
+ return Select(
+ candidates,
+ processName,
+ workingDirectory,
+ candidate => candidate.StartTime >= launcherStartTime.Value);
+ }
+
+ ///
+ /// Applies the checks both paths share and lets the caller supply the one that decides whether
+ /// a candidate belongs to this launch.
+ ///
+ /// The processes currently observed on the machine.
+ /// The expected process name, without extension.
+ /// The directory the game must run from, or to skip the check.
+ /// The caller's test for a candidate having started as part of this launch.
+ /// The selected candidate, or when none qualifies.
+ private static GameProcessCandidate? Select(
+ IEnumerable candidates,
+ string processName,
+ string? workingDirectory,
+ Func startedWithThisLaunch)
+ {
+ var matches = candidates
+ .Where(candidate => NameMatches(candidate, processName))
+ .Where(startedWithThisLaunch);
+
// Residence is required whenever a working directory is known, including for a lone match:
// a same-named process elsewhere on the machine is somebody else's.
if (!string.IsNullOrEmpty(workingDirectory))
@@ -49,6 +124,44 @@ public static class GameProcessSelector
.FirstOrDefault();
}
+ ///
+ /// Decides whether a candidate is the client the caller asked for. The image path is the
+ /// authority when it is readable: a Unix kernel truncates the reported process name, so the
+ /// path is the only place the full name survives for a client such as GeneralsOnlineZH_60.
+ /// The reported name is the fallback for a process whose image path cannot be read.
+ ///
+ /// The candidate to test.
+ /// The expected process name, without extension.
+ /// when the candidate carries the expected name.
+ private static bool NameMatches(GameProcessCandidate candidate, string processName)
+ {
+ var imageName = candidate.ExecutablePath is null ? null : Path.GetFileName(candidate.ExecutablePath);
+
+ if (!string.IsNullOrEmpty(imageName))
+ {
+ // A Unix binary carries no extension and may legitimately contain dots, so both
+ // spellings of the file name have to be offered before the candidate is rejected.
+ return imageName.Equals(processName, StringComparison.OrdinalIgnoreCase)
+ || Path.GetFileNameWithoutExtension(imageName).Equals(processName, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return candidate.ProcessName.Equals(processName, StringComparison.OrdinalIgnoreCase)
+ || candidate.ProcessName.Equals(GetDiscoveryName(processName), StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Decides whether a candidate runs from the expected directory. The image path is fully
+ /// symlink-resolved by the operating system while a configured working directory is not, so a
+ /// plain string comparison misses a workspace reached through a link — the /var against
+ /// /private/var spelling on macOS being the everyday case. Canonicalizing through the
+ /// filesystem also settles case: the on-disk spelling of every component is recovered under the
+ /// platform's own matching rules, so accepts a
+ /// differently cased path on a case-insensitive volume and still keeps two directories that
+ /// differ only in case apart on a case-sensitive one.
+ ///
+ /// The candidate to test.
+ /// The directory the game must run from.
+ /// when the candidate runs from that directory.
private static bool ResidesIn(GameProcessCandidate candidate, string workingDirectory)
{
if (candidate.ExecutablePath is null)
@@ -57,7 +170,134 @@ private static bool ResidesIn(GameProcessCandidate candidate, string workingDire
}
var directory = Path.GetDirectoryName(candidate.ExecutablePath);
- return directory != null && Normalize(directory).Equals(Normalize(workingDirectory), StringComparison.OrdinalIgnoreCase);
+ if (string.IsNullOrEmpty(directory))
+ {
+ return false;
+ }
+
+ var candidateDirectory = Normalize(directory);
+ var expectedDirectory = Normalize(workingDirectory);
+
+ if (candidateDirectory.Equals(expectedDirectory, PathHelper.PathComparison))
+ {
+ return true;
+ }
+
+ return Normalize(Canonicalize(candidateDirectory))
+ .Equals(Normalize(Canonicalize(expectedDirectory)), PathHelper.PathComparison);
+ }
+
+ ///
+ /// Rewrites a path so every component carries its real on-disk name and no component is a
+ /// symbolic link. A component that cannot be inspected is left exactly as it was spelled, so a
+ /// missing or malformed path degrades to the plain comparison instead of aborting the scan.
+ ///
+ /// The path to canonicalize.
+ /// The canonicalized path.
+ private static string Canonicalize(string path) => Canonicalize(path, depth: 0);
+
+ private static string Canonicalize(string path, int depth)
+ {
+ var full = TryGetFullPath(path);
+ if (full is null)
+ {
+ return path;
+ }
+
+ var resolved = Path.GetPathRoot(full);
+ if (string.IsNullOrEmpty(resolved))
+ {
+ return path;
+ }
+
+ var segments = full[resolved.Length..].Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var segment in segments)
+ {
+ resolved = ResolveSegment(resolved, segment, depth);
+ }
+
+ return resolved;
+ }
+
+ private static string ResolveSegment(string parent, string segment, int depth)
+ {
+ var combined = Path.Combine(parent, OnDiskName(parent, segment));
+ if (depth >= IoConstants.MaxSymbolicLinkResolutionDepth)
+ {
+ return combined;
+ }
+
+ var target = TryResolveLinkTarget(combined);
+
+ // A link target is spelled by whoever created the link, so it may be reached through
+ // links of its own and has to go back through the same walk.
+ return target is null ? combined : Canonicalize(target, depth + 1);
+ }
+
+ private static string? TryResolveLinkTarget(string path)
+ {
+ try
+ {
+ return Directory.ResolveLinkTarget(path, returnFinalTarget: true)?.FullName;
+ }
+ catch (IOException)
+ {
+ // An unreadable or missing component leaves the caller's spelling in place.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // An unreadable or missing component leaves the caller's spelling in place.
+ }
+ catch (ArgumentException)
+ {
+ // A malformed component leaves the caller's spelling in place.
+ }
+
+ return null;
+ }
+
+ ///
+ /// Recovers the spelling a directory entry actually has on disk. Enumeration matches under the
+ /// platform's own case rules, so this changes nothing on a case-sensitive volume and folds case
+ /// on a volume that does.
+ ///
+ /// The directory to look in.
+ /// The name as it was spelled by the caller.
+ /// The on-disk name, or when it cannot be established.
+ private static string OnDiskName(string parent, string segment)
+ {
+ try
+ {
+ var entries = Directory.GetFileSystemEntries(parent, segment);
+ if (entries.Length == 1)
+ {
+ var onDisk = Path.GetFileName(entries[0]);
+
+ // A name is also a search pattern, so an entry matched through a wildcard has to be
+ // rejected rather than substituted for a name that was never on disk.
+ if (onDisk.Equals(segment, StringComparison.OrdinalIgnoreCase))
+ {
+ return onDisk;
+ }
+ }
+ }
+ catch (IOException)
+ {
+ // An unreadable directory leaves the caller's spelling in place.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // An unreadable directory leaves the caller's spelling in place.
+ }
+ catch (ArgumentException)
+ {
+ // A malformed name leaves the caller's spelling in place.
+ }
+
+ return segment;
}
private static string Normalize(string path)
@@ -65,9 +305,17 @@ private static string Normalize(string path)
// MainModule.FileName is always absolute and fully resolved, while the configured working
// directory is neither guaranteed. Canonicalize first so a relative spelling or a "."
// segment does not read as a different directory and abandon an adoptable process.
+ return (TryGetFullPath(path) ?? path)
+ .Replace(Path.DirectorySeparatorChar, '/')
+ .Replace(Path.AltDirectorySeparatorChar, '/')
+ .TrimEnd('/');
+ }
+
+ private static string? TryGetFullPath(string path)
+ {
try
{
- path = Path.GetFullPath(path);
+ return Path.GetFullPath(path);
}
catch (ArgumentException)
{
@@ -82,9 +330,6 @@ private static string Normalize(string path)
// A malformed path compares on its original spelling rather than aborting the scan.
}
- return path
- .Replace(Path.DirectorySeparatorChar, '/')
- .Replace(Path.AltDirectorySeparatorChar, '/')
- .TrimEnd('/');
+ return null;
}
}
diff --git a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs
index a2c705df2..0027abe51 100644
--- a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs
+++ b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs
@@ -89,10 +89,17 @@ public static void ApplyFromGeneralsOnlineSettings(GeneralsOnlineSettings settin
/// Applies settings from a GameProfile to a GeneralsOnlineSettings object.
/// Used by GameLauncher to prepare settings.json for launch.
///
+ ///
+ /// Only the fields the profile declares are written, as does for
+ /// Options.ini. The caller passes the settings already on disk, and anything the profile leaves
+ /// unset is the GeneralsOnline client's own configuration, which a launch must not overwrite.
+ ///
/// The GameProfile source.
/// The GeneralsOnlineSettings to populate.
public static void ApplyToGeneralsOnlineSettings(GameProfile profile, GeneralsOnlineSettings settings)
{
+ settings.EnsureNestedSectionsInitialized();
+
ApplyGoGeneralSettings(profile, settings);
ApplyGoCameraAndChatSettings(profile, settings);
ApplyGoRenderAndDebugSettings(profile, settings);
@@ -607,60 +614,60 @@ private static void ApplyNetworkFromOptions(IniOptions options, GameProfile prof
private static void ApplyGoGeneralSettings(GameProfile profile, GeneralsOnlineSettings settings)
{
- settings.ShowFps = profile.GoShowFps ?? false;
- settings.ShowPing = profile.GoShowPing ?? true;
- settings.ShowPlayerRanks = profile.GoShowPlayerRanks ?? true;
- settings.AutoLogin = profile.GoAutoLogin ?? false;
- settings.RememberUsername = profile.GoRememberUsername ?? true;
- settings.EnableNotifications = profile.GoEnableNotifications ?? true;
- settings.EnableSoundNotifications = profile.GoEnableSoundNotifications ?? true;
- settings.ChatFontSize = profile.GoChatFontSize ?? 12;
+ if (profile.GoShowFps.HasValue) settings.ShowFps = profile.GoShowFps.Value;
+ if (profile.GoShowPing.HasValue) settings.ShowPing = profile.GoShowPing.Value;
+ if (profile.GoShowPlayerRanks.HasValue) settings.ShowPlayerRanks = profile.GoShowPlayerRanks.Value;
+ if (profile.GoAutoLogin.HasValue) settings.AutoLogin = profile.GoAutoLogin.Value;
+ if (profile.GoRememberUsername.HasValue) settings.RememberUsername = profile.GoRememberUsername.Value;
+ if (profile.GoEnableNotifications.HasValue) settings.EnableNotifications = profile.GoEnableNotifications.Value;
+ if (profile.GoEnableSoundNotifications.HasValue) settings.EnableSoundNotifications = profile.GoEnableSoundNotifications.Value;
+ if (profile.GoChatFontSize.HasValue) settings.ChatFontSize = profile.GoChatFontSize.Value;
}
private static void ApplyGoCameraAndChatSettings(GameProfile profile, GeneralsOnlineSettings settings)
{
- settings.Camera.MaxHeightOnlyWhenLobbyHost = profile.GoCameraMaxHeightOnlyWhenLobbyHost ?? 310.0f;
- settings.Camera.MinHeight = profile.GoCameraMinHeight ?? 310.0f;
- settings.Camera.MoveSpeedRatio = profile.GoCameraMoveSpeedRatio ?? 1.5f;
- settings.Chat.DurationSecondsUntilFadeOut = profile.GoChatDurationSecondsUntilFadeOut ?? 30;
+ if (profile.GoCameraMaxHeightOnlyWhenLobbyHost.HasValue) settings.Camera.MaxHeightOnlyWhenLobbyHost = profile.GoCameraMaxHeightOnlyWhenLobbyHost.Value;
+ if (profile.GoCameraMinHeight.HasValue) settings.Camera.MinHeight = profile.GoCameraMinHeight.Value;
+ if (profile.GoCameraMoveSpeedRatio.HasValue) settings.Camera.MoveSpeedRatio = profile.GoCameraMoveSpeedRatio.Value;
+ if (profile.GoChatDurationSecondsUntilFadeOut.HasValue) settings.Chat.DurationSecondsUntilFadeOut = profile.GoChatDurationSecondsUntilFadeOut.Value;
}
private static void ApplyGoRenderAndDebugSettings(GameProfile profile, GeneralsOnlineSettings settings)
{
- settings.Debug.VerboseLogging = profile.GoDebugVerboseLogging ?? false;
- settings.Render.FpsLimit = profile.GoRenderFpsLimit ?? 144;
- settings.Render.LimitFramerate = profile.GoRenderLimitFramerate ?? true;
- settings.Render.StatsOverlay = profile.GoRenderStatsOverlay ?? true;
+ if (profile.GoDebugVerboseLogging.HasValue) settings.Debug.VerboseLogging = profile.GoDebugVerboseLogging.Value;
+ if (profile.GoRenderFpsLimit.HasValue) settings.Render.FpsLimit = profile.GoRenderFpsLimit.Value;
+ if (profile.GoRenderLimitFramerate.HasValue) settings.Render.LimitFramerate = profile.GoRenderLimitFramerate.Value;
+ if (profile.GoRenderStatsOverlay.HasValue) settings.Render.StatsOverlay = profile.GoRenderStatsOverlay.Value;
}
private static void ApplyGoSocialSettings(GameProfile profile, GeneralsOnlineSettings settings)
{
- settings.Social.NotificationFriendComesOnlineGameplay = profile.GoSocialNotificationFriendComesOnlineGameplay ?? true;
- settings.Social.NotificationFriendComesOnlineMenus = profile.GoSocialNotificationFriendComesOnlineMenus ?? true;
- settings.Social.NotificationFriendGoesOfflineGameplay = profile.GoSocialNotificationFriendGoesOfflineGameplay ?? true;
- settings.Social.NotificationFriendGoesOfflineMenus = profile.GoSocialNotificationFriendGoesOfflineMenus ?? true;
- settings.Social.NotificationPlayerAcceptsRequestGameplay = profile.GoSocialNotificationPlayerAcceptsRequestGameplay ?? true;
- settings.Social.NotificationPlayerAcceptsRequestMenus = profile.GoSocialNotificationPlayerAcceptsRequestMenus ?? true;
- settings.Social.NotificationPlayerSendsRequestGameplay = profile.GoSocialNotificationPlayerSendsRequestGameplay ?? true;
- settings.Social.NotificationPlayerSendsRequestMenus = profile.GoSocialNotificationPlayerSendsRequestMenus ?? true;
+ if (profile.GoSocialNotificationFriendComesOnlineGameplay.HasValue) settings.Social.NotificationFriendComesOnlineGameplay = profile.GoSocialNotificationFriendComesOnlineGameplay.Value;
+ if (profile.GoSocialNotificationFriendComesOnlineMenus.HasValue) settings.Social.NotificationFriendComesOnlineMenus = profile.GoSocialNotificationFriendComesOnlineMenus.Value;
+ if (profile.GoSocialNotificationFriendGoesOfflineGameplay.HasValue) settings.Social.NotificationFriendGoesOfflineGameplay = profile.GoSocialNotificationFriendGoesOfflineGameplay.Value;
+ if (profile.GoSocialNotificationFriendGoesOfflineMenus.HasValue) settings.Social.NotificationFriendGoesOfflineMenus = profile.GoSocialNotificationFriendGoesOfflineMenus.Value;
+ if (profile.GoSocialNotificationPlayerAcceptsRequestGameplay.HasValue) settings.Social.NotificationPlayerAcceptsRequestGameplay = profile.GoSocialNotificationPlayerAcceptsRequestGameplay.Value;
+ if (profile.GoSocialNotificationPlayerAcceptsRequestMenus.HasValue) settings.Social.NotificationPlayerAcceptsRequestMenus = profile.GoSocialNotificationPlayerAcceptsRequestMenus.Value;
+ if (profile.GoSocialNotificationPlayerSendsRequestGameplay.HasValue) settings.Social.NotificationPlayerSendsRequestGameplay = profile.GoSocialNotificationPlayerSendsRequestGameplay.Value;
+ if (profile.GoSocialNotificationPlayerSendsRequestMenus.HasValue) settings.Social.NotificationPlayerSendsRequestMenus = profile.GoSocialNotificationPlayerSendsRequestMenus.Value;
}
private static void ApplyGoTshSettings(GameProfile profile, GeneralsOnlineSettings settings)
{
- settings.ArchiveReplays = profile.TshArchiveReplays ?? false;
- settings.MoneyTransactionVolume = profile.TshMoneyTransactionVolume ?? 50;
- settings.ShowMoneyPerMinute = profile.TshShowMoneyPerMinute ?? false;
- settings.PlayerObserverEnabled = profile.TshPlayerObserverEnabled ?? GameSettingsTheSuperHackersConstants.DefaultPlayerObserverEnabled;
- settings.SystemTimeFontSize = profile.TshSystemTimeFontSize ?? GameSettingsTheSuperHackersConstants.DefaultSystemTimeFontSize;
- settings.NetworkLatencyFontSize = profile.TshNetworkLatencyFontSize ?? GameSettingsTheSuperHackersConstants.DefaultNetworkLatencyFontSize;
- settings.RenderFpsFontSize = profile.TshRenderFpsFontSize ?? GameSettingsTheSuperHackersConstants.DefaultRenderFpsFontSize;
- settings.ResolutionFontAdjustment = profile.TshResolutionFontAdjustment ?? GameSettingsTheSuperHackersConstants.DefaultResolutionFontAdjustment;
- settings.CursorCaptureEnabledInFullscreenGame = profile.TshCursorCaptureEnabledInFullscreenGame ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenGame;
- settings.CursorCaptureEnabledInFullscreenMenu = profile.TshCursorCaptureEnabledInFullscreenMenu ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInFullscreenMenu;
- settings.CursorCaptureEnabledInWindowedGame = profile.TshCursorCaptureEnabledInWindowedGame ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedGame;
- settings.CursorCaptureEnabledInWindowedMenu = profile.TshCursorCaptureEnabledInWindowedMenu ?? GameSettingsTheSuperHackersConstants.DefaultCursorCaptureEnabledInWindowedMenu;
- settings.ScreenEdgeScrollEnabledInFullscreenApp = profile.TshScreenEdgeScrollEnabledInFullscreenApp ?? GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInFullscreenApp;
- settings.ScreenEdgeScrollEnabledInWindowedApp = profile.TshScreenEdgeScrollEnabledInWindowedApp ?? GameSettingsTheSuperHackersConstants.DefaultScreenEdgeScrollEnabledInWindowedApp;
+ if (profile.TshArchiveReplays.HasValue) settings.ArchiveReplays = profile.TshArchiveReplays.Value;
+ if (profile.TshMoneyTransactionVolume.HasValue) settings.MoneyTransactionVolume = profile.TshMoneyTransactionVolume.Value;
+ if (profile.TshShowMoneyPerMinute.HasValue) settings.ShowMoneyPerMinute = profile.TshShowMoneyPerMinute.Value;
+ if (profile.TshPlayerObserverEnabled.HasValue) settings.PlayerObserverEnabled = profile.TshPlayerObserverEnabled.Value;
+ if (profile.TshSystemTimeFontSize.HasValue) settings.SystemTimeFontSize = profile.TshSystemTimeFontSize.Value;
+ if (profile.TshNetworkLatencyFontSize.HasValue) settings.NetworkLatencyFontSize = profile.TshNetworkLatencyFontSize.Value;
+ if (profile.TshRenderFpsFontSize.HasValue) settings.RenderFpsFontSize = profile.TshRenderFpsFontSize.Value;
+ if (profile.TshResolutionFontAdjustment.HasValue) settings.ResolutionFontAdjustment = profile.TshResolutionFontAdjustment.Value;
+ if (profile.TshCursorCaptureEnabledInFullscreenGame.HasValue) settings.CursorCaptureEnabledInFullscreenGame = profile.TshCursorCaptureEnabledInFullscreenGame.Value;
+ if (profile.TshCursorCaptureEnabledInFullscreenMenu.HasValue) settings.CursorCaptureEnabledInFullscreenMenu = profile.TshCursorCaptureEnabledInFullscreenMenu.Value;
+ if (profile.TshCursorCaptureEnabledInWindowedGame.HasValue) settings.CursorCaptureEnabledInWindowedGame = profile.TshCursorCaptureEnabledInWindowedGame.Value;
+ if (profile.TshCursorCaptureEnabledInWindowedMenu.HasValue) settings.CursorCaptureEnabledInWindowedMenu = profile.TshCursorCaptureEnabledInWindowedMenu.Value;
+ if (profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue) settings.ScreenEdgeScrollEnabledInFullscreenApp = profile.TshScreenEdgeScrollEnabledInFullscreenApp.Value;
+ if (profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue) settings.ScreenEdgeScrollEnabledInWindowedApp = profile.TshScreenEdgeScrollEnabledInWindowedApp.Value;
}
private static void ApplyVideoResolutionAndQualityToOptions(GameProfile profile, IniOptions options, ILogger? logger)
diff --git a/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
new file mode 100644
index 000000000..b19f11d94
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/HtmlTextHelper.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Net;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Provides high-performance utilities for stripping HTML tags, decoding HTML entities,
+/// and normalizing text descriptions for display across the application.
+///
+public static partial class HtmlTextHelper
+{
+ ///
+ /// Converts an HTML snippet or formatted description into clean, normalized plain text:
+ /// - Replaces <br> and block element closures (</p>, </div>, etc.) with line breaks.
+ /// - Strips all remaining HTML tags.
+ /// - Decodes HTML entities (e.g., &, ", >, ).
+ /// - Normalizes whitespace and excessive blank lines.
+ /// - Uses the platform newline format.
+ ///
+ /// The raw HTML or formatted text string to normalize.
+ /// Normalized plain text, or empty string if input is null or whitespace.
+ public static string NormalizeHtml(string? html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ {
+ return string.Empty;
+ }
+
+ // 0. Remove script and style elements along with their contents
+ var text = ScriptTagRegex().Replace(html, string.Empty);
+ text = StyleTagRegex().Replace(text, string.Empty);
+
+ // 1. Convert ]
tags to newline
+ text = BrTagRegex().Replace(text, "\n");
+
+ // 2. Convert paragraph closing tags to double newline for paragraph separation
+ text = ParagraphCloseTagRegex().Replace(text, "\n\n");
+
+ // 3. Convert other block-level closing tags and
tags to newline
+ text = BlockCloseTagRegex().Replace(text, "\n");
+
+ // 4. Strip all remaining HTML/XML tags
+ text = HtmlTagRegex().Replace(text, string.Empty);
+
+ // 5. Decode HTML entities ( , >, ", ', numeric entities, etc.)
+ text = WebUtility.HtmlDecode(text);
+
+ // 6. Normalize non-breaking spaces and line endings
+ text = text.Replace('\u00A0', ' ')
+ .Replace("\r\n", "\n")
+ .Replace('\r', '\n');
+
+ // 7. Clean trailing whitespace on lines and collapse excess blank lines
+ text = TrailingWhitespaceBeforeNewlineRegex().Replace(text, "\n");
+ text = ExcessBlankLinesRegex().Replace(text, "\n\n");
+
+ // 8. Trim and unify with environment newline
+ text = text.Trim();
+ text = text.Replace("\n", Environment.NewLine);
+
+ return text;
+ }
+
+ ///
+ /// Converts an HTML snippet or multi-line text into a single-line summary without HTML tags,
+ /// collapsing all whitespace runs into a single space, and optionally truncating with an ellipsis.
+ ///
+ /// The input HTML or text string.
+ /// Optional maximum character length including ellipsis.
+ /// A single-line plain text summary.
+ public static string CleanToSingleLine(string? htmlOrText, int? maxLength = null)
+ {
+ if (string.IsNullOrWhiteSpace(htmlOrText))
+ {
+ return string.Empty;
+ }
+
+ // Strip HTML if tags exist, decode entities, and normalize
+ var text = NormalizeHtml(htmlOrText);
+
+ // Collapse all newlines, tabs, and multiple spaces into a single space
+ text = MultiWhitespaceRegex().Replace(text, " ").Trim();
+
+ if (maxLength.HasValue && maxLength.Value > 0 && text.Length > maxLength.Value)
+ {
+ return TruncateWithEllipsis(text, maxLength.Value);
+ }
+
+ return text;
+ }
+
+ ///
+ /// Truncates a string to a specified maximum length and appends an ellipsis ("...") if truncated.
+ ///
+ /// The text to truncate.
+ /// The maximum allowed length (including the ellipsis).
+ /// The truncated text with an ellipsis if it exceeded maxLength, or the original text.
+ public static string TruncateWithEllipsis(string? text, int maxLength)
+ {
+ if (string.IsNullOrWhiteSpace(text) || maxLength <= 0)
+ {
+ return string.Empty;
+ }
+
+ if (text.Length <= maxLength)
+ {
+ return text;
+ }
+
+ if (maxLength <= 3)
+ {
+ return text[..maxLength];
+ }
+
+ return string.Concat(text.AsSpan(0, maxLength - 3), "...");
+ }
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ScriptTagRegex();
+
+ [GeneratedRegex(@"", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex StyleTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BrTagRegex();
+
+ [GeneratedRegex(@"
", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex ParagraphCloseTagRegex();
+
+ [GeneratedRegex(@"?(?:div|li|h[1-6]|tr|section|article|blockquote|header|footer|hr)\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex BlockCloseTagRegex();
+
+ [GeneratedRegex(@"?[A-Za-z][^>]*>", RegexOptions.CultureInvariant)]
+ private static partial Regex HtmlTagRegex();
+
+ [GeneratedRegex(@"[ \t]+\n", RegexOptions.CultureInvariant)]
+ private static partial Regex TrailingWhitespaceBeforeNewlineRegex();
+
+ [GeneratedRegex(@"(?:\n){3,}", RegexOptions.CultureInvariant)]
+ private static partial Regex ExcessBlankLinesRegex();
+
+ [GeneratedRegex(@"\s+", RegexOptions.CultureInvariant)]
+ private static partial Regex MultiWhitespaceRegex();
+}
diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs
index e249b1e1e..8b59e6eff 100644
--- a/GenHub/GenHub.Core/Helpers/PathHelper.cs
+++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs
@@ -1,5 +1,6 @@
using System;
using System.IO;
+using System.Security;
namespace GenHub.Core.Helpers;
@@ -26,6 +27,44 @@ public static class PathHelper
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal;
+ ///
+ /// Determines whether two paths point at the same filesystem location, normalizing both and
+ /// comparing them with the platform-appropriate case sensitivity.
+ ///
+ /// The first path.
+ /// The second path.
+ /// when both paths resolve to the same location.
+ public static bool AreSamePath(string first, string second)
+ {
+ try
+ {
+ return string.Equals(
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(first)),
+ Path.TrimEndingDirectorySeparator(Path.GetFullPath(second)),
+ PathComparison);
+ }
+ catch (IOException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (SecurityException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (NotSupportedException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ catch (ArgumentException)
+ {
+ return string.Equals(first, second, PathComparison);
+ }
+ }
+
///
/// Gets the parent directory of a path, with fallback to the path itself if at drive root.
///
@@ -39,4 +78,133 @@ public static string GetSafeParentDirectory(string path)
var parent = Path.GetDirectoryName(path);
return string.IsNullOrEmpty(parent) ? path : parent;
}
+
+ ///
+ /// Determines whether a candidate path resolves to a location inside a base directory.
+ /// Both paths are fully normalized first, so .. segments, redundant separators and
+ /// rooted candidates cannot escape the base directory. Because normalization is textual and a
+ /// symbolic link or junction redirects a path that reads as contained, both sides are also
+ /// compared after their links are followed; a path that cannot be resolved — because it does
+ /// not exist yet, or the filesystem refuses the query — is compared as written.
+ ///
+ /// The directory that must contain the candidate path.
+ /// The path to test for containment.
+ /// when the candidate resolves inside the base directory; otherwise, .
+ public static bool IsPathWithinDirectory(string baseDirectory, string candidatePath)
+ {
+ if (string.IsNullOrWhiteSpace(baseDirectory) || string.IsNullOrWhiteSpace(candidatePath))
+ {
+ return false;
+ }
+
+ try
+ {
+ var normalizedRoot = Path.GetFullPath(baseDirectory);
+ var normalizedTarget = Path.GetFullPath(candidatePath);
+
+ return IsContained(normalizedRoot, normalizedTarget) &&
+ IsContained(FollowLinks(normalizedRoot), FollowLinks(normalizedTarget));
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Normalizes a relative path by standardizing directory separators and removing leading separators.
+ ///
+ /// The relative path to normalize.
+ /// The normalized relative path.
+ public static string NormalizeRelativePath(string relativePath)
+ {
+ if (string.IsNullOrWhiteSpace(relativePath))
+ {
+ return string.Empty;
+ }
+
+ return relativePath
+ .Replace('\\', '/')
+ .TrimStart('/')
+ .Replace('/', Path.DirectorySeparatorChar);
+ }
+
+ private static bool IsContained(string normalizedRoot, string normalizedTarget)
+ {
+ var relative = Path.GetRelativePath(normalizedRoot, normalizedTarget);
+
+ return !relative.Equals("..", StringComparison.Ordinal) &&
+ !relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) &&
+ !relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) &&
+ !Path.IsPathRooted(relative);
+ }
+
+ private static string FollowLinks(string fullPath, int maxDepth = 32)
+ {
+ if (maxDepth <= 0)
+ {
+ return fullPath;
+ }
+
+ try
+ {
+ var normalized = Path.GetFullPath(fullPath);
+ var root = Path.GetPathRoot(normalized);
+ if (string.IsNullOrEmpty(root))
+ {
+ return normalized;
+ }
+
+ var relativeFromRoot = Path.GetRelativePath(root, normalized);
+ if (relativeFromRoot == "." || relativeFromRoot.Length == 0)
+ {
+ return root;
+ }
+
+ var segments = relativeFromRoot.Split(
+ [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar],
+ StringSplitOptions.RemoveEmptyEntries);
+
+ var current = root;
+ foreach (var segment in segments)
+ {
+ current = Path.Combine(current, segment);
+
+ if (Directory.Exists(current) || File.Exists(current))
+ {
+ FileSystemInfo info = Directory.Exists(current)
+ ? new DirectoryInfo(current)
+ : new FileInfo(current);
+
+ var target = info.ResolveLinkTarget(returnFinalTarget: true);
+ if (target != null)
+ {
+ current = FollowLinks(target.FullName, maxDepth - 1);
+ }
+ }
+ }
+
+ return Path.GetFullPath(current);
+ }
+ catch (IOException)
+ {
+ return fullPath;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return fullPath;
+ }
+ catch (SecurityException)
+ {
+ return fullPath;
+ }
+ catch (NotSupportedException)
+ {
+ return fullPath;
+ }
+ catch (ArgumentException)
+ {
+ return fullPath;
+ }
+ }
}
diff --git a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs
index 38b0ec736..2d7d3eff9 100644
--- a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs
+++ b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs
@@ -12,6 +12,10 @@ public interface IAppConfiguration
/// The root application data path.
string GetConfiguredDataPath();
+ /// Gets the root application data path used by releases up to v0.0.3, which stored data under the roaming profile.
+ /// The legacy root application data path.
+ string GetLegacyConfiguredDataPath();
+
/// Gets the default workspace path for GenHub.
/// The default workspace path.
string GetDefaultWorkspacePath();
diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
index d97f2f8cf..93a3536b0 100644
--- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
@@ -64,6 +64,18 @@ public interface IConfigurationProviderService
/// True if auto-check is enabled; otherwise, false.
bool GetAutoCheckForUpdatesOnStartup();
+ ///
+ /// Gets whether to automatically check for updates periodically.
+ ///
+ /// True if periodic auto-check is enabled; otherwise, false.
+ bool GetAutoCheckForUpdatesPeriodically();
+
+ ///
+ /// Gets the interval in minutes for periodic update checks.
+ ///
+ /// The update check interval in minutes.
+ int GetPeriodicUpdateCheckIntervalMinutes();
+
///
/// Gets whether detailed logging is enabled.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs
new file mode 100644
index 000000000..74f9cd2a3
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationInstructionsService.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Service for validating and executing manifest-declared installation steps.
+///
+public interface IInstallationInstructionsService
+{
+ ///
+ /// Executes post-installation steps for the specified manifest, optionally forcing run-once steps.
+ ///
+ /// The content manifest declaring post-installation steps.
+ /// The working directory containing the content files.
+ /// The provider source name supplying the content, used for step authorization.
+ /// Whether to force execution of steps marked as run-once even if already executed.
+ /// Optional progress reporter for acquisition status.
+ /// A token to cancel the operation.
+ /// A result indicating whether all post-installation steps succeeded.
+ Task ExecutePostInstallStepsAsync(
+ ContentManifest manifest,
+ string workingDirectory,
+ string? providerSource = null,
+ bool force = false,
+ IProgress? progress = null,
+ CancellationToken cancellationToken = default);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs
new file mode 100644
index 000000000..f4ef7d82f
--- /dev/null
+++ b/GenHub/GenHub.Core/Interfaces/Content/IInstallationStepPrecondition.cs
@@ -0,0 +1,27 @@
+using GenHub.Core.Models.Manifest;
+
+namespace GenHub.Core.Interfaces.Content;
+
+///
+/// Defines a precondition or environment check for an installation step.
+/// Allows domain-specific probes (e.g. system service or installed anti-cheat detection)
+/// to determine whether a step is already satisfied.
+///
+public interface IInstallationStepPrecondition
+{
+ ///
+ /// Determines whether this precondition can handle the specified installation step.
+ ///
+ /// The installation step to inspect.
+ /// The content manifest declaring the step.
+ /// if this precondition applies to the step; otherwise, .
+ bool CanHandle(InstallationStep step, ContentManifest manifest);
+
+ ///
+ /// Determines whether the step's goal is already fulfilled in the local environment.
+ ///
+ /// The installation step to evaluate.
+ /// The content manifest declaring the step.
+ /// if the step is already fulfilled; otherwise, .
+ bool IsAlreadyFulfilled(InstallationStep step, ContentManifest manifest);
+}
diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
index 84a4d3773..9b26f26b2 100644
--- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
@@ -20,6 +20,7 @@ public interface ILocalContentService
/// Optional original source path of the content.
/// Optional progress reporter for tracking manifest creation.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the created manifest or errors.
Task> CreateLocalContentManifestAsync(
string directoryPath,
@@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Adds local content by creating and storing a manifest.
@@ -66,6 +68,7 @@ Task> AddLocalContentAsync(
/// Optional original source path of the content.
/// Optional progress reporter.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the updated manifest.
Task> UpdateLocalContentManifestAsync(
string existingManifestId,
@@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Gets the allowed content types for local content creation.
diff --git a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
index e707f019e..58985d1e0 100644
--- a/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
+++ b/GenHub/GenHub.Core/Interfaces/Manifest/IContentManifestBuilder.cs
@@ -86,6 +86,13 @@ public interface IContentManifestBuilder
/// The builder instance for chaining.
IContentManifestBuilder WithPublisher(string name, string website = "", string supportUrl = "", string contactEmail = "", string publisherType = "");
+ ///
+ /// Sets publisher information from an existing instance.
+ ///
+ /// The publisher information.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithPublisher(PublisherInfo publisher);
+
///
/// Sets content metadata.
///
@@ -207,26 +214,42 @@ IContentManifestBuilder AddDependency(
IContentManifestBuilder WithInstallationInstructions(WorkspaceStrategy workspaceStrategy = WorkspaceConstants.DefaultWorkspaceStrategy);
///
- /// Adds a pre-installation step.
+ /// Sets the complete installation instructions object for the manifest.
///
- /// Step name.
- /// Command to execute.
- /// Command arguments.
- /// Working directory for the command.
- /// Whether elevation is required.
+ /// The installation instructions object.
/// The builder instance for chaining.
- IContentManifestBuilder AddPreInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false);
+ IContentManifestBuilder WithInstallationInstructions(InstallationInstructions installationInstructions);
///
/// Adds a post-installation step.
///
/// Step name.
- /// Command to execute.
- /// Command arguments.
- /// Working directory for the command.
+ /// The kind of installation step to execute.
+ /// Target relative path within workspace.
+ /// Command arguments for executable steps.
+ /// Destination relative path for rename operations.
/// Whether elevation is required.
+ /// Optional user-facing status message.
+ /// Whether to execute only once and skip on future updates.
+ /// Optional unique step key for tracking execution.
/// The builder instance for chaining.
- IContentManifestBuilder AddPostInstallStep(string name, string command, List? arguments = null, string workingDirectory = "", bool requiresElevation = false);
+ IContentManifestBuilder AddPostInstallStep(
+ string name,
+ InstallationStepKind kind,
+ string? targetRelativePath = null,
+ List? arguments = null,
+ string? destinationRelativePath = null,
+ bool requiresElevation = false,
+ string? statusMessage = null,
+ bool runOnce = false,
+ string? stepKey = null);
+
+ ///
+ /// Adds a post-installation step using an existing instance.
+ ///
+ /// The installation step to add.
+ /// The builder instance for chaining.
+ IContentManifestBuilder AddPostInstallStep(InstallationStep step);
///
/// Adds a content reference for cross-publisher linking.
@@ -244,6 +267,13 @@ IContentManifestBuilder AddContentReference(
string minVersion = "",
string maxVersion = "");
+ ///
+ /// Sets content references for cross-publisher linking.
+ ///
+ /// The collection of content references.
+ /// The builder instance for chaining.
+ IContentManifestBuilder WithContentReferences(IEnumerable contentReferences);
+
///
/// Adds a file patching operation to the manifest.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
index 34a88df40..55800dd3e 100644
--- a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
@@ -28,6 +28,11 @@ public interface INotificationService
///
IObservable NotificationHistory { get; }
+ ///
+ /// Gets the observable stream of notification update requests.
+ ///
+ IObservable<(Guid Id, string? Title, string Message)> UpdateRequests { get; }
+
///
/// Shows an informational notification.
///
@@ -70,6 +75,14 @@ public interface INotificationService
/// The notification to show.
void Show(NotificationMessage notification);
+ ///
+ /// Updates the message and optionally the title of an active notification.
+ ///
+ /// The ID of the notification to update.
+ /// The new message content.
+ /// Optional new title. If null, the existing title is preserved.
+ void Update(Guid notificationId, string message, string? title = null);
+
///
/// Dismisses a specific notification.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs b/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs
index 6038b2efe..58b76818a 100644
--- a/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Workspace/IFileOperationsService.cs
@@ -58,6 +58,22 @@ Task VerifyFileHashAsync(
string expectedHash,
CancellationToken cancellationToken = default);
+ ///
+ /// Compares a file against an expected hash, distinguishing a genuine mismatch from a failure to
+ /// compute the hash at all. Callers that act destructively on a mismatch must use this rather
+ /// than , which collapses both outcomes into false.
+ /// A file that does not exist yields : no hash was
+ /// computed, so its absence is not evidence that its content ever differed.
+ ///
+ /// The file path.
+ /// The expected hash value.
+ /// A cancellation token.
+ /// The verification outcome.
+ Task CheckFileHashAsync(
+ string filePath,
+ string expectedHash,
+ CancellationToken cancellationToken = default);
+
///
/// Applies a patch to a target file. The patch format is determined by the implementation.
///
diff --git a/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs
new file mode 100644
index 000000000..e199ff5b3
--- /dev/null
+++ b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs
@@ -0,0 +1,12 @@
+namespace GenHub.Core.Messages;
+
+///
+/// Message sent when update settings have changed.
+///
+/// Whether to check for updates on startup.
+/// Whether to check for updates periodically.
+/// Interval in minutes between periodic update checks.
+public record UpdateSettingsChangedMessage(
+ bool AutoCheckForUpdatesOnStartup,
+ bool AutoCheckForUpdatesPeriodically,
+ int PeriodicUpdateCheckIntervalMinutes);
diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
index a8d2a9693..e9fc4bb56 100644
--- a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
+++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
@@ -50,6 +50,11 @@ public record PullRequestInfo
///
public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}";
+ ///
+ /// Gets the display title formatted with the PR number (e.g., "#123 - PR Title").
+ ///
+ public string DisplayTitle => $"#{Number} - {Title}";
+
///
/// Gets a value indicating whether this PR is still open.
///
diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
index 4b77fe175..fd4c33fe7 100644
--- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs
+++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
@@ -39,6 +39,12 @@ public class UserSettings
/// Gets or sets a value indicating whether to automatically check for updates on startup.
public bool AutoCheckForUpdatesOnStartup { get; set; } = true;
+ /// Gets or sets a value indicating whether to automatically check for updates periodically.
+ public bool AutoCheckForUpdatesPeriodically { get; set; } = true;
+
+ /// Gets or sets the interval in minutes between periodic update checks.
+ public int PeriodicUpdateCheckIntervalMinutes { get; set; } = GenHub.Core.Constants.AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
+
/// Gets or sets the timestamp of the last update check in ISO 8601 format.
public string? LastUpdateCheckTimestamp { get; set; }
@@ -89,6 +95,11 @@ public class UserSettings
///
public CasConfiguration CasConfiguration { get; set; } = new();
+ ///
+ /// Gets or sets the collection of installation step keys that have been executed on this machine.
+ ///
+ public HashSet ExecutedInstallationSteps { get; set; } = [];
+
/// Marks a property as explicitly set by the user.
/// The name of the property to mark as explicitly set.
public void MarkAsExplicitlySet(string propertyName)
@@ -96,6 +107,29 @@ public void MarkAsExplicitlySet(string propertyName)
ExplicitlySetProperties.Add(propertyName);
}
+ ///
+ /// Checks whether an installation step key has already been recorded as executed.
+ ///
+ /// The unique installation step key.
+ /// if already executed; otherwise, .
+ public bool IsInstallationStepExecuted(string stepKey)
+ {
+ return !string.IsNullOrWhiteSpace(stepKey) && ExecutedInstallationSteps != null && ExecutedInstallationSteps.Contains(stepKey);
+ }
+
+ ///
+ /// Records that an installation step key has been executed.
+ ///
+ /// The unique installation step key.
+ public void RecordInstallationStepExecuted(string stepKey)
+ {
+ if (!string.IsNullOrWhiteSpace(stepKey))
+ {
+ ExecutedInstallationSteps ??= [];
+ ExecutedInstallationSteps.Add(stepKey);
+ }
+ }
+
/// Checks if a property was explicitly set by the user.
/// The name of the property to check.
/// true if the property was explicitly set by the user; otherwise, false.
@@ -151,6 +185,8 @@ public UserSettings Clone()
MaxConcurrentDownloads = MaxConcurrentDownloads,
AllowBackgroundDownloads = AllowBackgroundDownloads,
AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup,
+ AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically,
+ PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes,
LastUpdateCheckTimestamp = LastUpdateCheckTimestamp,
EnableDetailedLogging = EnableDetailedLogging,
DefaultWorkspaceStrategy = DefaultWorkspaceStrategy,
@@ -173,6 +209,7 @@ public UserSettings Clone()
UseInstallationAdjacentStorage = UseInstallationAdjacentStorage,
ExplicitlySetProperties = [.. ExplicitlySetProperties],
CasConfiguration = (CasConfiguration?)CasConfiguration?.Clone() ?? new CasConfiguration(),
+ ExecutedInstallationSteps = ExecutedInstallationSteps != null ? [.. ExecutedInstallationSteps] : [],
SkippedUpdateVersions = SkippedUpdateVersions != null ? new Dictionary(SkippedUpdateVersions) : [],
PreferredUpdateStrategy = PreferredUpdateStrategy,
PublisherSubscriptions = PublisherSubscriptions != null
diff --git a/GenHub/GenHub.Core/Models/Enums/ContentState.cs b/GenHub/GenHub.Core/Models/Enums/ContentState.cs
new file mode 100644
index 000000000..c69305f69
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Enums/ContentState.cs
@@ -0,0 +1,23 @@
+namespace GenHub.Core.Models.Enums;
+
+///
+/// Content state for UI display - determines which button to show.
+///
+public enum ContentState
+{
+ ///
+ /// Content has not been downloaded yet. Show "Download" button.
+ ///
+ NotDownloaded,
+
+ ///
+ /// Content exists locally but a newer version is available (same publisher+name, newer date).
+ /// Show "Update" button.
+ ///
+ UpdateAvailable,
+
+ ///
+ /// Content is downloaded and up-to-date. Show "Add to Profile" dropdown.
+ ///
+ Downloaded,
+}
diff --git a/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs b/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs
new file mode 100644
index 000000000..1ce5e9797
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Enums/FileHashVerification.cs
@@ -0,0 +1,23 @@
+namespace GenHub.Core.Models.Enums;
+
+///
+/// Outcome of comparing a file's content against an expected hash.
+///
+public enum FileHashVerification
+{
+ ///
+ /// The hash could not be computed, so nothing is known about the file's content.
+ /// Callers must not treat this as evidence that the file changed.
+ ///
+ Failed = 0,
+
+ ///
+ /// The hash was computed and matches the expected value.
+ ///
+ Match = 1,
+
+ ///
+ /// The hash was computed and differs from the expected value.
+ ///
+ Mismatch = 2,
+}
diff --git a/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs
new file mode 100644
index 000000000..1389e130b
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Enums/InstallationStepKind.cs
@@ -0,0 +1,30 @@
+using System.Text.Json.Serialization;
+
+namespace GenHub.Core.Models.Enums;
+
+///
+/// Defines the supported kind of installation operation in manifest-declared installation steps.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum InstallationStepKind
+{
+ ///
+ /// Installation step kind is unknown or undefined (default).
+ ///
+ Unknown = 0,
+
+ ///
+ /// Runs a verified installer executable that exists within the manifest and workspace.
+ ///
+ RunVerifiedInstaller = 1,
+
+ ///
+ /// Removes a file within the workspace.
+ ///
+ RemoveFile = 2,
+
+ ///
+ /// Renames or moves a file within the workspace.
+ ///
+ RenameFile = 3,
+}
diff --git a/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs b/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs
index 5bab7e51b..14d6f830e 100644
--- a/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs
+++ b/GenHub/GenHub.Core/Models/Enums/WorkspaceStrategy.cs
@@ -6,27 +6,40 @@ namespace GenHub.Core.Models.Enums;
///
/// Workspace preparation strategy preference.
///
+///
+///
+/// The numeric values are part of the on-disk format. Releases up to v0.0.3 serialized workspace
+/// metadata without an enum converter, so workspaces.json holds raw ordinals in this order;
+/// they must not be reordered. Profile files are unaffected: v0.0.3 wrote the member name.
+///
+///
+/// Builds of the default branch made after v0.0.3 and before this ordering was restored wrote
+/// ordinals under a reordered enum, so numbers they persisted are now read as a different member
+/// (0 meant HardLink there and means SymlinkOnly here). No release is affected, but such an
+/// install should have its workspaces.json and profile strategies checked after upgrading.
+///
+///
[JsonConverter(typeof(JsonWorkspaceStrategyConverter))]
public enum WorkspaceStrategy
{
- ///
- /// Hard link strategy - creates hard links where possible, copies otherwise. Space-efficient, requires same volume.
- /// Default strategy for new profiles.
- ///
- HardLink = 0,
-
///
/// Symlink only strategy - creates symbolic links to all files. Minimal disk usage, requires admin rights.
///
- SymlinkOnly = 1,
+ SymlinkOnly = 0,
///
/// Full copy strategy - copies all files to workspace. Maximum compatibility and isolation, highest disk usage.
///
- FullCopy = 2,
+ FullCopy = 1,
///
/// Hybrid copy/symlink strategy - copies essential files, symlinks others. Balanced disk usage and compatibility.
///
- HybridCopySymlink = 3,
+ HybridCopySymlink = 2,
+
+ ///
+ /// Hard link strategy - creates hard links where possible, copies otherwise. Space-efficient, requires same volume.
+ /// Default strategy for new profiles.
+ ///
+ HardLink = 3,
}
diff --git a/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs b/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs
index 1180fe768..b727c4383 100644
--- a/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs
+++ b/GenHub/GenHub.Core/Models/GameSettings/GeneralsOnlineSettings.cs
@@ -1,3 +1,8 @@
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using GenHub.Core.Constants;
+
namespace GenHub.Core.Models.GameSettings;
/// GeneralsOnline game client settings (inherits TheSuperHackers settings plus GeneralsOnline-specific options).
@@ -7,25 +12,25 @@ public class GeneralsOnlineSettings : TheSuperHackersSettings
public bool ShowFps { get; set; }
/// Gets or sets a value indicating whether to show ping/latency.
- public bool ShowPing { get; set; } = true;
+ public bool ShowPing { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultShowPing;
/// Gets or sets a value indicating whether to enable auto-login.
public bool AutoLogin { get; set; }
/// Gets or sets a value indicating whether to remember username.
- public bool RememberUsername { get; set; } = true;
+ public bool RememberUsername { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultRememberUsername;
/// Gets or sets a value indicating whether to enable notifications.
- public bool EnableNotifications { get; set; } = true;
+ public bool EnableNotifications { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultEnableNotifications;
/// Gets or sets the chat font size.
- public int ChatFontSize { get; set; } = 12;
+ public int ChatFontSize { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultChatFontSize;
/// Gets or sets a value indicating whether to enable sound notifications.
- public bool EnableSoundNotifications { get; set; } = true;
+ public bool EnableSoundNotifications { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultEnableSoundNotifications;
/// Gets or sets a value indicating whether to show player ranks.
- public bool ShowPlayerRanks { get; set; } = true;
+ public bool ShowPlayerRanks { get; set; } = GameSettingsGeneralsOnlineConstants.DefaultShowPlayerRanks;
/// Gets or sets the camera settings.
public CameraSettings Camera { get; set; } = new();
@@ -42,6 +47,27 @@ public class GeneralsOnlineSettings : TheSuperHackersSettings
/// Gets or sets the social notification settings.
public SocialSettings Social { get; set; } = new();
+ ///
+ /// Gets or sets the settings.json keys this model does not declare. GenHub rewrites the
+ /// GeneralsOnline client's own settings.json wholesale, so without this the client would
+ /// lose every option GenHub has no property for.
+ ///
+ [JsonExtensionData]
+ public Dictionary AdditionalSettings { get; set; } = [];
+
+ ///
+ /// Replaces nested sections that a settings.json spelled as an explicit null, which is valid
+ /// JSON and overwrites the initializers, so that merging into this instance cannot throw.
+ ///
+ public void EnsureNestedSectionsInitialized()
+ {
+ Camera ??= new CameraSettings();
+ Chat ??= new ChatSettings();
+ Debug ??= new DebugSettings();
+ Render ??= new RenderSettings();
+ Social ??= new SocialSettings();
+ }
+
/// Nested camera settings.
public class CameraSettings
{
@@ -53,6 +79,10 @@ public class CameraSettings
/// Gets or sets the camera move speed ratio.
public float MoveSpeedRatio { get; set; } = 1.5f;
+
+ /// Gets or sets the camera keys this model does not declare, so they survive a rewrite.
+ [JsonExtensionData]
+ public Dictionary AdditionalSettings { get; set; } = [];
}
/// Nested chat settings.
@@ -60,6 +90,10 @@ public class ChatSettings
{
/// Gets or sets the chat duration in seconds until fade out.
public int DurationSecondsUntilFadeOut { get; set; } = 30;
+
+ /// Gets or sets the chat keys this model does not declare, so they survive a rewrite.
+ [JsonExtensionData]
+ public Dictionary AdditionalSettings { get; set; } = [];
}
/// Nested debug settings.
@@ -67,6 +101,10 @@ public class DebugSettings
{
/// Gets or sets a value indicating whether debug verbose logging is enabled.
public bool VerboseLogging { get; set; }
+
+ /// Gets or sets the debug keys this model does not declare, so they survive a rewrite.
+ [JsonExtensionData]
+ public Dictionary AdditionalSettings { get; set; } = [];
}
/// Nested render settings.
@@ -80,6 +118,10 @@ public class RenderSettings
/// Gets or sets a value indicating whether to render stats overlay.
public bool StatsOverlay { get; set; } = true;
+
+ /// Gets or sets the render keys this model does not declare, so they survive a rewrite.
+ [JsonExtensionData]
+ public Dictionary AdditionalSettings { get; set; } = [];
}
/// Nested social settings.
@@ -108,5 +150,9 @@ public class SocialSettings
/// Gets or sets a value indicating whether to show notification when player sends request in menus.
public bool NotificationPlayerSendsRequestMenus { get; set; } = true;
+
+ /// Gets or sets the social keys this model does not declare, so they survive a rewrite.
+ [JsonExtensionData]
+ public Dictionary AdditionalSettings { get; set; } = [];
}
}
diff --git a/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs b/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs
index 84c318569..ec8144db1 100644
--- a/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs
+++ b/GenHub/GenHub.Core/Models/GameSettings/TheSuperHackersSettings.cs
@@ -1,3 +1,5 @@
+using GenHub.Core.Constants;
+
namespace GenHub.Core.Models.GameSettings;
/// TheSuperHackers game client settings from Options.ini.
@@ -19,7 +21,7 @@ public class TheSuperHackersSettings
public bool CursorCaptureEnabledInWindowedMenu { get; set; }
/// Gets or sets the volume of money transaction audio events (0-100, 0 to mute).
- public int MoneyTransactionVolume { get; set; }
+ public int MoneyTransactionVolume { get; set; } = GameSettingsTheSuperHackersConstants.DefaultMoneyTransactionVolume;
/// Gets or sets the font size for network latency display (0 to disable).
public int NetworkLatencyFontSize { get; set; } = 8;
diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs
index fd42ee2b2..b3a98df3a 100644
--- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs
+++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs
@@ -36,6 +36,12 @@ public class GeneralsOnlineRelease
///
public long? PortableSize { get; init; }
+ ///
+ /// Gets SHA256 hash of the portable ZIP package for file verification.
+ /// Null when hash is unknown (e.g., from latest.txt API).
+ ///
+ public string? Sha256 { get; init; }
+
///
/// Gets release changelog/notes.
///
diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs
index 1c5e1dba6..b954fcdbd 100644
--- a/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/InstallationInstructions.cs
@@ -10,11 +10,6 @@ namespace GenHub.Core.Models.Manifest;
///
public class InstallationInstructions
{
- ///
- /// Gets or sets the steps to run before installation.
- ///
- public List PreInstallSteps { get; set; } = [];
-
///
/// Gets or sets the steps to run after installation.
///
diff --git a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs
index 6ecd505a9..78590ebfd 100644
--- a/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/InstallationStep.cs
@@ -1,7 +1,11 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using GenHub.Core.Models.Enums;
+
namespace GenHub.Core.Models.Manifest;
///
-/// Individual installation step with commands and conditions.
+/// Individual installation step with typed operation kind and structured parameters.
///
public class InstallationStep
{
@@ -11,22 +15,49 @@ public class InstallationStep
public string Name { get; set; } = string.Empty;
///
- /// Gets or sets the command to execute.
+ /// Gets or sets the kind of installation operation to execute.
+ ///
+ public InstallationStepKind Kind { get; set; } = InstallationStepKind.Unknown;
+
+ ///
+ /// Gets or sets the relative path of the target file to act upon in the delivered workspace or manifest.
///
- public string Command { get; set; } = string.Empty;
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? TargetRelativePath { get; set; }
///
- /// Gets or sets the arguments for the command.
+ /// Gets or sets the destination relative path when renaming or moving a file.
+ /// Only used when is .
///
- public List Arguments { get; set; } = new();
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? DestinationRelativePath { get; set; }
///
- /// Gets or sets the working directory for the command.
+ /// Gets or sets the arguments for executable steps.
+ /// Only used when is .
///
- public string? WorkingDirectory { get; set; }
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public List? Arguments { get; set; }
///
/// Gets or sets a value indicating whether the step requires elevation.
///
public bool RequiresElevation { get; set; }
+
+ ///
+ /// Gets or sets an optional user-facing status message to display in notifications or progress.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? StatusMessage { get; set; }
+
+ ///
+ /// Gets or sets an optional unique key identifying this installation step for execution tracking across updates.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? StepKey { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this step should only run once and be skipped on subsequent updates if already executed.
+ ///
+ public bool RunOnce { get; set; }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
index 83d681312..7a93f17cf 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
@@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest;
public sealed class ManifestIdJsonConverter : JsonConverter
{
///
- public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
var s = reader.GetString() ?? string.Empty;
return ManifestId.Create(s);
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
index ddb22794a..fc609db20 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
@@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint(
files);
}
- private static bool PathsMatch(string left, string right) =>
+ ///
+ /// Determines whether two relative file paths match, normalizing directory separators and leading slashes.
+ ///
+ /// The first relative path.
+ /// The second relative path.
+ /// true if the paths match; otherwise, false.
+ public static bool PathsMatch(string left, string right) =>
string.Equals(
left.Replace('\\', '/').TrimStart('/'),
right.Replace('\\', '/').TrimStart('/'),
diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
index 24058e7d9..ee5ed6952 100644
--- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
+++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
@@ -7,15 +7,16 @@
namespace GenHub.Core.Serialization;
///
-/// Custom JSON converter for WorkspaceStrategy that supports both string and integer formats.
-/// Provides backward compatibility for integer-based strategy values.
+/// Custom JSON converter for WorkspaceStrategy that writes the member name and reads both string
+/// and integer formats, so metadata written by releases up to v0.0.3 still deserializes.
///
public class JsonWorkspaceStrategyConverter : JsonConverter
{
///
[SuppressMessage("Maintainability", "CS-R1138:Inappropriate ordering of parameters", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
[SuppressMessage("DeepSource", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
- public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ [SuppressMessage("csharp", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
+ public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
if (reader.TokenType == JsonTokenType.Number)
{
@@ -52,6 +53,6 @@ public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToCon
///
public override void Write(Utf8JsonWriter writer, WorkspaceStrategy value, JsonSerializerOptions options)
{
- writer.WriteNumberValue((int)value);
+ writer.WriteStringValue(value.ToString());
}
}
diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
index 8f544eec5..2912aa9a5 100644
--- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
+++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
@@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
@@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs
var manifest = builder.Build();
manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath;
+ if (!string.IsNullOrWhiteSpace(entryPoint))
+ {
+ var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/');
+
+ var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == ".."))
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..').");
+ }
+
+ var matchedFile = manifest.Files.FirstOrDefault(f =>
+ ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint));
+
+ if (matchedFile == null)
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' was not found among the files in the directory.");
+ }
+
+ manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/');
+ }
+
// Auto-add GameInstallation dependency for GameClient content types
// This ensures auto-resolution logic works correctly for locally added clients
if (contentType == ContentType.GameClient)
@@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
// 1. Create the new manifest/content
// We do this FIRST to ensure the new content is valid before deleting the old one
- var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken);
+ var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint);
if (!createResult.Success)
{
diff --git a/GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs b/GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs
new file mode 100644
index 000000000..967b3c0f4
--- /dev/null
+++ b/GenHub/GenHub.Core/Utilities/ArchiveEntryName.cs
@@ -0,0 +1,79 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Linq;
+
+namespace GenHub.Core.Utilities;
+
+///
+/// Screens archive entry names before they are turned into filesystem paths. Names come from
+/// third-party archives, so a name the host cannot represent has to be refused up front rather
+/// than left to fail somewhere inside the write: an empty name in particular collapses
+/// onto the extraction directory itself, which puts the
+/// write on the directory instead of on a file inside it.
+///
+public static class ArchiveEntryName
+{
+ private static readonly char[] SeparatorChars = ['/', '\\'];
+
+ private static readonly char[] UnusableChars =
+ ['\"', '<', '>', '|', ':', '*', '?', .. Enumerable.Range(0, 32).Select(value => (char)value)];
+
+ private static readonly string[] ReservedDeviceNames =
+ [
+ "CON", "PRN", "AUX", "NUL",
+ "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
+ "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
+ ];
+
+ ///
+ /// Determines whether an archive entry name can be combined with an extraction directory to
+ /// name a file. A name that resolves to the directory itself is refused, as is one the
+ /// strictest supported host cannot represent, so an archive behaves the same everywhere: that
+ /// rules out reserved device names and the characters Windows forbids, including the colon that
+ /// would otherwise open an NTFS alternate data stream. Traversal in the middle of a name is not
+ /// judged here; that stays with the containment check that follows.
+ ///
+ /// The archive-relative entry name to screen.
+ /// when the name can be extracted; otherwise, .
+ public static bool IsExtractable([NotNullWhen(true)] string? entryName)
+ {
+ if (string.IsNullOrWhiteSpace(entryName))
+ {
+ return false;
+ }
+
+ if (entryName.EndsWith('/') || entryName.EndsWith('\\'))
+ {
+ return false;
+ }
+
+ var segments = entryName.Split(SeparatorChars, StringSplitOptions.RemoveEmptyEntries);
+
+ return segments.Length > 0 &&
+ segments[^1] is not ("." or "..") &&
+ segments.All(IsExtractableSegment);
+ }
+
+ private static bool IsExtractableSegment(string segment)
+ {
+ if (string.IsNullOrWhiteSpace(segment))
+ {
+ return false;
+ }
+
+ if (segment is not ("." or "..") && (segment.EndsWith('.') || segment.EndsWith(' ')))
+ {
+ return false;
+ }
+
+ if (segment.IndexOfAny(UnusableChars) >= 0)
+ {
+ return false;
+ }
+
+ var deviceName = segment.Split('.')[0];
+
+ return !ReservedDeviceNames.Contains(deviceName, StringComparer.OrdinalIgnoreCase);
+ }
+}
diff --git a/GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs b/GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs
new file mode 100644
index 000000000..b1f9095e0
--- /dev/null
+++ b/GenHub/GenHub.Core/Utilities/BoundedArchiveExtractor.cs
@@ -0,0 +1,128 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Exceptions;
+
+namespace GenHub.Core.Utilities;
+
+///
+/// Streams archive entries to disk under an expansion budget. Sizes recorded in archive headers are
+/// attacker-controlled, so the budget is measured against the bytes actually decompressed and the
+/// copy aborts the moment it is exceeded.
+///
+public static class BoundedArchiveExtractor
+{
+ ///
+ /// Copies a decompressed archive entry to , aborting as soon as the
+ /// per-entry cap or the remaining archive-wide budget is exhausted. When
+ /// is set the entry is staged beside its destination and moved into place only once the copy has
+ /// completed, so a failure leaves any pre-existing file intact and removes only what this call wrote.
+ ///
+ /// The decompressed entry stream to read from.
+ /// The file to write the entry to.
+ /// The archive-relative entry name, used in failure messages.
+ /// Maximum number of bytes a single entry may expand to.
+ /// Bytes still available in the archive-wide budget.
+ /// Whether an existing destination file may be replaced.
+ /// Token used to cancel the copy.
+ /// The number of bytes written.
+ /// Thrown when the budget is already exhausted or the entry expands past it.
+ public static async Task CopyEntryToFileAsync(
+ Stream entryStream,
+ string destinationPath,
+ string entryName,
+ long maxEntryBytes,
+ long remainingAggregateBytes,
+ bool overwrite = false,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(entryStream);
+
+ var limit = Math.Min(maxEntryBytes, remainingAggregateBytes);
+ if (limit <= 0)
+ {
+ throw ArchiveExpansionLimitExceededException.ForSpentBudget(entryName);
+ }
+
+ var buffer = new byte[IoConstants.DefaultFileBufferSize];
+ long written = 0;
+
+ var writePath = overwrite ? BuildStagingPath(destinationPath) : destinationPath;
+ var destination = new FileStream(writePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
+
+ try
+ {
+ int read = 0;
+ while ((read = await entryStream.ReadAsync(buffer, cancellationToken)) > 0)
+ {
+ written += read;
+ if (written > limit)
+ {
+ throw new ArchiveExpansionLimitExceededException(entryName, limit);
+ }
+
+ await destination.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
+ }
+
+ await destination.DisposeAsync();
+
+ if (overwrite)
+ {
+ File.Move(writePath, destinationPath, overwrite: true);
+ }
+ }
+ catch
+ {
+ await DisposeQuietlyAsync(destination);
+ DeletePartialOutput(writePath);
+ throw;
+ }
+
+ return written;
+ }
+
+ private static string BuildStagingPath(string destinationPath)
+ {
+ var directory = Path.GetDirectoryName(destinationPath);
+ var stagingName = Path.GetRandomFileName() + IoConstants.StagingFileSuffix;
+
+ return string.IsNullOrEmpty(directory) ? stagingName : Path.Combine(directory, stagingName);
+ }
+
+ private static async Task DisposeQuietlyAsync(FileStream destination)
+ {
+ try
+ {
+ await destination.DisposeAsync();
+ }
+ catch (IOException)
+ {
+ // The failure being handled is the one worth surfacing, not a flush that fails after it.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // The failure being handled is the one worth surfacing, not a flush that fails after it.
+ }
+ }
+
+ private static void DeletePartialOutput(string writePath)
+ {
+ try
+ {
+ if (File.Exists(writePath))
+ {
+ File.Delete(writePath);
+ }
+ }
+ catch (IOException)
+ {
+ // Best effort cleanup; the original failure is the one worth surfacing.
+ }
+ catch (UnauthorizedAccessException)
+ {
+ // Best effort cleanup; the original failure is the one worth surfacing.
+ }
+ }
+}
diff --git a/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs b/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs
index 06834b8db..6bd9d2385 100644
--- a/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs
+++ b/GenHub/GenHub.Linux/GameInstallations/SteamInstallation.cs
@@ -165,78 +165,127 @@ public void Fetch()
}
}
- ///
- /// Gets Steam library paths on Linux.
- ///
- /// List of Steam library paths.
- private List GetSteamLibraryPaths()
+ private static IReadOnlyList GetCandidateHomeDirectories()
{
- var libraryPaths = new List();
+ var homeDirs = new HashSet(StringComparer.Ordinal);
+ var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ var envHome = Environment.GetEnvironmentVariable("HOME");
- try
+ AddHomeVariants(homeDirectory, homeDirs);
+ AddHomeVariants(envHome, homeDirs);
+
+ return homeDirs.ToList();
+ }
+
+ private static void AddHomeVariants(string? path, HashSet homeDirs)
+ {
+ if (string.IsNullOrEmpty(path))
{
- var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
- var steamConfigPaths = new Dictionary
- {
- {
- ".steam/steam/steamapps/libraryfolders.vdf",
- LinuxInstallationType.Binary
- },
- {
- ".local/share/Steam/steamapps/libraryfolders.vdf",
- LinuxInstallationType.Binary
- },
- {
- ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/libraryfolders.vdf",
- LinuxInstallationType.Flatpack
- },
- {
- "snap/steam/common/.local/share/Steam/steamapps/libraryfolders.vdf",
- LinuxInstallationType.Snap
- },
- {
- "/usr/share/steam/steamapps/libraryfolders.vdf",
- LinuxInstallationType.Unknown
- },
- };
+ return;
+ }
- string? configFile = null;
- foreach (KeyValuePair entry in steamConfigPaths)
+ homeDirs.Add(path);
+ if (path.StartsWith("/home/", StringComparison.Ordinal))
+ {
+ homeDirs.Add("/var" + path);
+ }
+ else if (path.StartsWith("/var/home/", StringComparison.Ordinal))
+ {
+ homeDirs.Add(path.Substring(4));
+ }
+ }
+
+ private static IReadOnlyList<(string ConfigFile, LinuxInstallationType Type)> GetSteamConfigFiles(IEnumerable homeDirs)
+ {
+ var steamConfigRelativePaths = new (string Path, LinuxInstallationType Type)[]
+ {
+ (".steam/steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Binary),
+ (".steam/root/steamapps/libraryfolders.vdf", LinuxInstallationType.Binary),
+ (".local/share/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Binary),
+ (".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack),
+ (".var/app/com.valvesoftware.Steam/data/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack),
+ (".var/app/com.valvesoftware.Steam/.steam/steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack),
+ (".var/app/com.valvesoftware.Steam/.steam/root/steamapps/libraryfolders.vdf", LinuxInstallationType.Flatpack),
+ ("snap/steam/common/.local/share/Steam/steamapps/libraryfolders.vdf", LinuxInstallationType.Snap),
+ };
+
+ var configFiles = new List<(string ConfigFile, LinuxInstallationType Type)>();
+ foreach (var home in homeDirs)
+ {
+ foreach (var (relPath, type) in steamConfigRelativePaths)
{
- if (File.Exists(Path.Combine(homeDirectory, entry.Key)))
+ var fullPath = Path.Combine(home, relPath);
+ if (File.Exists(fullPath))
{
- configFile = Path.Combine(homeDirectory, entry.Key);
- PackageInstallationType = entry.Value;
- break;
+ configFiles.Add((fullPath, type));
}
}
+ }
- if (configFile == null)
+ const string systemConfigFile = "/usr/share/steam/steamapps/libraryfolders.vdf";
+ if (File.Exists(systemConfigFile))
+ {
+ configFiles.Add((systemConfigFile, LinuxInstallationType.Unknown));
+ }
+
+ return configFiles;
+ }
+
+ private static void ResolveFlatpakFallbackPaths(
+ string steamPath,
+ IReadOnlyList homeDirs,
+ HashSet libraryPaths)
+ {
+ foreach (var home in homeDirs)
+ {
+ var flatpakLocal = Path.Combine(home, ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common");
+ if (Directory.Exists(flatpakLocal))
{
- logger?.LogDebug("Steam library configuration file not found");
- return libraryPaths;
+ libraryPaths.Add(flatpakLocal);
}
- logger?.LogDebug("Reading Steam library configuration from: {ConfigFile}", configFile);
+ var flatpakData = Path.Combine(home, ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/common");
+ if (Directory.Exists(flatpakData))
+ {
+ libraryPaths.Add(flatpakData);
+ }
- var lines = File.ReadAllLines(configFile);
- foreach (var line in lines)
+ // Map sandboxed home path to host Flatpak sandbox storage
+ if (steamPath.StartsWith(home, StringComparison.Ordinal))
{
- if (!line.Contains("\"path\""))
- continue;
+ var relativePart = steamPath.Substring(home.Length).TrimStart('/');
+ var flatpakMapped = Path.Combine(home, ".var/app/com.valvesoftware.Steam", relativePart, "steamapps", "common");
+ if (Directory.Exists(flatpakMapped))
+ {
+ libraryPaths.Add(flatpakMapped);
+ }
+ }
+ }
+ }
- var parts = line.Split('"');
- if (parts.Length < 4)
- continue;
+ ///
+ /// Gets Steam library paths on Linux.
+ ///
+ /// List of Steam library paths.
+ private List GetSteamLibraryPaths()
+ {
+ var libraryPaths = new HashSet(StringComparer.Ordinal);
- var steamPath = parts[3].Trim();
- var commonPath = Path.Combine(steamPath, "steamapps", "common");
+ try
+ {
+ var homeDirs = GetCandidateHomeDirectories();
+ var configFiles = GetSteamConfigFiles(homeDirs);
+ CollectStandardLibraryPaths(homeDirs, libraryPaths);
- if (Directory.Exists(commonPath))
- {
- libraryPaths.Add(commonPath);
- logger?.LogDebug("Found Steam library: {LibraryPath}", commonPath);
- }
+ if (configFiles.Count == 0 && libraryPaths.Count == 0)
+ {
+ logger?.LogDebug("Steam library configuration file not found");
+ return libraryPaths.ToList();
+ }
+
+ foreach (var (configFile, pkgType) in configFiles)
+ {
+ ParseSteamConfigFile(configFile, pkgType, homeDirs, libraryPaths);
}
}
catch (Exception ex)
@@ -244,6 +293,72 @@ private List GetSteamLibraryPaths()
logger?.LogWarning(ex, "Failed to read Steam library paths");
}
- return libraryPaths;
+ return libraryPaths.ToList();
+ }
+
+ private void CollectStandardLibraryPaths(IEnumerable homeDirs, HashSet libraryPaths)
+ {
+ var standardLibraryRelativePaths = new[]
+ {
+ ".local/share/Steam/steamapps/common",
+ ".steam/steam/steamapps/common",
+ ".steam/root/steamapps/common",
+ ".var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common",
+ ".var/app/com.valvesoftware.Steam/data/Steam/steamapps/common",
+ ".var/app/com.valvesoftware.Steam/.steam/steam/steamapps/common",
+ ".var/app/com.valvesoftware.Steam/.steam/root/steamapps/common",
+ "snap/steam/common/.local/share/Steam/steamapps/common",
+ };
+
+ foreach (var home in homeDirs)
+ {
+ foreach (var relLib in standardLibraryRelativePaths)
+ {
+ var fullLib = Path.Combine(home, relLib);
+ if (Directory.Exists(fullLib))
+ {
+ libraryPaths.Add(fullLib);
+ logger?.LogDebug("Found Steam library via standard path: {LibraryPath}", fullLib);
+ }
+ }
+ }
+ }
+
+ private void ParseSteamConfigFile(
+ string configFile,
+ LinuxInstallationType pkgType,
+ IReadOnlyList homeDirs,
+ HashSet libraryPaths)
+ {
+ PackageInstallationType = pkgType;
+ logger?.LogDebug("Reading Steam library configuration from: {ConfigFile}", configFile);
+
+ var lines = File.ReadAllLines(configFile);
+ foreach (var line in lines)
+ {
+ if (!line.Contains("\"path\""))
+ {
+ continue;
+ }
+
+ var parts = line.Split('"');
+ if (parts.Length < 4)
+ {
+ continue;
+ }
+
+ var steamPath = parts[3].Trim();
+ var commonPath = Path.Combine(steamPath, "steamapps", "common");
+
+ if (Directory.Exists(commonPath))
+ {
+ libraryPaths.Add(commonPath);
+ logger?.LogDebug("Found Steam library: {LibraryPath}", commonPath);
+ }
+ else
+ {
+ ResolveFlatpakFallbackPaths(steamPath, homeDirs, libraryPaths);
+ }
+ }
}
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
index 49836a2dc..6a638f2ff 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
@@ -549,6 +549,58 @@ public void GetAutoCheckForUpdatesOnStartup_ReturnsUserSetting(bool userValue)
Assert.Equal(userValue, result);
}
+ ///
+ /// Verifies that GetAutoCheckForUpdatesPeriodically returns user setting when explicitly set.
+ ///
+ /// The value to set for AutoCheckForUpdatesPeriodically in user settings.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void GetAutoCheckForUpdatesPeriodically_ReturnsUserSetting(bool userValue)
+ {
+ // Arrange
+ var userSettings = new UserSettings { AutoCheckForUpdatesPeriodically = userValue };
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically));
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetAutoCheckForUpdatesPeriodically();
+
+ // Assert
+ Assert.Equal(userValue, result);
+ }
+
+ ///
+ /// Verifies that GetPeriodicUpdateCheckIntervalMinutes returns user setting when explicitly set.
+ ///
+ /// The interval to set in user settings.
+ /// The expected clamped interval.
+ [Theory]
+ [InlineData(60, 60)]
+ [InlineData(0, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes)]
+ [InlineData(20000, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)]
+ public void GetPeriodicUpdateCheckIntervalMinutes_ReturnsUserSetting(int intervalMinutes, int expectedMinutes)
+ {
+ // Arrange
+ var userSettings = new UserSettings { PeriodicUpdateCheckIntervalMinutes = intervalMinutes };
+ if (intervalMinutes > 0)
+ {
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes));
+ }
+
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetPeriodicUpdateCheckIntervalMinutes();
+
+ // Assert
+ Assert.Equal(expectedMinutes, result);
+ }
+
///
/// Verifies that GetEnableDetailedLogging returns user setting when explicitly set.
///
@@ -742,10 +794,35 @@ public void GetContentDirectories_WithNullUserSetting_ReturnsDefaults()
// Assert
Assert.Contains(Path.Combine(appDataPath, FileTypes.ManifestsDirectory), result);
- Assert.Contains(Path.Combine(appDataPath, "CustomManifests"), result);
+ Assert.Contains(Path.Combine(appDataPath, DirectoryNames.CustomManifests), result);
Assert.True(result.Count >= 3);
}
+ ///
+ /// Verifies that the default content directories follow an explicitly set application data path,
+ /// so local discovery scans the same root the manifests are read from and written to.
+ ///
+ [Fact]
+ public void GetContentDirectories_WithExplicitApplicationDataPath_ReturnsOverride()
+ {
+ // Arrange
+ var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root");
+ var userSettings = new UserSettings { ApplicationDataPath = userPath, ContentDirectories = [] };
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath));
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+ _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path");
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetContentDirectories();
+
+ // Assert
+ Assert.Contains(Path.Combine(userPath, FileTypes.ManifestsDirectory), result);
+ Assert.Contains(Path.Combine(userPath, DirectoryNames.CustomManifests), result);
+ Assert.Equal(provider.GetManifestsPath(), result[0]);
+ }
+
///
/// Verifies that GetGitHubDiscoveryRepositories returns user setting when available.
///
@@ -784,7 +861,400 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults()
// Assert
Assert.Contains("TheSuperHackers/GeneralsGameCode", result);
- Assert.Single(result);
+ Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ ///
+ /// Verifies that GetProfilesPath honors an explicitly set application data path.
+ ///
+ [Fact]
+ public void GetProfilesPath_WithExplicitApplicationDataPath_ReturnsOverride()
+ {
+ // Arrange
+ var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root");
+ var userSettings = new UserSettings { ApplicationDataPath = userPath };
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath));
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+ _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path");
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetProfilesPath();
+
+ // Assert
+ Assert.Equal(Path.Combine(userPath, DirectoryNames.Profiles), result);
+ }
+
+ ///
+ /// Verifies that GetManifestsPath honors an explicitly set application data path.
+ ///
+ [Fact]
+ public void GetManifestsPath_WithExplicitApplicationDataPath_ReturnsOverride()
+ {
+ // Arrange
+ var userPath = Path.Combine(Path.GetTempPath(), "genhub-user-data-root");
+ var userSettings = new UserSettings { ApplicationDataPath = userPath };
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.ApplicationDataPath));
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+ _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns("/app/data/path");
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetManifestsPath();
+
+ // Assert
+ Assert.Equal(Path.Combine(userPath, FileTypes.ManifestsDirectory), result);
+ }
+
+ ///
+ /// Verifies that the profiles and manifests paths fall back to the configured data path when no
+ /// application data path override is set.
+ ///
+ [Fact]
+ public void GetProfilesAndManifestsPath_WithoutOverride_ReturnConfiguredDataPath()
+ {
+ // Arrange
+ var appDataPath = "/app/data/path";
+ _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(appDataPath);
+
+ var provider = CreateProvider();
+
+ // Act & Assert
+ Assert.Equal(Path.Combine(appDataPath, DirectoryNames.Profiles), provider.GetProfilesPath());
+ Assert.Equal(Path.Combine(appDataPath, FileTypes.ManifestsDirectory), provider.GetManifestsPath());
+ }
+
+ ///
+ /// Verifies that the legacy roaming data root is migrated into the current root while the CAS
+ /// pool, which still defaults to the legacy location, is left in place.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithLegacyData_MovesTrackedEntriesAndLeavesCasPool()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ SeedLegacyRoot(legacyRoot);
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json")));
+ Assert.Equal("manifest", File.ReadAllText(Path.Combine(newRoot, FileTypes.ManifestsDirectory, "content.manifest.json")));
+ Assert.Equal("index", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName)));
+ Assert.Equal("backup", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, DirectoryNames.UserDataBackups, "save.bak")));
+ Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName)));
+ Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName)));
+
+ Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin")));
+ Assert.False(Directory.Exists(Path.Combine(newRoot, DirectoryNames.CasPool)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that running the legacy root migration a second time leaves the migrated data alone.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_RunTwice_IsIdempotent()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ SeedLegacyRoot(legacyRoot);
+ var provider = CreateProvider();
+
+ provider.MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+ provider.MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json")));
+ Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName)));
+ Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin")));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that data already present in the current root wins over the legacy copy.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithExistingData_DoesNotOverwriteNewRoot()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ SeedLegacyRoot(legacyRoot);
+ Directory.CreateDirectory(Path.Combine(newRoot, DirectoryNames.Profiles));
+ File.WriteAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json"), "current-profile");
+ File.WriteAllText(Path.Combine(newRoot, FileTypes.SettingsFileName), "current-settings");
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.Equal("current-profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json")));
+ Assert.Equal("current-settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName)));
+ Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that a missing legacy root does not create the current root.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithoutLegacyRoot_DoesNothing()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ Directory.Delete(legacyRoot);
+ Directory.Delete(newRoot);
+ try
+ {
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.False(Directory.Exists(newRoot));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that the migration is skipped when both roots resolve to the same directory.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithIdenticalRoots_DoesNothing()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ SeedLegacyRoot(legacyRoot);
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, Path.Combine(legacyRoot, "."), Path.Combine(legacyRoot, "."));
+
+ Assert.Equal("profile", File.ReadAllText(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json")));
+ Assert.Equal("settings", File.ReadAllText(Path.Combine(legacyRoot, FileTypes.SettingsFileName)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that the migration leaves nothing behind in the legacy root, so a regression from a
+ /// move to a copy is caught rather than passing every positive assertion.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithLegacyData_RemovesTheLegacySources()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ SeedLegacyRoot(legacyRoot);
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.SettingsFileName)));
+ Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.WorkspaceMetadataFileName)));
+ Assert.False(Directory.Exists(Path.Combine(legacyRoot, DirectoryNames.Profiles)));
+ Assert.False(Directory.Exists(Path.Combine(legacyRoot, FileTypes.ManifestsDirectory)));
+ Assert.False(Directory.Exists(Path.Combine(legacyRoot, DirectoryNames.UserData)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies the steady state after a successful migration: a legacy root that still holds the CAS
+ /// pool, but none of the migrated entries, is left completely alone.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithoutLegacyEntries_LeavesBothRootsAlone()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ Directory.Delete(newRoot);
+ try
+ {
+ WriteFile(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"), "cas");
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.False(Directory.Exists(newRoot));
+ Assert.True(File.Exists(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin")));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that the sub-layout releases up to v0.0.3 wrote, which nested the manifests, tracked
+ /// user data and workspace metadata under a Content directory, is flattened into the data root.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithContentSubLayout_FlattensIntoDataRoot()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ var legacyContent = Path.Combine(legacyRoot, DirectoryNames.LegacyContent);
+ WriteFile(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile");
+ WriteFile(Path.Combine(legacyContent, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest");
+ WriteFile(Path.Combine(legacyContent, DirectoryNames.UserData, FileTypes.UserDataIndexFileName), "index");
+ WriteFile(Path.Combine(legacyContent, FileTypes.WorkspaceMetadataFileName), "workspaces");
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.Equal("profile", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.Profiles, "profile.json")));
+ Assert.Equal("manifest", File.ReadAllText(Path.Combine(newRoot, FileTypes.ManifestsDirectory, "content.manifest.json")));
+ Assert.Equal("index", File.ReadAllText(Path.Combine(newRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName)));
+ Assert.Equal("workspaces", File.ReadAllText(Path.Combine(newRoot, FileTypes.WorkspaceMetadataFileName)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that the settings file releases up to v0.0.3 wrote, which was named after the JSON
+ /// extension rather than the settings file name, is migrated under the current name.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithLegacySettingsFileName_MigratesUnderCurrentName()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ WriteFile(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName), "settings");
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName)));
+ Assert.False(File.Exists(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that a settings file already under the current name wins over the v0.0.3 one.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithBothSettingsFileNames_PrefersTheCurrentName()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ try
+ {
+ WriteFile(Path.Combine(legacyRoot, FileTypes.SettingsFileName), "current");
+ WriteFile(Path.Combine(legacyRoot, FileTypes.LegacySettingsFileName), "older");
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, newRoot, newRoot);
+
+ Assert.Equal("current", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Verifies that the data consumers read through the application data path lands in the override
+ /// root while the settings file, which is resolved from the configured root, lands there instead.
+ ///
+ [Fact]
+ public void MigrateLegacyDataRoot_WithSeparateDataAndSettingsRoots_SplitsTheDestinations()
+ {
+ var (legacyRoot, newRoot) = CreateMigrationRoots();
+ var overrideRoot = Path.Combine(Path.GetDirectoryName(newRoot)!, "relocated");
+ try
+ {
+ SeedLegacyRoot(legacyRoot);
+
+ CreateProvider().MigrateLegacyDataRoot(legacyRoot, overrideRoot, newRoot);
+
+ Assert.Equal("profile", File.ReadAllText(Path.Combine(overrideRoot, DirectoryNames.Profiles, "profile.json")));
+ Assert.Equal("manifest", File.ReadAllText(Path.Combine(overrideRoot, FileTypes.ManifestsDirectory, "content.manifest.json")));
+ Assert.Equal("index", File.ReadAllText(Path.Combine(overrideRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName)));
+ Assert.Equal("workspaces", File.ReadAllText(Path.Combine(overrideRoot, FileTypes.WorkspaceMetadataFileName)));
+
+ Assert.Equal("settings", File.ReadAllText(Path.Combine(newRoot, FileTypes.SettingsFileName)));
+ Assert.False(File.Exists(Path.Combine(overrideRoot, FileTypes.SettingsFileName)));
+ Assert.False(Directory.Exists(Path.Combine(newRoot, DirectoryNames.Profiles)));
+ }
+ finally
+ {
+ DeleteDirectories(legacyRoot, newRoot);
+ }
+ }
+
+ ///
+ /// Creates a fresh legacy and current data root pair under the temp directory.
+ ///
+ /// The legacy and current root paths.
+ private static (string LegacyRoot, string NewRoot) CreateMigrationRoots()
+ {
+ var testRoot = Path.Combine(Path.GetTempPath(), $"genhub-migration-{Guid.NewGuid():N}");
+ var legacyRoot = Path.Combine(testRoot, "roaming");
+ var newRoot = Path.Combine(testRoot, "local");
+ Directory.CreateDirectory(legacyRoot);
+ Directory.CreateDirectory(newRoot);
+ return (legacyRoot, newRoot);
+ }
+
+ ///
+ /// Populates a legacy data root with the entries an alpha-3 install would contain.
+ ///
+ /// The legacy data root to populate.
+ private static void SeedLegacyRoot(string legacyRoot)
+ {
+ WriteFile(Path.Combine(legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile");
+ WriteFile(Path.Combine(legacyRoot, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest");
+ WriteFile(Path.Combine(legacyRoot, DirectoryNames.UserData, FileTypes.UserDataIndexFileName), "index");
+ WriteFile(Path.Combine(legacyRoot, DirectoryNames.UserData, DirectoryNames.UserDataBackups, "save.bak"), "backup");
+ WriteFile(Path.Combine(legacyRoot, FileTypes.SettingsFileName), "settings");
+ WriteFile(Path.Combine(legacyRoot, FileTypes.WorkspaceMetadataFileName), "workspaces");
+ WriteFile(Path.Combine(legacyRoot, DirectoryNames.CasPool, "objects", "blob.bin"), "cas");
+ }
+
+ private static void WriteFile(string path, string content)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, content);
+ }
+
+ private static void DeleteDirectories(params string[] paths)
+ {
+ foreach (var path in paths.Select(Path.GetDirectoryName).Where(path => !string.IsNullOrEmpty(path)).Distinct())
+ {
+ try
+ {
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path!, true);
+ }
+ }
+ catch (IOException)
+ {
+ }
+ }
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs
new file mode 100644
index 000000000..887d63802
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/LegacyRootUpgradeTests.cs
@@ -0,0 +1,402 @@
+using GenHub.Common.Services;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Models.Common;
+using GenHub.Core.Models.Enums;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace GenHub.Tests.Core.Common.Services;
+
+///
+/// Covers the first launch after upgrading from a release that kept its data under the roaming
+/// profile.
+///
+/// loads in its own constructor and resolves the settings path
+/// straight from , so it runs before
+/// has had any chance to migrate the legacy root. Left
+/// alone it would start from defaults, and the first save of the session would then write those
+/// defaults over the freshly migrated settings file, permanently destroying the user's settings.
+///
+///
+public class LegacyRootUpgradeTests : IDisposable
+{
+ private readonly string _testRoot;
+ private readonly string _legacyRoot;
+ private readonly string _newRoot;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public LegacyRootUpgradeTests()
+ {
+ _testRoot = Path.Combine(Path.GetTempPath(), $"genhub-upgrade-{Guid.NewGuid():N}");
+ _legacyRoot = Path.Combine(_testRoot, "roaming");
+ _newRoot = Path.Combine(_testRoot, "local");
+ Directory.CreateDirectory(_legacyRoot);
+ Directory.CreateDirectory(_newRoot);
+ }
+
+ ///
+ /// Removes the temporary roots created for the test.
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_testRoot))
+ {
+ Directory.Delete(_testRoot, recursive: true);
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Verifies that the settings a user had before the upgrade are in effect on the first launch,
+ /// without waiting for a restart.
+ ///
+ [Fact]
+ public void FirstLaunch_WithLegacySettings_LoadsLegacyValues()
+ {
+ WriteLegacySettings("""
+ {
+ "theme": "Light",
+ "maxConcurrentDownloads": 7,
+ "defaultWorkspaceStrategy": "SymlinkOnly"
+ }
+ """);
+
+ var settings = CreateSettingsService().Get();
+
+ Assert.Equal("Light", settings.Theme);
+ Assert.Equal(7, settings.MaxConcurrentDownloads);
+ Assert.Equal(WorkspaceStrategy.SymlinkOnly, settings.DefaultWorkspaceStrategy);
+ }
+
+ ///
+ /// Verifies the exact sequence that destroyed user settings: a first-launch load, the legacy
+ /// root migration moving the settings file into the new root, and then a save during that same
+ /// session. The saved file must still carry the user's values, not defaults.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task FirstLaunch_ThenMigrationThenSave_PreservesLegacyValuesAsync()
+ {
+ WriteLegacySettings("""
+ {
+ "theme": "Light",
+ "maxConcurrentDownloads": 7
+ }
+ """);
+
+ var appConfig = CreateAppConfig();
+ var settingsService = CreateSettingsService(appConfig);
+ var provider = new ConfigurationProviderService(
+ appConfig,
+ settingsService,
+ Mock.Of>());
+
+ // Triggers the legacy root migration, which moves settings.json into the new root.
+ provider.GetApplicationDataPath();
+ Assert.True(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName)));
+
+ settingsService.Update(settings => settings.WindowWidth = 1440.0);
+ await settingsService.SaveAsync();
+
+ var persisted = CreateSettingsService(appConfig).Get();
+ Assert.Equal("Light", persisted.Theme);
+ Assert.Equal(7, persisted.MaxConcurrentDownloads);
+ Assert.Equal(1440.0, persisted.WindowWidth);
+ }
+
+ ///
+ /// Verifies that an application data path override carried over from the legacy settings is
+ /// honored on the first launch rather than after a restart.
+ ///
+ [Fact]
+ public void FirstLaunch_WithLegacyApplicationDataPathOverride_HonorsOverride()
+ {
+ var overridePath = Path.Combine(_testRoot, "relocated");
+ Directory.CreateDirectory(overridePath);
+ var escapedOverridePath = overridePath.Replace("\\", "\\\\");
+ WriteLegacySettings($$"""
+ {
+ "applicationDataPath": "{{escapedOverridePath}}"
+ }
+ """);
+
+ var appConfig = CreateAppConfig();
+ var provider = new ConfigurationProviderService(
+ appConfig,
+ CreateSettingsService(appConfig),
+ Mock.Of>());
+
+ Assert.Equal(overridePath, provider.GetApplicationDataPath());
+ Assert.Equal(Path.Combine(overridePath, DirectoryNames.Profiles), provider.GetProfilesPath());
+ Assert.Equal(Path.Combine(overridePath, FileTypes.ManifestsDirectory), provider.GetManifestsPath());
+ }
+
+ ///
+ /// Verifies that the migration puts the profiles where
+ /// resolves them when an application data path override is in effect, rather than in the
+ /// configured root the app would never look at.
+ ///
+ [Fact]
+ public void FirstLaunch_WithOverride_MigratesDataIntoTheRootTheAppReadsFrom()
+ {
+ var overridePath = Path.Combine(_testRoot, "relocated");
+ WriteLegacySettings($$"""
+ {
+ "applicationDataPath": "{{overridePath.Replace("\\", "\\\\")}}"
+ }
+ """);
+ SeedLegacyDataDirectories();
+
+ var appConfig = CreateAppConfig();
+ var provider = new ConfigurationProviderService(
+ appConfig,
+ CreateSettingsService(appConfig),
+ Mock.Of>());
+
+ Assert.Equal("profile", File.ReadAllText(Path.Combine(provider.GetProfilesPath(), "profile.json")));
+ Assert.Equal("manifest", File.ReadAllText(Path.Combine(provider.GetManifestsPath(), "content.manifest.json")));
+ Assert.Equal("workspaces", File.ReadAllText(Path.Combine(provider.GetApplicationDataPath(), FileTypes.WorkspaceMetadataFileName)));
+
+ Assert.False(Directory.Exists(Path.Combine(_newRoot, DirectoryNames.Profiles)));
+ Assert.True(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName)));
+ }
+
+ ///
+ /// Verifies that the settings file releases up to v0.0.3 wrote, which was named after the JSON
+ /// extension rather than the settings file name, is still picked up on the first launch.
+ ///
+ [Fact]
+ public void FirstLaunch_WithV003SettingsFileName_LoadsLegacyValues()
+ {
+ var legacyJson = """
+ { "theme": "Light", "maxConcurrentDownloads": 7 }
+ """;
+ File.WriteAllText(Path.Combine(_legacyRoot, FileTypes.LegacySettingsFileName), legacyJson);
+
+ var settings = CreateSettingsService().Get();
+
+ Assert.Equal("Light", settings.Theme);
+ Assert.Equal(7, settings.MaxConcurrentDownloads);
+ }
+
+ ///
+ /// Verifies that a normalization failure, which used to reset the settings to defaults while the
+ /// settings path still pointed at the user's file, keeps the loaded values instead.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task FirstLaunch_WhenNormalizationThrows_KeepsLoadedValuesAsync()
+ {
+ WriteLegacySettings("""
+ { "theme": "Light", "maxConcurrentDownloads": 7 }
+ """);
+
+ var appConfig = CreateAppConfigMock();
+ appConfig.Setup(config => config.GetMinConcurrentDownloads()).Returns(8);
+ appConfig.Setup(config => config.GetMaxConcurrentDownloads()).Returns(1);
+
+ var service = new UserSettingsService(Mock.Of>(), appConfig.Object);
+ Assert.Equal("Light", service.Get().Theme);
+
+ await service.SaveAsync();
+
+ Assert.Equal("Light", CreateSettingsService().Get().Theme);
+ }
+
+ ///
+ /// Verifies that a failed initialization can never persist defaults over a settings file that was
+ /// never read successfully.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task Save_AfterFailedInitialization_RefusesToOverwriteExistingSettingsAsync()
+ {
+ var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName);
+ var existingJson = """
+ { "theme": "Light" }
+ """;
+ File.WriteAllText(settingsPath, existingJson);
+
+ var appConfig = CreateBaseAppConfigMock();
+ appConfig.Setup(config => config.GetConfiguredDataPath()).Throws(new UnauthorizedAccessException("denied"));
+
+ var service = new UserSettingsService(Mock.Of>(), appConfig.Object);
+
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+ Assert.Contains("Light", File.ReadAllText(settingsPath));
+ }
+
+ ///
+ /// Verifies that a settings file the loader could not parse blocks the save that would replace
+ /// it with defaults. The failure is swallowed inside the load, so nothing reaches the outer
+ /// catch and the file looks like a clean load unless the load reports what it produced.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task Save_WithCorruptSettingsFile_RefusesToOverwriteAsync()
+ {
+ var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName);
+ var corruptJson = "{ invalid json }";
+ File.WriteAllText(settingsPath, corruptJson);
+
+ var service = CreateSettingsService();
+ Assert.Equal(AppConstants.DefaultThemeName, service.Get().Theme);
+
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+ Assert.Equal(corruptJson, File.ReadAllText(settingsPath));
+ }
+
+ ///
+ /// Verifies that a corrupt pre-upgrade settings file blocks saving as well, rather than starting
+ /// the session from defaults and writing them into the current root as if the upgrade had found
+ /// nothing to carry over.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task Save_WithCorruptLegacySettingsFile_RefusesToOverwriteAsync()
+ {
+ var corruptJson = "{ invalid json }";
+ WriteLegacySettings(corruptJson);
+
+ var service = CreateSettingsService();
+
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+ Assert.Equal(corruptJson, File.ReadAllText(Path.Combine(_legacyRoot, FileTypes.SettingsFileName)));
+ Assert.False(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName)));
+ }
+
+ ///
+ /// Verifies that a settings file which could not be opened, the case of a file locked by another
+ /// process or denied by permissions, blocks saving and therefore survives the session.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task Save_WithUnreadableSettingsFile_RefusesToOverwriteAsync()
+ {
+ var settingsPath = Path.Combine(_newRoot, FileTypes.SettingsFileName);
+ var existingJson = """
+ { "theme": "Light" }
+ """;
+ File.WriteAllText(settingsPath, existingJson);
+
+ UserSettingsService service = null!;
+ using (File.Open(settingsPath, System.IO.FileMode.Open, FileAccess.ReadWrite, FileShare.None))
+ {
+ service = CreateSettingsService();
+ }
+
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+ Assert.Equal(existingJson, File.ReadAllText(settingsPath));
+ }
+
+ ///
+ /// Verifies that the absence of any settings file is still a legitimate first run, so blocking
+ /// saves after a failed load cannot leave a fresh install unable to persist anything.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task Save_OnFirstRunWithoutAnySettingsFile_PersistsTheSettingsAsync()
+ {
+ Directory.Delete(_legacyRoot);
+
+ var service = CreateSettingsService();
+ service.Update(settings => settings.Theme = "Light");
+ await service.SaveAsync();
+
+ Assert.Contains("Light", File.ReadAllText(Path.Combine(_newRoot, FileTypes.SettingsFileName)));
+ }
+
+ ///
+ /// Verifies that a settings file already present in the current root wins over the legacy copy.
+ ///
+ [Fact]
+ public void SecondLaunch_WithSettingsInNewRoot_IgnoresLegacyFile()
+ {
+ WriteLegacySettings("""
+ { "theme": "Light" }
+ """);
+ var currentSettingsJson = """
+ { "theme": "Dark" }
+ """;
+ File.WriteAllText(Path.Combine(_newRoot, FileTypes.SettingsFileName), currentSettingsJson);
+
+ var settings = CreateSettingsService().Get();
+
+ Assert.Equal("Dark", settings.Theme);
+ }
+
+ ///
+ /// Verifies that a fresh install, which has no legacy root at all, is unaffected.
+ ///
+ [Fact]
+ public void FreshInstall_WithoutLegacyRoot_UsesDefaults()
+ {
+ Directory.Delete(_legacyRoot);
+
+ var service = CreateSettingsService();
+ var settings = service.Get();
+
+ Assert.Equal(AppConstants.DefaultThemeName, settings.Theme);
+ Assert.False(settings.IsExplicitlySet(nameof(UserSettings.ApplicationDataPath)));
+ Assert.False(File.Exists(Path.Combine(_newRoot, FileTypes.SettingsFileName)));
+ }
+
+ ///
+ /// Verifies that a failure while looking for the pre-upgrade settings cannot stop startup.
+ ///
+ [Fact]
+ public void FirstLaunch_WhenLegacyLookupThrows_FallsBackToDefaults()
+ {
+ var appConfig = CreateAppConfigMock();
+ appConfig.Setup(config => config.GetLegacyConfiguredDataPath()).Throws(new UnauthorizedAccessException("denied"));
+
+ var service = new UserSettingsService(Mock.Of>(), appConfig.Object);
+
+ Assert.Equal(AppConstants.DefaultThemeName, service.Get().Theme);
+ }
+
+ private static Mock CreateBaseAppConfigMock()
+ {
+ var appConfig = new Mock();
+ appConfig.Setup(config => config.GetMinConcurrentDownloads()).Returns(1);
+ appConfig.Setup(config => config.GetMaxConcurrentDownloads()).Returns(8);
+ appConfig.Setup(config => config.GetMinDownloadTimeoutSeconds()).Returns(30);
+ appConfig.Setup(config => config.GetMaxDownloadTimeoutSeconds()).Returns(600);
+ appConfig.Setup(config => config.GetMinDownloadBufferSizeBytes()).Returns(4096);
+ appConfig.Setup(config => config.GetMaxDownloadBufferSizeBytes()).Returns(1048576);
+ return appConfig;
+ }
+
+ private Mock CreateAppConfigMock()
+ {
+ var appConfig = CreateBaseAppConfigMock();
+ appConfig.Setup(config => config.GetConfiguredDataPath()).Returns(_newRoot);
+ appConfig.Setup(config => config.GetLegacyConfiguredDataPath()).Returns(_legacyRoot);
+ return appConfig;
+ }
+
+ private IAppConfiguration CreateAppConfig() => CreateAppConfigMock().Object;
+
+ private UserSettingsService CreateSettingsService(IAppConfiguration? appConfig = null) =>
+ new(Mock.Of>(), appConfig ?? CreateAppConfig());
+
+ private void WriteLegacySettings(string json) =>
+ File.WriteAllText(Path.Combine(_legacyRoot, FileTypes.SettingsFileName), json);
+
+ private void SeedLegacyDataDirectories()
+ {
+ WriteLegacyFile(Path.Combine(_legacyRoot, DirectoryNames.Profiles, "profile.json"), "profile");
+ WriteLegacyFile(Path.Combine(_legacyRoot, FileTypes.ManifestsDirectory, "content.manifest.json"), "manifest");
+ WriteLegacyFile(Path.Combine(_legacyRoot, FileTypes.WorkspaceMetadataFileName), "workspaces");
+ }
+
+ private void WriteLegacyFile(string path, string content)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, content);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
index 9b37967c3..f5b451106 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
@@ -66,6 +66,8 @@ public void Get_WhenNoFileExists_ReturnsDefaultUserSettings()
Assert.Equal(DownloadDefaults.MaxConcurrentDownloads, settings.MaxConcurrentDownloads);
Assert.True(settings.AllowBackgroundDownloads);
Assert.True(settings.AutoCheckForUpdatesOnStartup);
+ Assert.True(settings.AutoCheckForUpdatesPeriodically);
+ Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, settings.PeriodicUpdateCheckIntervalMinutes);
Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, settings.DefaultWorkspaceStrategy);
}
@@ -231,10 +233,7 @@ public async Task SaveAsync_CreatesDirectoryIfNotExistsAsync()
var nestedPath = Path.Combine(_tempDirectory, "nested", "path");
var settingsPath = Path.Combine(nestedPath, FileTypes.JsonFileExtension);
var service = CreateService();
- var settingsPathField = typeof(UserSettingsService)
- .GetField("_settingsFilePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
- Assert.NotNull(settingsPathField);
- settingsPathField.SetValue(service, settingsPath);
+ service.AdoptSettingsFile(settingsPath);
await service.SaveAsync();
Assert.True(Directory.Exists(nestedPath));
Assert.True(File.Exists(settingsPath));
@@ -262,10 +261,7 @@ public async Task SaveAsync_WithLongPath_CreatesNestedDirectoriesAsync()
var settingsPath = Path.Combine(deepPath, FileTypes.JsonFileExtension);
var service = CreateService();
- var settingsPathField = typeof(UserSettingsService)
- .GetField("_settingsFilePath", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
- Assert.NotNull(settingsPathField);
- settingsPathField.SetValue(service, settingsPath);
+ service.AdoptSettingsFile(settingsPath);
// Act
await service.SaveAsync();
@@ -364,6 +360,196 @@ public void UpdateSettings_EnableDetailedLogging_CanBeSetAndRetrieved(bool enabl
Assert.Equal(enableLogging, currentSettings.EnableDetailedLogging);
}
+ ///
+ /// Verifies that periodic update settings can be set and retrieved correctly.
+ ///
+ [Fact]
+ public void UpdateSettings_PeriodicUpdateSettings_CanBeSetAndRetrieved()
+ {
+ var service = CreateService();
+
+ service.Update(settings =>
+ {
+ settings.AutoCheckForUpdatesPeriodically = false;
+ settings.PeriodicUpdateCheckIntervalMinutes = 15;
+ });
+ var currentSettings = service.Get();
+
+ Assert.False(currentSettings.AutoCheckForUpdatesPeriodically);
+ Assert.Equal(15, currentSettings.PeriodicUpdateCheckIntervalMinutes);
+ }
+
+ ///
+ /// Verifies that pointing the settings file at a file that already holds settings refuses the
+ /// save instead of replacing that file with values read from a different one, and that the
+ /// edits being saved survive the refusal.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task Update_WhenRepointedAtExistingSettingsFile_RefusesToOverwriteItAsync()
+ {
+ var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ var otherPath = Path.Combine(_tempDirectory, "backup.json");
+ var otherJson = """{ "theme": "Light", "maxConcurrentDownloads": 7 }""";
+ File.WriteAllText(currentPath, """{ "theme": "Dark" }""");
+ File.WriteAllText(otherPath, otherJson);
+
+ var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath);
+ service.Update(settings =>
+ {
+ settings.WorkspacePath = "/edited";
+ settings.SettingsFilePath = otherPath;
+ });
+
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+
+ Assert.Equal(otherJson, File.ReadAllText(otherPath));
+ Assert.Equal("/edited", service.Get().WorkspacePath);
+ }
+
+ ///
+ /// Verifies that the same re-point through the combined update-and-save entry point reports
+ /// failure rather than overwriting the file it was pointed at.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task TryUpdateAndSaveAsync_WhenRepointedAtExistingSettingsFile_FailsWithoutOverwritingItAsync()
+ {
+ var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ var otherPath = Path.Combine(_tempDirectory, "backup.json");
+ var otherJson = """{ "theme": "Light", "maxConcurrentDownloads": 7 }""";
+ File.WriteAllText(currentPath, """{ "theme": "Dark" }""");
+ File.WriteAllText(otherPath, otherJson);
+
+ var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath);
+ var saved = await service.TryUpdateAndSaveAsync(settings =>
+ {
+ settings.SettingsFilePath = otherPath;
+ return true;
+ });
+
+ Assert.False(saved);
+ Assert.Equal(otherJson, File.ReadAllText(otherPath));
+ }
+
+ ///
+ /// Verifies that relocating the settings to a path that holds nothing is still honoured, since
+ /// there is nothing there for the save to destroy.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task Update_WhenRepointedAtUnusedPath_SavesTheEditsThereAsync()
+ {
+ var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ var newPath = Path.Combine(_tempDirectory, "moved", FileTypes.SettingsFileName);
+ File.WriteAllText(currentPath, """{ "theme": "Dark" }""");
+
+ var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath);
+ service.Update(settings =>
+ {
+ settings.Theme = "Light";
+ settings.SettingsFilePath = newPath;
+ });
+
+ await service.SaveAsync();
+
+ Assert.Contains("Light", File.ReadAllText(newPath));
+ }
+
+ ///
+ /// Verifies that a refused re-point is recoverable by pointing back at the file the settings
+ /// were read from, so the refusal cannot strand the session.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task Update_AfterRefusedRepoint_SavesAgainOncePointedBackAsync()
+ {
+ var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ var otherPath = Path.Combine(_tempDirectory, "backup.json");
+ var otherJson = """{ "theme": "Light" }""";
+ File.WriteAllText(currentPath, """{ "theme": "Dark" }""");
+ File.WriteAllText(otherPath, otherJson);
+
+ var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath);
+ service.Update(settings =>
+ {
+ settings.WorkspacePath = "/edited";
+ settings.SettingsFilePath = otherPath;
+ });
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+
+ service.Update(settings => settings.SettingsFilePath = currentPath);
+ await service.SaveAsync();
+
+ Assert.Contains("/edited", File.ReadAllText(currentPath));
+ Assert.Equal(otherJson, File.ReadAllText(otherPath));
+ }
+
+ ///
+ /// Verifies that a refused re-point is also recoverable by clearing the path it was refused
+ /// for, so the refusal lasts exactly as long as the file it protects.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task Update_AfterRefusedRepoint_SavesOnceTheConflictingFileIsGoneAsync()
+ {
+ var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ var otherPath = Path.Combine(_tempDirectory, "backup.json");
+ File.WriteAllText(currentPath, """{ "theme": "Dark" }""");
+ File.WriteAllText(otherPath, """{ "theme": "Light" }""");
+
+ var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath);
+ service.Update(settings =>
+ {
+ settings.WorkspacePath = "/edited";
+ settings.SettingsFilePath = otherPath;
+ });
+ await Assert.ThrowsAsync(() => service.SaveAsync());
+
+ File.Delete(otherPath);
+ service.Update(settings => settings.SettingsFilePath = otherPath);
+ await service.SaveAsync();
+
+ Assert.Contains("/edited", File.ReadAllText(otherPath));
+ }
+
+ ///
+ /// Verifies that the ordinary save, where the settings name the very file they were read from,
+ /// is unaffected by the re-point check.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task Update_WhenTheSettingsNameTheFileTheyCameFrom_SavesAsync()
+ {
+ var currentPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ File.WriteAllText(
+ currentPath,
+ $$"""{ "theme": "Dark", "settingsFilePath": {{JsonSerializer.Serialize(currentPath)}} }""");
+
+ var service = new TestableUserSettingsService(_mockLogger.Object, CreateAppConfigMock(), currentPath);
+ service.Update(settings => settings.Theme = "Light");
+
+ await service.SaveAsync();
+
+ Assert.Contains("Light", File.ReadAllText(currentPath));
+ }
+
+ ///
+ /// Verifies that a first run, which has no settings file at all, still persists its settings.
+ ///
+ /// A representing the asynchronous test operation.
+ [Fact]
+ public async Task SaveAsync_OnFirstRunWithoutAnExistingFile_PersistsTheSettingsAsync()
+ {
+ var settingsPath = Path.Combine(_tempDirectory, FileTypes.SettingsFileName);
+ var service = CreateServiceWithPath(settingsPath);
+
+ service.Update(settings => settings.Theme = "Light");
+ await service.SaveAsync();
+
+ Assert.Contains("Light", File.ReadAllText(settingsPath));
+ }
+
private static IAppConfiguration CreateAppConfigMock()
{
var appConfig = new Mock();
@@ -427,5 +613,7 @@ public TestableUserSettingsService(ILogger logger, IAppConf
// We then set the path, which will load from the file if it exists.
SetSettingsFilePath(settingsFilePath);
}
+
+ public void AdoptSettingsFile(string settingsFilePath) => SetSettingsFilePath(settingsFilePath);
}
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs
new file mode 100644
index 000000000..db6350c4e
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs
@@ -0,0 +1,109 @@
+using System;
+using GenHub.Core.Constants;
+using Xunit;
+
+namespace GenHub.Tests.Core.Constants;
+
+///
+/// Unit tests for .
+///
+public class AppUpdateConstantsTests
+{
+ ///
+ /// Tests that tab index constants have expected values.
+ ///
+ [Fact]
+ public void TabIndex_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(0, AppUpdateConstants.UpdateTabIndex);
+ Assert.Equal(1, AppUpdateConstants.BrowseBuildsTabIndex);
+ Assert.Equal(1, AppUpdateConstants.MaxTabIndex);
+ }
+
+ ///
+ /// Tests that platform and artifact prefix constants have expected values.
+ ///
+ [Fact]
+ public void ArtifactAndPlatform_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal("velopack", AppUpdateConstants.VelopackDirectory);
+ Assert.Equal("genhub-velopack-windows-", AppUpdateConstants.ArtifactPrefixWindows);
+ Assert.Equal("genhub-velopack-linux-", AppUpdateConstants.ArtifactPrefixLinux);
+ Assert.Equal("GenHub-Release", AppUpdateConstants.ArtifactNameRelease);
+ Assert.Equal("windows", AppUpdateConstants.PlatformWindows);
+ Assert.Equal("linux", AppUpdateConstants.PlatformLinux);
+ }
+
+ ///
+ /// Tests that periodic update check interval constants have expected values.
+ ///
+ [Fact]
+ public void PeriodicUpdateCheckInterval_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(30, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(5, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(10080, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(5, AppUpdateConstants.PeriodicUpdateCheckIntervalIncrementMinutes);
+ Assert.True(AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes);
+ Assert.True(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes);
+ }
+
+ ///
+ /// Tests that timespan constants have expected durations.
+ ///
+ [Fact]
+ public void TimeSpan_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(5), AppUpdateConstants.PostUpdateExitDelay);
+ Assert.Equal(TimeSpan.FromHours(1), AppUpdateConstants.CacheDuration);
+ Assert.Equal(3, AppUpdateConstants.MaxHttpRetries);
+ }
+
+ ///
+ /// Tests that notification title and format constants are non-empty strings.
+ ///
+ [Fact]
+ public void NotificationAndFormat_Constants_ShouldBeValid()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchUpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrUpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdatingAppNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateFailedNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAction));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ViewUpdatesAction));
+ Assert.Contains("{0}", AppUpdateConstants.ReleaseUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.BranchUpdateNotificationFormat);
+ Assert.Contains("{1}", AppUpdateConstants.BranchUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.PrUpdateNotificationFormat);
+ Assert.Contains("{1}", AppUpdateConstants.PrUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.UpdateFailedNotificationFormat);
+ }
+
+ ///
+ /// Tests that sort option constants are distinct non-empty strings.
+ ///
+ [Fact]
+ public void SortOption_Constants_ShouldBeDistinctAndNonEmpty()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionLastUpdated));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberDesc));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberAsc));
+ Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc);
+ Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc);
+ }
+
+ ///
+ /// Tests that parallel download constants have valid positive values.
+ ///
+ [Fact]
+ public void ParallelDownload_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(131072, AppUpdateConstants.DefaultStreamBufferSize);
+ Assert.Equal(2 * 1024 * 1024, AppUpdateConstants.DownloadChunkSizeBytes);
+ Assert.Equal(8, AppUpdateConstants.ParallelDownloadConcurrency);
+ Assert.Equal(4 * 1024 * 1024, AppUpdateConstants.ParallelDownloadThresholdBytes);
+ Assert.True(AppUpdateConstants.ParallelDownloadConcurrency > 0);
+ Assert.True(AppUpdateConstants.DownloadChunkSizeBytes > 0);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs
new file mode 100644
index 000000000..f163c0603
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Converters/StripHtmlConverterTests.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Globalization;
+using GenHub.Infrastructure.Converters;
+using Xunit;
+
+namespace GenHub.Tests.Core.Converters;
+
+///
+/// Unit tests for .
+///
+public sealed class StripHtmlConverterTests
+{
+ private readonly StripHtmlConverter _converter = new();
+
+ ///
+ /// Verifies Convert strips HTML tags and normalizes text.
+ ///
+ [Fact]
+ public void Convert_WithHtmlMarkup_StripsTags()
+ {
+ var input = "Test content with links.
";
+ var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture);
+
+ Assert.Equal("Test content with links.", result);
+ }
+
+ ///
+ /// Verifies Convert with integer parameter truncates and single-lines text.
+ ///
+ [Fact]
+ public void Convert_WithMaxLenParameter_CleansToSingleLineAndTruncates()
+ {
+ var input = "First line
\n\nSecond line with a lot of details here.
";
+ var result = _converter.Convert(input, typeof(string), 25, CultureInfo.InvariantCulture);
+
+ Assert.Equal("First line Second line...", result);
+ }
+
+ ///
+ /// Verifies Convert with string parameter parses integer and truncates.
+ ///
+ [Fact]
+ public void Convert_WithStringParameter_ParsesAndTruncates()
+ {
+ var input = "First line
\n\nSecond line with a lot of details here.
";
+ var result = _converter.Convert(input, typeof(string), "25", CultureInfo.InvariantCulture);
+
+ Assert.Equal("First line Second line...", result);
+ }
+
+ ///
+ /// Verifies Convert handles non-string input by returning value untouched.
+ ///
+ [Fact]
+ public void Convert_WithScriptAndStyleTags_StripsContents()
+ {
+ var input = "Hello World
";
+ var result = _converter.Convert(input, typeof(string), null, CultureInfo.InvariantCulture);
+
+ Assert.Equal("Hello World", result);
+ }
+
+ ///
+ /// Verifies Convert handles non-string input by returning value untouched.
+ ///
+ [Fact]
+ public void Convert_NonStringValue_ReturnsOriginalValue()
+ {
+ var result = _converter.Convert(42, typeof(int), null, CultureInfo.InvariantCulture);
+ Assert.Equal(42, result);
+ }
+
+ ///
+ /// Verifies ConvertBack throws NotSupportedException.
+ ///
+ [Fact]
+ public void ConvertBack_ThrowsNotSupportedException()
+ {
+ Assert.Throws(() =>
+ _converter.ConvertBack("test", typeof(string), null, CultureInfo.InvariantCulture));
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs
new file mode 100644
index 000000000..0396848f4
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/Enums/ContentInstallTargetExtensionsTests.cs
@@ -0,0 +1,54 @@
+using System;
+using System.Linq;
+using GenHub.Core.Extensions.Enums;
+using GenHub.Core.Models.Enums;
+using Xunit;
+
+namespace GenHub.Tests.Core.Extensions.Enums;
+
+///
+/// Tests for .
+///
+public class ContentInstallTargetExtensionsTests
+{
+ ///
+ /// The four user directories must be copied out of CAS rather than hard-linked, because the game
+ /// engine writes into them in place and would otherwise rewrite the canonical CAS object.
+ ///
+ /// The user-writable target under test.
+ [Theory]
+ [InlineData(ContentInstallTarget.UserDataDirectory)]
+ [InlineData(ContentInstallTarget.UserMapsDirectory)]
+ [InlineData(ContentInstallTarget.UserReplaysDirectory)]
+ [InlineData(ContentInstallTarget.UserScreenshotsDirectory)]
+ public void IsUserWritableTarget_ForUserDirectories_ReturnsTrue(ContentInstallTarget installTarget)
+ {
+ Assert.True(installTarget.IsUserWritableTarget());
+ }
+
+ ///
+ /// Workspace and system installs are managed by GenHub rather than written to by the user, so
+ /// they remain eligible for hard links to CAS.
+ ///
+ /// The GenHub-managed target under test.
+ [Theory]
+ [InlineData(ContentInstallTarget.Workspace)]
+ [InlineData(ContentInstallTarget.System)]
+ public void IsUserWritableTarget_ForGenHubManagedTargets_ReturnsFalse(ContentInstallTarget installTarget)
+ {
+ Assert.False(installTarget.IsUserWritableTarget());
+ }
+
+ ///
+ /// An install target this method has never been taught about must fail towards copying. The path
+ /// resolver sends unmapped targets into the user data root, so answering "not user-writable"
+ /// would hard-link a CAS object straight into the user's Documents folder.
+ ///
+ [Fact]
+ public void IsUserWritableTarget_ForAnUnmappedTarget_FailsTowardsCopying()
+ {
+ var unmapped = Enum.GetValues().Cast().Max() + 1;
+
+ Assert.True(((ContentInstallTarget)unmapped).IsUserWritableTarget());
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs
new file mode 100644
index 000000000..ea4054d5a
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Extensions/GameProfileExtensionsTests.cs
@@ -0,0 +1,145 @@
+using GenHub.Core.Constants;
+using GenHub.Core.Extensions;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GameClients;
+using GenHub.Core.Models.GameProfile;
+
+namespace GenHub.Tests.Core.Extensions;
+
+///
+/// Tests for .
+///
+public class GameProfileExtensionsTests
+{
+ ///
+ /// Verifies that the publisher type identifies a GeneralsOnline profile regardless of casing.
+ ///
+ /// The publisher type recorded on the profile's client.
+ [Theory]
+ [InlineData("generalsonline")]
+ [InlineData("GeneralsOnline")]
+ [InlineData("GENERALSONLINE")]
+ public void IsGeneralsOnlineProfile_WithGeneralsOnlinePublisher_ReturnsTrue(string publisherType)
+ {
+ // Arrange
+ var profile = CreateZeroHourProfile(publisherType, "Zero Hour", []);
+
+ // Act & Assert
+ Assert.True(profile.IsGeneralsOnlineProfile());
+ }
+
+ ///
+ /// Verifies that other Zero Hour publishers are not mistaken for GeneralsOnline, which is what
+ /// kept their launches from overwriting the GeneralsOnline client's settings.json.
+ ///
+ /// The publisher type recorded on the profile's client.
+ [Theory]
+ [InlineData(PublisherTypeConstants.TheSuperHackers)]
+ [InlineData(CommunityOutpostConstants.PublisherType)]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void IsGeneralsOnlineProfile_WithOtherPublisher_ReturnsFalse(string? publisherType)
+ {
+ // Arrange
+ var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.0.genhub.mod.test"]);
+
+ // Act & Assert
+ Assert.False(profile.IsGeneralsOnlineProfile());
+ }
+
+ ///
+ /// Verifies that a recorded publisher settles the question, so a profile belonging to another
+ /// client is not reclassified by content it happens to enable or by its client name. Answering
+ /// otherwise would let it rewrite the GeneralsOnline client's global settings.
+ ///
+ /// The publisher type recorded on the profile's client.
+ [Theory]
+ [InlineData(PublisherTypeConstants.TheSuperHackers)]
+ [InlineData(CommunityOutpostConstants.PublisherType)]
+ public void IsGeneralsOnlineProfile_WithOtherPublisherAndGeneralsOnlineHints_ReturnsFalse(string publisherType)
+ {
+ // Arrange
+ var profile = CreateZeroHourProfile(
+ publisherType,
+ "GeneralsOnline Compatible",
+ ["1.9.generalsonline.gameclient.30hz"]);
+
+ // Act & Assert
+ Assert.False(profile.IsGeneralsOnlineProfile());
+ }
+
+ ///
+ /// Verifies that a profile predating the recorded publisher type is still recognised by its
+ /// client name. Such a profile records no publisher at all, so null is its real shape.
+ ///
+ /// The publisher type recorded on the profile's client.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void IsGeneralsOnlineProfile_WithGeneralsOnlineClientName_ReturnsTrue(string? publisherType)
+ {
+ // Arrange
+ var profile = CreateZeroHourProfile(publisherType, "GeneralsOnline 30Hz", []);
+
+ // Act & Assert
+ Assert.True(profile.IsGeneralsOnlineProfile());
+ }
+
+ ///
+ /// Verifies that a profile predating the recorded publisher type is still recognised by its
+ /// enabled content. Such a profile records no publisher at all, so null is its real shape.
+ ///
+ /// The publisher type recorded on the profile's client.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public void IsGeneralsOnlineProfile_WithGeneralsOnlineContent_ReturnsTrue(string? publisherType)
+ {
+ // Arrange
+ var profile = CreateZeroHourProfile(publisherType, "Zero Hour", ["1.9.generalsonline.gameclient.30hz"]);
+
+ // Act & Assert
+ Assert.True(profile.IsGeneralsOnlineProfile());
+ }
+
+ ///
+ /// Verifies that a profile with no client at all, which is the shape the settings editor sees
+ /// while a profile is being created, falls back to its enabled content.
+ ///
+ /// The content the profile enables.
+ /// Whether that content makes it a GeneralsOnline profile.
+ [Theory]
+ [InlineData("1.9.generalsonline.gameclient.30hz", true)]
+ [InlineData("1.0.genhub.mod.test", false)]
+ public void IsGeneralsOnlineProfile_WithoutGameClient_FallsBackToContent(string contentId, bool expected)
+ {
+ // Arrange
+ var profile = new GameProfile
+ {
+ Id = "profile-1",
+ Name = "Test Profile",
+ EnabledContentIds = [contentId],
+ };
+
+ // Act & Assert
+ Assert.Equal(expected, profile.IsGeneralsOnlineProfile());
+ }
+
+ private static GameProfile CreateZeroHourProfile(string? publisherType, string clientName, List enabledContentIds)
+ {
+ return new GameProfile
+ {
+ Id = "profile-1",
+ Name = "Test Profile",
+ GameClient = new GameClient
+ {
+ Id = "client-1",
+ Name = clientName,
+ GameType = GameType.ZeroHour,
+ PublisherType = publisherType,
+ },
+ EnabledContentIds = enabledContentIds,
+ };
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs
new file mode 100644
index 000000000..2c5db6ecf
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/FastHttpClientFileDownloaderTests.cs
@@ -0,0 +1,535 @@
+using System;
+using System.Collections.Concurrent;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Features.AppUpdate.Services;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.AppUpdate.Services;
+
+///
+/// Unit tests for .
+///
+public class FastHttpClientFileDownloaderTests : IDisposable
+{
+ private sealed class TestHttpMessageHandler(Func handlerFunc) : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return Task.FromCanceled(cancellationToken);
+ }
+
+ return Task.FromResult(handlerFunc(request));
+ }
+ }
+
+ private readonly Mock> _mockLogger = new();
+ private readonly string _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-downloader-tests-{Guid.NewGuid():N}");
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FastHttpClientFileDownloaderTests()
+ {
+ Directory.CreateDirectory(_tempDirectory);
+ }
+
+ ///
+ /// Disposes test resources and cleans up temporary directories.
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDirectory))
+ {
+ try
+ {
+ Directory.Delete(_tempDirectory, recursive: true);
+ }
+ catch
+ {
+ // Ignore test directory cleanup failures
+ }
+ }
+ }
+
+ ///
+ /// Tests that the downloader can be initialized with and without a logger.
+ ///
+ [Fact]
+ public void Constructor_ShouldInitializeSuccessfully()
+ {
+ var downloaderWithoutLogger = new FastHttpClientFileDownloader();
+ var downloaderWithLogger = new FastHttpClientFileDownloader(_mockLogger.Object);
+
+ Assert.NotNull(downloaderWithoutLogger);
+ Assert.NotNull(downloaderWithLogger);
+ }
+
+ ///
+ /// Tests that DownloadFile throws ArgumentException when URL is invalid.
+ ///
+ /// The invalid URL string.
+ /// A representing the asynchronous operation.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task DownloadFile_WithInvalidUrl_ShouldThrowArgumentExceptionAsync(string? invalidUrl)
+ {
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object);
+ var targetFile = Path.Combine(_tempDirectory, "test.tmp");
+
+ await Assert.ThrowsAnyAsync(
+ () => downloader.DownloadFile(invalidUrl!, targetFile, _ => { }, null, 30));
+ }
+
+ ///
+ /// Tests that DownloadFile throws ArgumentException when target file path is invalid.
+ ///
+ /// The invalid target file path string.
+ /// A representing the asynchronous operation.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task DownloadFile_WithInvalidTargetFile_ShouldThrowArgumentExceptionAsync(string? invalidTargetFile)
+ {
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object);
+
+ await Assert.ThrowsAnyAsync(
+ () => downloader.DownloadFile("https://example.com/file.zip", invalidTargetFile!, _ => { }, null, 30));
+ }
+
+ ///
+ /// Tests that parallel chunk downloading correctly assembles multi-chunk files and reports progress monotonically.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_ParallelRange_ValidAssembly_ShouldDownloadAndVerifyContentAsync()
+ {
+ // 6 MB file (3 chunks of 2 MB)
+ var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3;
+ var sourceBytes = new byte[totalBytes];
+ new Random(42).NextBytes(sourceBytes);
+
+ var progressHistory = new ConcurrentQueue();
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var range = request.Headers.Range?.Ranges.FirstOrDefault();
+ if (range is { From: 0, To: 0 })
+ {
+ // Probe request
+ var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent([sourceBytes[0]]),
+ RequestMessage = request,
+ };
+ probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" };
+ return probeResponse;
+ }
+
+ if (range is { From: { } from, To: { } to })
+ {
+ var length = (int)(to - from + 1);
+ var chunkData = new byte[length];
+ Array.Copy(sourceBytes, from, chunkData, 0, length);
+
+ var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent(chunkData),
+ RequestMessage = request,
+ };
+ chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" };
+ return chunkResponse;
+ }
+
+ var fullResponse = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(sourceBytes),
+ RequestMessage = request,
+ };
+ return fullResponse;
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "parallel-output.bin");
+
+ await downloader.DownloadFile(
+ "https://github.com/community-outpost/GenHub/releases/download/v1.0.0/test.bin",
+ targetFile,
+ progressHistory.Enqueue,
+ null,
+ 30);
+
+ Assert.True(File.Exists(targetFile));
+ var downloadedBytes = await File.ReadAllBytesAsync(targetFile);
+ Assert.Equal(sourceBytes, downloadedBytes);
+
+ var progressList = progressHistory.ToList();
+ Assert.NotEmpty(progressList);
+ Assert.Equal(100, progressList.Last());
+ }
+
+ ///
+ /// Tests that small files below the parallel threshold use single-stream mode without chunking.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_SmallFileBelowThreshold_ShouldUseSingleStreamAsync()
+ {
+ var smallBytes = new byte[1024 * 1024]; // 1 MB
+ new Random(42).NextBytes(smallBytes);
+
+ var chunkRequestsCount = 0;
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var range = request.Headers.Range?.Ranges.FirstOrDefault();
+ if (range is { From: 0, To: 0 })
+ {
+ // Probe response indicates 1MB file
+ var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent([smallBytes[0]]),
+ RequestMessage = request,
+ };
+ probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, smallBytes.Length) { Unit = "bytes" };
+ return probeResponse;
+ }
+
+ if (range is not null)
+ {
+ Interlocked.Increment(ref chunkRequestsCount);
+ }
+
+ var fullResponse = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(smallBytes),
+ RequestMessage = request,
+ };
+ return fullResponse;
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "small-file.bin");
+
+ await downloader.DownloadFile("https://example.com/small.bin", targetFile, _ => { }, null, 30);
+
+ Assert.True(File.Exists(targetFile));
+ var downloadedBytes = await File.ReadAllBytesAsync(targetFile);
+ Assert.Equal(smallBytes, downloadedBytes);
+ Assert.Equal(0, chunkRequestsCount);
+ }
+
+ ///
+ /// Tests that when the server ignores range headers (returning 200 OK on probe), the downloader streams directly without error.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_ServerIgnoresRange_ShouldStreamProbeResponseDirectlyAsync()
+ {
+ var fileBytes = new byte[1024 * 512]; // 512 KB
+ new Random(1337).NextBytes(fileBytes);
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(fileBytes),
+ RequestMessage = request,
+ };
+ return response;
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "ignored-range.bin");
+
+ await downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30);
+
+ Assert.True(File.Exists(targetFile));
+ var downloadedBytes = await File.ReadAllBytesAsync(targetFile);
+ Assert.Equal(fileBytes, downloadedBytes);
+ }
+
+ ///
+ /// Tests that when a chunk response returns an invalid Content-Range header, the downloader falls back to single-stream.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_InvalidContentRange_ShouldFallbackToSingleStreamAsync()
+ {
+ var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2;
+ var sourceBytes = new byte[totalBytes];
+ new Random(77).NextBytes(sourceBytes);
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var range = request.Headers.Range?.Ranges.FirstOrDefault();
+ if (range is { From: 0, To: 0 })
+ {
+ // Probe response
+ var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent([sourceBytes[0]]),
+ RequestMessage = request,
+ };
+ probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" };
+ return probeResponse;
+ }
+
+ if (range is not null)
+ {
+ // Return mismatched Content-Range
+ var badResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent(new byte[100]),
+ RequestMessage = request,
+ };
+ badResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(999, 1098, totalBytes) { Unit = "bytes" };
+ return badResponse;
+ }
+
+ // Fallback path sends full payload
+ var fullResponse = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(sourceBytes),
+ RequestMessage = request,
+ };
+ return fullResponse;
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "fallback-invalid-range.bin");
+
+ await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30);
+
+ Assert.True(File.Exists(targetFile));
+ var downloadedBytes = await File.ReadAllBytesAsync(targetFile);
+ Assert.Equal(sourceBytes, downloadedBytes);
+ }
+
+ ///
+ /// Tests that when a chunk response streams fewer bytes than requested, the downloader falls back to single-stream.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_ShortChunkStream_ShouldFallbackToSingleStreamAsync()
+ {
+ var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2;
+ var sourceBytes = new byte[totalBytes];
+ new Random(99).NextBytes(sourceBytes);
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var range = request.Headers.Range?.Ranges.FirstOrDefault();
+ if (range is { From: 0, To: 0 })
+ {
+ // Probe response
+ var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent([sourceBytes[0]]),
+ RequestMessage = request,
+ };
+ probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" };
+ return probeResponse;
+ }
+
+ if (range is { From: { } from, To: { } to })
+ {
+ // Return short stream (100 bytes instead of expected chunk length)
+ var shortResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent(new byte[100]),
+ RequestMessage = request,
+ };
+ shortResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" };
+ return shortResponse;
+ }
+
+ // Fallback path sends full payload
+ var fullResponse = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(sourceBytes),
+ RequestMessage = request,
+ };
+ return fullResponse;
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "fallback-short-chunk.bin");
+
+ await downloader.DownloadFile("https://example.com/large.bin", targetFile, _ => { }, null, 30);
+
+ Assert.True(File.Exists(targetFile));
+ var downloadedBytes = await File.ReadAllBytesAsync(targetFile);
+ Assert.Equal(sourceBytes, downloadedBytes);
+ }
+
+ ///
+ /// Tests that progress reporting is strictly monotonic (never moves backward) and throttled to at most 101 updates.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_ProgressReporting_ShouldBeStrictlyMonotonicAndThrottledAsync()
+ {
+ var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 3; // 24 MB
+ var sourceBytes = new byte[totalBytes];
+
+ var progressHistory = new ConcurrentQueue();
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var range = request.Headers.Range?.Ranges.FirstOrDefault();
+ if (range is { From: 0, To: 0 })
+ {
+ var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent([0]),
+ RequestMessage = request,
+ };
+ probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" };
+ return probeResponse;
+ }
+
+ if (range is { From: { } from, To: { } to })
+ {
+ var length = (int)(to - from + 1);
+ var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent(new byte[length]),
+ RequestMessage = request,
+ };
+ chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" };
+ return chunkResponse;
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(sourceBytes),
+ RequestMessage = request,
+ };
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "progress-test.bin");
+
+ await downloader.DownloadFile("https://example.com/file.bin", targetFile, progressHistory.Enqueue, null, 30);
+
+ var progressList = progressHistory.ToList();
+
+ Assert.NotEmpty(progressList);
+ Assert.Equal(100, progressList.Last());
+
+ // Verify strictly monotonic ordering (each progress event >= previous)
+ for (var i = 1; i < progressList.Count; i++)
+ {
+ Assert.True(progressList[i] >= progressList[i - 1], $"Progress moved backward from {progressList[i - 1]} to {progressList[i]}");
+ }
+
+ // Verify throttling: no more than 101 progress updates (0 to 100)
+ Assert.True(progressList.Count <= 101, $"Progress was called {progressList.Count} times, exceeding maximum throttled limit of 101");
+ }
+
+ ///
+ /// Tests that cancellation tokens are properly observed and propagated.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_WhenCancelled_ShouldThrowOperationCanceledExceptionAsync()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ var handler = new TestHttpMessageHandler(request => new HttpResponseMessage(HttpStatusCode.OK));
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "canceled.bin");
+
+ await Assert.ThrowsAnyAsync(
+ () => downloader.DownloadFile("https://example.com/file.bin", targetFile, _ => { }, null, 30, cts.Token));
+ }
+
+ ///
+ /// Tests that when redirected to a cross-origin storage host, the Authorization header is omitted from chunk requests.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task DownloadFile_WhenRedirectedToCrossOriginCdn_ShouldStripAuthorizationHeaderOnChunksAsync()
+ {
+ var totalBytes = AppUpdateConstants.DownloadChunkSizeBytes * 2;
+ var sourceBytes = new byte[totalBytes];
+ new Random(42).NextBytes(sourceBytes);
+
+ var chunkAuthHeadersPresent = 0;
+
+ var handler = new TestHttpMessageHandler(request =>
+ {
+ var range = request.Headers.Range?.Ranges.FirstOrDefault();
+ if (range is { From: 0, To: 0 })
+ {
+ var probeResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent([sourceBytes[0]]),
+ RequestMessage = new HttpRequestMessage(HttpMethod.Get, "https://cdn.blob.core.windows.net/artifacts/file.zip"),
+ };
+ probeResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, totalBytes) { Unit = "bytes" };
+ return probeResponse;
+ }
+
+ if (range is { From: { } from, To: { } to })
+ {
+ if (request.Headers.Contains("Authorization"))
+ {
+ Interlocked.Increment(ref chunkAuthHeadersPresent);
+ }
+
+ var length = (int)(to - from + 1);
+ var chunkData = new byte[length];
+ Array.Copy(sourceBytes, from, chunkData, 0, length);
+
+ var chunkResponse = new HttpResponseMessage(HttpStatusCode.PartialContent)
+ {
+ Content = new ByteArrayContent(chunkData),
+ RequestMessage = request,
+ };
+ chunkResponse.Content.Headers.ContentRange = new ContentRangeHeaderValue(from, to, totalBytes) { Unit = "bytes" };
+ return chunkResponse;
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(sourceBytes),
+ RequestMessage = request,
+ };
+ });
+
+ var downloader = new FastHttpClientFileDownloader(_mockLogger.Object, handler);
+ var targetFile = Path.Combine(_tempDirectory, "cross-origin-test.bin");
+ var headers = new Dictionary
+ {
+ { "Authorization", "Bearer test_pat_token" },
+ { "User-Agent", "GenHub" },
+ };
+
+ await downloader.DownloadFile(
+ "https://api.github.com/repos/community-outpost/GenHub/actions/artifacts/123/zip",
+ targetFile,
+ _ => { },
+ headers,
+ 30);
+
+ Assert.True(File.Exists(targetFile));
+ Assert.Equal(sourceBytes, await File.ReadAllBytesAsync(targetFile));
+ Assert.Equal(0, chunkAuthHeadersPresent);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs
index 5719dac51..18d980766 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/VelopackUpdateManagerTests.cs
@@ -3,6 +3,7 @@
using GenHub.Features.AppUpdate.Services;
using Microsoft.Extensions.Logging;
using Moq;
+using Velopack.Sources;
namespace GenHub.Tests.Core.Features.AppUpdate.Services;
@@ -188,6 +189,28 @@ public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNullAsync(
Assert.False(manager.HasArtifactUpdateAvailable);
}
+ ///
+ /// Tests that VelopackUpdateManager accepts a custom IFileDownloader.
+ ///
+ [Fact]
+ public void Constructor_WithCustomFileDownloader_ShouldInitializeSuccessfully()
+ {
+ // Arrange
+ var customDownloader = new Mock().Object;
+
+ // Act
+ var manager = new VelopackUpdateManager(
+ _mockLogger.Object,
+ _mockHttpClientFactory.Object,
+ _mockGitHubTokenStorage.Object,
+ _mockUserSettingsService.Object,
+ customDownloader);
+
+ // Assert
+ Assert.NotNull(manager);
+ Assert.False(manager.IsUpdatePendingRestart);
+ }
+
///
/// Creates a new VelopackUpdateManager instance with mocked dependencies.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
index fed2373b8..5ad314dec 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
@@ -1,8 +1,15 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Models.AppUpdate;
+using GenHub.Core.Models.Common;
using GenHub.Features.AppUpdate.Interfaces;
using GenHub.Features.AppUpdate.ViewModels;
using Microsoft.Extensions.Logging;
using Moq;
+using Xunit;
namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels;
@@ -23,7 +30,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn
.ReturnsAsync((Velopack.UpdateInfo?)null);
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
mockVelopack.Object,
@@ -43,7 +50,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn
public void Constructor_InitializesSuccessfully()
{
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
Mock.Of(),
@@ -63,7 +70,7 @@ public void Constructor_InitializesSuccessfully()
public void IsCheckButtonEnabled_ReflectsCheckingState()
{
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
Mock.Of(),
@@ -72,4 +79,515 @@ public void IsCheckButtonEnabled_ReflectsCheckingState()
Assert.True(vm.IsCheckButtonEnabled);
}
+
+ ///
+ /// Verifies that pull request display title formats properly with PR number and title.
+ ///
+ [Fact]
+ public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle()
+ {
+ var prInfo = new PullRequestInfo
+ {
+ Number = 265,
+ Title = "feat: UI Downloads",
+ BranchName = "feat/ui-downloads",
+ Author = "developer",
+ State = "open",
+ UpdatedAt = DateTimeOffset.UtcNow,
+ };
+
+ Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle);
+ }
+
+ ///
+ /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var artifacts = new List
+ {
+ new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024),
+ };
+
+ var loadTcs = new TaskCompletionSource>();
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => loadTcs.TrySetCanceled(ct));
+ return await loadTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToPrCommand.Execute(389);
+
+ Assert.True(vm.IsLoadingVersions);
+ loadTcs.SetResult(artifacts);
+
+ // wait briefly for async continuation
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Equal(2, vm.AvailableVersions.Count);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version);
+ Assert.Equal("e1212a5", vm.SelectedVersion.GitHash);
+ Assert.True(vm.CanDownloadUpdate);
+ }
+
+ ///
+ /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var artifacts = new List
+ {
+ new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048),
+ };
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny()))
+ .ReturnsAsync(artifacts);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToBranchCommand.Execute("development");
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version);
+ }
+
+ ///
+ /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var pr391Tcs = new TaskCompletionSource>();
+ var pr389Tcs = new TaskCompletionSource>();
+
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => pr391Tcs.TrySetCanceled(ct));
+ return await pr391Tcs.Task;
+ });
+
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => pr389Tcs.TrySetCanceled(ct));
+ return await pr389Tcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ // subscribe to 391 first
+ vm.SubscribeToPrCommand.Execute(391);
+ Assert.True(vm.IsLoadingVersions);
+
+ // immediately switch to 389 while 391 is loading
+ vm.SubscribeToPrCommand.Execute(389);
+
+ // resolve 389 artifacts
+ var pr389Artifacts = new List
+ {
+ new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ };
+ pr389Tcs.TrySetResult(pr389Artifacts);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(pr391Tcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version);
+ Assert.Equal(389, vm.SelectedVersion.PullRequestNumber);
+ }
+
+ ///
+ /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var branchOldTcs = new TaskCompletionSource>();
+ var branchNewTcs = new TaskCompletionSource>();
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny()))
+ .Returns(async (string _, CancellationToken ct) =>
+ {
+ ct.Register(() => branchOldTcs.TrySetCanceled(ct));
+ return await branchOldTcs.Task;
+ });
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny()))
+ .Returns(async (string _, CancellationToken ct) =>
+ {
+ ct.Register(() => branchNewTcs.TrySetCanceled(ct));
+ return await branchNewTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToBranchCommand.Execute("old-branch");
+ Assert.True(vm.IsLoadingVersions);
+
+ vm.SubscribeToBranchCommand.Execute("new-branch");
+
+ var newArtifacts = new List
+ {
+ new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048),
+ };
+ branchNewTcs.TrySetResult(newArtifacts);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(branchOldTcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version);
+ }
+
+ ///
+ /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var prTcs = new TaskCompletionSource>();
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => prTcs.TrySetCanceled(ct));
+ return await prTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToPrCommand.Execute(391);
+ Assert.True(vm.IsLoadingVersions);
+
+ vm.UnsubscribeCommand.Execute(null);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(prTcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Empty(vm.AvailableVersions);
+ Assert.Null(vm.SelectedVersion);
+ }
+
+ ///
+ /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers.
+ ///
+ /// The PR number under test.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber)
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ // verify command execution does not throw
+ vm.OpenPullRequestUrlCommand.Execute(prNumber);
+ Assert.NotNull(vm);
+ }
+
+ ///
+ /// Verifies that changing the sort option reorders available pull requests accordingly.
+ ///
+ [Fact]
+ public void SelectedSortOption_ReordersAvailablePullRequests()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ var now = DateTimeOffset.UtcNow;
+ var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) };
+ var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) };
+ var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now };
+
+ vm.AvailablePullRequests.Add(pr100);
+ vm.AvailablePullRequests.Add(pr200);
+ vm.AvailablePullRequests.Add(pr300);
+
+ // sort by PR number descending
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc;
+ Assert.Equal(300, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(100, vm.AvailablePullRequests[2].Number);
+
+ // sort by PR number ascending
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc;
+ Assert.Equal(100, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(300, vm.AvailablePullRequests[2].Number);
+
+ // sort by last updated (newest first)
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated;
+ Assert.Equal(300, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(100, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[2].Number);
+ }
+
+ ///
+ /// Verifies that tab commands correctly switch between Update and Browse Builds tabs.
+ ///
+ [Fact]
+ public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ vm.ShowBrowseBuildsTabCommand.Execute(null);
+ Assert.Equal(1, vm.SelectedTabIndex);
+ Assert.True(vm.IsBrowseTabSelected);
+
+ vm.ShowUpdateTabCommand.Execute(null);
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ vm.SelectTabCommand.Execute("1");
+ Assert.Equal(1, vm.SelectedTabIndex);
+ Assert.True(vm.IsBrowseTabSelected);
+
+ vm.SelectTabCommand.Execute(0);
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ // Clamping out-of-range inputs
+ vm.SelectTabCommand.Execute(-1);
+ Assert.Equal(0, vm.SelectedTabIndex);
+
+ vm.SelectTabCommand.Execute(5);
+ Assert.Equal(1, vm.SelectedTabIndex);
+
+ vm.SelectTabCommand.Execute("99");
+ Assert.Equal(1, vm.SelectedTabIndex);
+ }
+
+ ///
+ /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string.
+ ///
+ [Fact]
+ public void DisplayCurrentVersion_ReturnsNonEmptyVersion()
+ {
+ var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion;
+ Assert.False(string.IsNullOrWhiteSpace(displayVersion));
+ Assert.StartsWith("v", displayVersion);
+
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(displayVersion, vm.InstalledVersionDisplay);
+ }
+
+ ///
+ /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true.
+ ///
+ [Fact]
+ public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024);
+ vm.SelectedVersion = newerArtifact;
+
+ Assert.True(vm.IsUpdateAvailable);
+ Assert.Equal("0.0.99999-pr389", vm.LatestVersion);
+ Assert.Contains("0.0.99999-pr389", vm.StatusMessage);
+ }
+
+ ///
+ /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl.
+ ///
+ [Fact]
+ public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" });
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object)
+ {
+ IsUpdateAvailable = true,
+ LatestVersion = "0.0.88888",
+ ReleaseNotesUrl = "https://example.com/notes",
+ };
+
+ var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024);
+ vm.SelectedVersion = dismissedArtifact;
+
+ Assert.False(vm.IsUpdateAvailable);
+ Assert.Empty(vm.LatestVersion);
+ Assert.Empty(vm.ReleaseNotesUrl);
+ Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message.
+ ///
+ [Fact]
+ public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 });
+
+ var mockVelopack = new Mock();
+ mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389);
+ mockVelopack.SetupProperty(x => x.SubscribedBranch, null);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object)
+ {
+ SubscribedPr = new PullRequestInfo
+ {
+ Number = 389,
+ Title = "Test PR",
+ BranchName = "feature/test",
+ Author = "testuser",
+ State = "open",
+ },
+ SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ IsUpdateAvailable = true,
+ LatestVersion = "0.0.99999-pr389",
+ ReleaseNotesUrl = "https://example.com/notes",
+ };
+
+ vm.UnsubscribeCommand.Execute(null);
+
+ Assert.Null(vm.SubscribedPr);
+ Assert.Null(vm.SubscribedBranch);
+ Assert.Null(vm.SelectedVersion);
+ Assert.False(vm.IsUpdateAvailable);
+ Assert.Empty(vm.LatestVersion);
+ Assert.Empty(vm.ReleaseNotesUrl);
+ Assert.False(string.IsNullOrEmpty(vm.StatusMessage));
+ Assert.Null(mockVelopack.Object.SubscribedPrNumber);
+ }
+
+ ///
+ /// Verifies that InitializeAsync seeds SubscribedPr immediately from user settings.
+ ///
+ [Fact]
+ public void Constructor_WhenPrSubscribedInSettings_SeedsSubscribedPr()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 242 });
+
+ var mockVelopack = new Mock();
+ mockVelopack.SetupProperty(x => x.SubscribedPrNumber);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(242, mockVelopack.Object.SubscribedPrNumber);
+ Assert.NotNull(vm.SubscribedPr);
+ Assert.Equal(242, vm.SubscribedPr.Number);
+ }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs
index 21b6cd066..3be9893e8 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/BaseContentProviderTests.cs
@@ -1,11 +1,18 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Interfaces.Content;
using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Manifest;
using GenHub.Core.Models.Results;
using GenHub.Core.Models.Validation;
using GenHub.Features.Content.Services.ContentProviders;
using Microsoft.Extensions.Logging;
using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
namespace GenHub.Tests.Core.Features.Content;
@@ -15,14 +22,15 @@ namespace GenHub.Tests.Core.Features.Content;
public class BaseContentProviderTests
{
///
- /// Verifies that PrepareContentAsync validates manifest before preparation.
+ /// Verifies that PrepareContentAsync validates manifest before preparation and executes post-install steps.
///
/// A task representing the asynchronous operation.
[Fact]
- public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync()
+ public async Task PrepareContentAsync_ValidatesManifestAndExecutesPostInstallStepsAsync()
{
// Arrange
var validatorMock = new Mock();
+ var instructionsMock = new Mock();
var loggerMock = new Mock();
var discovererMock = new Mock();
var resolverMock = new Mock();
@@ -40,7 +48,22 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync()
})
.ReturnsAsync(validationResult);
- var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object);
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess());
+
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
// Act
var result = await provider.PrepareContentAsync(manifest, "/tmp/test");
@@ -48,9 +71,101 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparationAsync()
// Assert
Assert.True(result.Success);
validatorMock.Verify(v => v.ValidateManifestAsync(manifest, It.IsAny()), Times.Once);
+ instructionsMock.Verify(i => i.ExecutePostInstallStepsAsync(manifest, "/tmp/test", "Test Provider", false, It.IsAny>(), It.IsAny()), Times.Once);
validatorMock.Verify(v => v.ValidateAllAsync(It.IsAny(), manifest, It.IsAny>(), It.IsAny()), Times.Once);
}
+ ///
+ /// Verifies that PrepareContentAsync fails and triggers rollback when post-install steps fail.
+ ///
+ /// A task representing the asynchronous operation.
+ [Fact]
+ public async Task PrepareContentAsync_FailsWhenPostInstallStepsFailAsync()
+ {
+ // Arrange
+ var validatorMock = new Mock();
+ var instructionsMock = new Mock();
+ var loggerMock = new Mock();
+ var discovererMock = new Mock();
+ var resolverMock = new Mock();
+ var delivererMock = new Mock();
+
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" };
+ var validationResult = new ValidationResult(manifest.Id, new List());
+
+ validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
+ .ReturnsAsync(validationResult);
+
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("Post-install step execution error"));
+
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
+
+ // Act
+ var result = await provider.PrepareContentAsync(manifest, "/tmp/test");
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("Post-install step execution error", result.FirstError);
+ Assert.True(provider.RollbackCalled);
+ }
+
+ ///
+ /// Verifies that PrepareContentAsync triggers rollback when post-install steps are canceled.
+ ///
+ /// A task representing the asynchronous operation.
+ [Fact]
+ public async Task PrepareContentAsync_CancelsAndTriggersRollbackAsync()
+ {
+ // Arrange
+ var validatorMock = new Mock();
+ var instructionsMock = new Mock();
+ var loggerMock = new Mock();
+ var discovererMock = new Mock();
+ var resolverMock = new Mock();
+ var delivererMock = new Mock();
+
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.content", Name = "Test" };
+ var validationResult = new ValidationResult(manifest.Id, new List());
+
+ validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
+ .ReturnsAsync(validationResult);
+
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ThrowsAsync(new OperationCanceledException());
+
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => provider.PrepareContentAsync(manifest, "/tmp/test"));
+
+ Assert.True(provider.RollbackCalled);
+ }
+
///
/// Verifies that PrepareContentAsync fails when manifest validation fails with errors.
///
@@ -60,6 +175,7 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync(
{
// Arrange
var validatorMock = new Mock();
+ var instructionsMock = new Mock();
var loggerMock = new Mock();
var discovererMock = new Mock();
var resolverMock = new Mock();
@@ -75,7 +191,13 @@ public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrorsAsync(
validatorMock.Setup(v => v.ValidateManifestAsync(manifest, It.IsAny()))
.ReturnsAsync(validationResult);
- var provider = new TestContentProvider(validatorMock.Object, loggerMock.Object, discovererMock.Object, resolverMock.Object, delivererMock.Object);
+ var provider = new TestContentProvider(
+ validatorMock.Object,
+ instructionsMock.Object,
+ loggerMock.Object,
+ discovererMock.Object,
+ resolverMock.Object,
+ delivererMock.Object);
// Act
var result = await provider.PrepareContentAsync(manifest, "/tmp/test");
@@ -94,13 +216,16 @@ private class TestContentProvider : BaseContentProvider
private readonly IContentResolver _resolver;
private readonly IContentDeliverer _deliverer;
+ public bool RollbackCalled { get; private set; }
+
public TestContentProvider(
IContentValidator validator,
+ IInstallationInstructionsService instructionsService,
ILogger logger,
IContentDiscoverer discoverer,
IContentResolver resolver,
IContentDeliverer deliverer)
- : base(validator, logger)
+ : base(validator, instructionsService, logger)
{
_discoverer = discoverer;
_resolver = resolver;
@@ -117,9 +242,19 @@ public TestContentProvider(
protected override IContentDeliverer Deliverer => _deliverer;
- public override Task> GetValidatedContentAsync(string contentId, CancellationToken cancellationToken = default)
+ public override Task> GetValidatedContentAsync(
+ string contentId,
+ CancellationToken cancellationToken = default)
{
- var manifest = new ContentManifest { Id = contentId, Name = $"Content {contentId}" };
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create(contentId),
+ Name = "Test Content",
+ Version = "1.0.0",
+ ContentType = ContentType.Map,
+ TargetGame = GameType.Generals,
+ };
+
return Task.FromResult(OperationResult.CreateSuccess(manifest));
}
@@ -128,5 +263,15 @@ protected override Task> PrepareContentInternal
{
return Task.FromResult(OperationResult.CreateSuccess(manifest));
}
+
+ protected override Task RollbackPreparedContentAsync(
+ ContentManifest originalManifest,
+ ContentManifest preparedManifest,
+ string workingDirectory,
+ CancellationToken cancellationToken)
+ {
+ RollbackCalled = true;
+ return Task.CompletedTask;
+ }
}
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
index 5422334ce..33d4f7d2a 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
@@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate
await Assert.ThrowsAnyAsync(
() => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token));
}
+
+ ///
+ /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync()
+ {
+ // Arrange
+ var specializedProviderMock = new Mock();
+ var githubProviderMock = new Mock();
+
+ const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2";
+
+ var specializedResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "TheSuperHackers Patch 2",
+ ProviderName = "thesuperhackers",
+ };
+
+ var githubResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "GeneralsGamePatch2",
+ ProviderName = "GitHub",
+ };
+
+ specializedProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult]));
+
+ githubProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([githubResult]));
+
+ var orchestrator = new ContentOrchestrator(
+ _loggerMock.Object,
+ [githubProviderMock.Object, specializedProviderMock.Object],
+ [],
+ [],
+ _cacheMock.Object,
+ _contentValidatorMock.Object,
+ _manifestPoolMock.Object,
+ _installationServiceMock.Object,
+ _installationCasPoolServiceMock.Object);
+
+ // Act
+ var result = await orchestrator.SearchAsync(new ContentSearchQuery());
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal("thesuperhackers", items[0].ProviderName);
+ Assert.Equal("TheSuperHackers Patch 2", items[0].Name);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
index a50f687ff..8ecfe2931 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubContentProviderTests.cs
@@ -46,12 +46,23 @@ public GitHubContentProviderTests()
_validatorMock.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()))
.ReturnsAsync(new ValidationResult("test", []));
+ var instructionsMock = new Mock();
+ instructionsMock.Setup(i => i.ExecutePostInstallStepsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess());
+
_provider = new GitHubContentProvider(
[_discovererMock.Object],
[_resolverMock.Object],
[_delivererMock.Object],
_loggerMock.Object,
- _validatorMock.Object);
+ _validatorMock.Object,
+ instructionsMock.Object);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs
new file mode 100644
index 000000000..2eb524a35
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/InstallationInstructionsServiceTests.cs
@@ -0,0 +1,1000 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.Notifications;
+using GenHub.Core.Models.Common;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content;
+
+///
+/// Unit tests for .
+///
+public sealed class InstallationInstructionsServiceTests : IDisposable
+{
+ private readonly string _tempDirectory;
+ private readonly Mock _hashProviderMock;
+ private readonly Mock _notificationServiceMock;
+ private readonly Mock _userSettingsServiceMock;
+ private readonly UserSettings _userSettings;
+ private readonly InstallationInstructionsService _service;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public InstallationInstructionsServiceTests()
+ {
+ _tempDirectory = Path.Combine(Path.GetTempPath(), $"genhub-inst-tests-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_tempDirectory);
+
+ _hashProviderMock = new Mock();
+ _notificationServiceMock = new Mock();
+ _userSettingsServiceMock = new Mock();
+ _userSettings = new UserSettings();
+
+ _userSettingsServiceMock.Setup(u => u.Get()).Returns(_userSettings);
+ _userSettingsServiceMock.Setup(u => u.Update(It.IsAny>()))
+ .Callback>(action => action(_userSettings));
+
+ _service = new InstallationInstructionsService(
+ _hashProviderMock.Object,
+ _notificationServiceMock.Object,
+ _userSettingsServiceMock.Object,
+ NullLogger.Instance);
+ }
+
+ ///
+ /// Cleans up temporary resources after test execution.
+ ///
+ public void Dispose()
+ {
+ if (Directory.Exists(_tempDirectory))
+ {
+ try
+ {
+ Directory.Delete(_tempDirectory, recursive: true);
+ }
+ catch
+ {
+ // Ignore cleanup error
+ }
+ }
+ }
+
+ ///
+ /// Verifies that executing post-install steps succeeds when no steps are declared.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_NullOrEmptySteps_ReturnsSuccess()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions();
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory);
+
+ Assert.True(result.Success);
+ }
+
+ ///
+ /// Verifies that executing installer steps from an untrusted provider fails even if manifest metadata claims to be trusted.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Run Malicious Executable",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "malicious.exe",
+ },
+ ],
+ };
+
+ // Manifest claims GeneralsOnline, but providerSource is untrusted
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source");
+
+ Assert.False(result.Success);
+ Assert.Contains("not authorized to execute installation steps", result.FirstError);
+ }
+
+ ///
+ /// Verifies that mutating steps like RemoveFile and RenameFile fail and do not modify files on disk when provider is untrusted.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_UntrustedProvider_MutatingSteps_FailExecution()
+ {
+ var importantFilePath = Path.Combine(_tempDirectory, "important.dat");
+ var sourceFilePath = Path.Combine(_tempDirectory, "source.dat");
+ var destFilePath = Path.Combine(_tempDirectory, "dest.dat");
+
+ await File.WriteAllTextAsync(importantFilePath, "important content");
+ await File.WriteAllTextAsync(sourceFilePath, "source content");
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Delete Something",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = "important.dat",
+ },
+ new InstallationStep
+ {
+ Name = "Rename Something",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = "source.dat",
+ DestinationRelativePath = "dest.dat",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: "untrusted_source");
+
+ Assert.False(result.Success);
+ Assert.Contains("not authorized to execute installation steps", result.FirstError);
+ Assert.True(File.Exists(importantFilePath));
+ Assert.True(File.Exists(sourceFilePath));
+ Assert.False(File.Exists(destFilePath));
+ }
+
+ ///
+ /// Verifies that paths attempting directory traversal are rejected.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_PathTraversalTarget_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Traverse Path",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = @"../../outside.exe",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that installer executables not declared in the manifest files list fail.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_FileNotInManifest_FailsExecution()
+ {
+ var targetFile = "installer.exe";
+ var fullPath = Path.Combine(_tempDirectory, targetFile);
+ File.WriteAllText(fullPath, "binary content");
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files = []; // Empty files list - installer not declared
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Run Undeclared Installer",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = targetFile,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("not declared in manifest files", result.FirstError);
+ }
+
+ ///
+ /// Verifies that hash mismatch during installer integrity check fails execution.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_HashMismatch_FailsExecution()
+ {
+ var targetFile = "installer.exe";
+ var fullPath = Path.Combine(_tempDirectory, targetFile);
+ File.WriteAllText(fullPath, "binary content");
+
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync("actual_hash_value");
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = targetFile,
+ Hash = "expected_different_hash",
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Run Corrupted Installer",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = targetFile,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("Integrity verification failed", result.FirstError);
+ }
+
+ ///
+ /// Verifies that remove file steps successfully delete the target file.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RemoveFile_DeletesTargetFile()
+ {
+ var fileToRemove = "temp_cache.tmp";
+ var fullPath = Path.Combine(_tempDirectory, fileToRemove);
+ File.WriteAllText(fullPath, "temporary content");
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Remove Cache",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = fileToRemove,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(fullPath));
+ }
+
+ ///
+ /// Verifies that rename file steps successfully move target files.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RenameFile_MovesTargetFile()
+ {
+ var sourceFile = "source.txt";
+ var destFile = Path.Combine("subfolder", "dest.txt");
+ var sourceFullPath = Path.Combine(_tempDirectory, sourceFile);
+ var destFullPath = Path.Combine(_tempDirectory, destFile);
+
+ File.WriteAllText(sourceFullPath, "hello world");
+
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Rename File",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = sourceFile,
+ DestinationRelativePath = destFile,
+ StepKey = "test_rename_step",
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.False(File.Exists(sourceFullPath));
+ Assert.True(File.Exists(destFullPath));
+ Assert.Equal("hello world", File.ReadAllText(destFullPath));
+ Assert.True(_userSettings.IsInstallationStepExecuted("test_rename_step"));
+ }
+
+ ///
+ /// Verifies that verified installer execution runs and dispatches user notifications.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunsInstallerAndDispatchesNotification()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "test_installer.exe" : "test_installer.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ File.Copy(systemCmd, fullPath, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "test_installer_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [],
+ StatusMessage = GeneralsOnlineConstants.EacStatusMessage,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.True(_userSettings.IsInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey));
+ _notificationServiceMock.Verify(
+ n => n.ShowInfo(
+ GeneralsOnlineConstants.EacStepName,
+ GeneralsOnlineConstants.EacStatusMessage,
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ _notificationServiceMock.Verify(
+ n => n.ShowSuccess(
+ "Installation Step Completed",
+ It.Is(msg => msg.Contains(GeneralsOnlineConstants.EacStepName)),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that run-once steps already recorded in user settings are skipped.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunOnceStepAlreadyExecuted_SkipsExecution()
+ {
+ var scriptName = "installer.bat";
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile { RelativePath = scriptName },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ // Mark as already executed
+ _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey);
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+
+ // Notification should NOT be shown for skipped step
+ _notificationServiceMock.Verify(
+ n => n.ShowInfo(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that forcing execution re-runs run-once steps even if recorded in settings.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RunOnceStepWithForceTrue_ExecutesEvenIfRecorded()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "test_force_installer.exe" : "test_force_installer.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ File.Copy(systemCmd, fullPath, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "test_force_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Publisher = new PublisherInfo
+ {
+ Name = GeneralsOnlineConstants.PublisherName,
+ PublisherType = PublisherTypeConstants.GeneralsOnline,
+ };
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = GeneralsOnlineConstants.EacStepName,
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ Arguments = OperatingSystem.IsWindows() ? ["/c", "exit", "0"] : [],
+ StatusMessage = GeneralsOnlineConstants.EacStatusMessage,
+ StepKey = GeneralsOnlineConstants.EacStepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ // Mark as already executed in settings
+ _userSettings.RecordInstallationStepExecuted(GeneralsOnlineConstants.EacStepKey);
+
+ // Force execution
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline, force: true);
+
+ Assert.True(result.Success);
+ _notificationServiceMock.Verify(
+ n => n.ShowInfo(
+ GeneralsOnlineConstants.EacStepName,
+ GeneralsOnlineConstants.EacStatusMessage,
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that unknown installation step kinds return failure.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_UnknownKind_ReturnsFailure()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Unknown Step",
+ Kind = InstallationStepKind.Unknown,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("Unsupported installation step kind", result.FirstError);
+ }
+
+ ///
+ /// Verifies that elevated steps fail with an unsupported result on non-Windows platforms.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_ElevationOnNonWindows_ReturnsFailure()
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var scriptName = "elevated_script.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 0\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+
+ const string expectedHash = "elevated_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Elevated Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ RequiresElevation = true,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("requires administrator elevation, which is only supported on Windows", result.FirstError);
+ }
+
+ ///
+ /// Verifies that remove file steps reject paths that escape the working directory.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RemoveFile_PathTraversalTarget_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Remove Escape",
+ Kind = InstallationStepKind.RemoveFile,
+ TargetRelativePath = "../../outside.tmp",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that rename file steps reject source paths that escape the working directory.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RenameFile_SourcePathTraversal_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Rename Source Escape",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = "../../outside.tmp",
+ DestinationRelativePath = "dest.tmp",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that rename file steps reject destination paths that escape the working directory.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_RenameFile_DestinationPathTraversal_FailsExecution()
+ {
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Rename Destination Escape",
+ Kind = InstallationStepKind.RenameFile,
+ TargetRelativePath = "source.tmp",
+ DestinationRelativePath = "../../outside.tmp",
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(manifest, _tempDirectory, providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("escapes the working directory", result.FirstError);
+ }
+
+ ///
+ /// Verifies that cancellation token terminates the running process and throws OperationCanceledException.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_CallerCancellation_TerminatesProcessAndThrows()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "sleep_installer.exe" : "sleep_installer.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ var systemCmd = Path.Combine(Environment.SystemDirectory, "cmd.exe");
+ File.Copy(systemCmd, fullPath, overwrite: true);
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nsleep 30\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "sleep_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Long Running Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ Arguments = OperatingSystem.IsWindows() ? ["/c", "ping", "-n", "30", "127.0.0.1"] : [],
+ },
+ ],
+ };
+
+ using var cts = new CancellationTokenSource();
+ cts.CancelAfter(TimeSpan.FromMilliseconds(200));
+
+ await Assert.ThrowsAnyAsync(() =>
+ _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline,
+ cancellationToken: cts.Token));
+ }
+
+ ///
+ /// Verifies that when a precondition is fulfilled, execution is skipped and the step key is recorded.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_PreconditionFulfilled_SkipsExecutionAndRecordsStepKey()
+ {
+ var preconditionMock = new Mock();
+ preconditionMock.Setup(p => p.CanHandle(It.IsAny(), It.IsAny())).Returns(true);
+ preconditionMock.Setup(p => p.IsAlreadyFulfilled(It.IsAny(), It.IsAny())).Returns(true);
+
+ var serviceWithPrecondition = new InstallationInstructionsService(
+ _hashProviderMock.Object,
+ _notificationServiceMock.Object,
+ _userSettingsServiceMock.Object,
+ [preconditionMock.Object],
+ NullLogger.Instance);
+
+ const string stepKey = "test:precondition:step";
+ var manifest = CreateBaseManifest();
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Preconditioned Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = "nonexistent.exe",
+ StepKey = stepKey,
+ RunOnce = true,
+ },
+ ],
+ };
+
+ var result = await serviceWithPrecondition.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.True(result.Success);
+ Assert.True(_userSettings.IsInstallationStepExecuted(stepKey));
+ }
+
+ ///
+ /// Verifies that verification fails when a step target file has no declared hash in the manifest.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_NoDeclaredHash_FailsVerification()
+ {
+ var scriptName = "installer_nohash.exe";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+ File.WriteAllText(fullPath, "binary content");
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = string.Empty,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "No Hash Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("has no declared hash", result.FirstError);
+ }
+
+ ///
+ /// Verifies that an installer process exiting with a non-zero exit code produces an execution failure.
+ ///
+ /// A task representing the asynchronous unit test.
+ [Fact]
+ public async Task ExecutePostInstallStepsAsync_NonZeroExitCode_FailsExecution()
+ {
+ var scriptName = OperatingSystem.IsWindows() ? "exit_error.cmd" : "exit_error.sh";
+ var fullPath = Path.Combine(_tempDirectory, scriptName);
+
+ if (OperatingSystem.IsWindows())
+ {
+ File.WriteAllText(fullPath, "exit /b 42\r\n");
+ }
+ else
+ {
+ File.WriteAllText(fullPath, "#!/bin/sh\nexit 42\n");
+ File.SetUnixFileMode(fullPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+ }
+
+ const string expectedHash = "exit_error_hash";
+ _hashProviderMock
+ .Setup(h => h.ComputeFileHashAsync(fullPath, It.IsAny()))
+ .ReturnsAsync(expectedHash);
+
+ var manifest = CreateBaseManifest();
+ manifest.Files =
+ [
+ new ManifestFile
+ {
+ RelativePath = scriptName,
+ Hash = expectedHash,
+ },
+ ];
+ manifest.InstallationInstructions = new InstallationInstructions
+ {
+ PostInstallSteps =
+ [
+ new InstallationStep
+ {
+ Name = "Failing Step",
+ Kind = InstallationStepKind.RunVerifiedInstaller,
+ TargetRelativePath = scriptName,
+ },
+ ],
+ };
+
+ var result = await _service.ExecutePostInstallStepsAsync(
+ manifest,
+ _tempDirectory,
+ providerSource: PublisherTypeConstants.GeneralsOnline);
+
+ Assert.False(result.Success);
+ Assert.Contains("failed with exit code", result.FirstError);
+ }
+
+ ///