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/scripts/package-macos-app.sh b/.github/scripts/package-macos-app.sh index d055885cd..ef25ff50d 100755 --- a/.github/scripts/package-macos-app.sh +++ b/.github/scripts/package-macos-app.sh @@ -30,8 +30,8 @@ APP_NAME="GenHub" EXECUTABLE_NAME="GenHub.MacOS" BUNDLE_ID="org.communityoutpost.genhub" -[ -d "$PUBLISH_DIR" ] || { echo "error: publish dir not found: $PUBLISH_DIR" >&2; exit 1; } -[ -f "$PUBLISH_DIR/$EXECUTABLE_NAME" ] || { +[[ -d "$PUBLISH_DIR" ]] || { echo "error: publish dir not found: $PUBLISH_DIR" >&2; exit 1; } +[[ -f "$PUBLISH_DIR/$EXECUTABLE_NAME" ]] || { echo "error: $EXECUTABLE_NAME not found in $PUBLISH_DIR" >&2 echo "hint: publish GenHub.MacOS with -r osx-arm64 --self-contained true first" >&2 exit 1 @@ -90,7 +90,7 @@ PLIST # An .icns is optional; without one macOS shows a generic application icon. Generate it # from the existing PNG when the source and tooling are both available. ICON_PNG="GenHub/GenHub/Assets/Icons/generalshub-icon.png" -if [ -f "$ICON_PNG" ] && command -v iconutil >/dev/null 2>&1 && command -v sips >/dev/null 2>&1; then +if [[ -f "$ICON_PNG" ]] && command -v iconutil >/dev/null 2>&1 && command -v sips >/dev/null 2>&1; then ICON_TEMP_DIR="$(mktemp -d)" ICONSET="$ICON_TEMP_DIR/AppIcon.iconset" ICON_GENERATION_FAILED=0 @@ -99,18 +99,18 @@ if [ -f "$ICON_PNG" ] && command -v iconutil >/dev/null 2>&1 && command -v sips ICON_1X="$ICONSET/icon_${size}x${size}.png" ICON_2X="$ICONSET/icon_${size}x${size}@2x.png" if ! sips -z "$size" "$size" "$ICON_PNG" --out "$ICON_1X" >/dev/null 2>&1 \ - || [ ! -s "$ICON_1X" ]; then + || [[ ! -s "$ICON_1X" ]]; then echo " warning: failed to generate ${size}x${size} icon" ICON_GENERATION_FAILED=1 fi if ! sips -z $((size * 2)) $((size * 2)) "$ICON_PNG" --out "$ICON_2X" >/dev/null 2>&1 \ - || [ ! -s "$ICON_2X" ]; then + || [[ ! -s "$ICON_2X" ]]; then echo " warning: failed to generate ${size}x${size}@2x icon" ICON_GENERATION_FAILED=1 fi done - if [ "$ICON_GENERATION_FAILED" -eq 0 ] \ + if [[ "$ICON_GENERATION_FAILED" -eq 0 ]] \ && iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns" 2>/dev/null; then echo " embedded AppIcon.icns" else diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 323290891..53f90cb6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,6 @@ name: GenHub CI -permissions: - contents: read - pull-requests: write +permissions: {} on: # release/** is covered explicitly rather than incidentally. A release branch is only built @@ -49,6 +47,8 @@ jobs: detect-changes: name: Detect File Changes runs-on: ubuntu-latest + permissions: + contents: read timeout-minutes: 5 outputs: core: ${{ steps.filter.outputs.core }} @@ -84,6 +84,8 @@ jobs: - '**/*.axaml' - '**/*.csproj' - '**/*.sln' + - '**/*.props' + - '**/*.targets' - '.github/workflows/**' - name: Changes Summary @@ -100,6 +102,8 @@ jobs: needs: detect-changes if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.ui == 'true' || needs.detect-changes.outputs.windows == 'true' }} runs-on: windows-latest + permissions: + contents: read steps: - name: Checkout Code @@ -122,16 +126,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" } @@ -254,6 +260,8 @@ jobs: needs: detect-changes if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.any == 'true' || needs.detect-changes.outputs.core == 'true' || needs.detect-changes.outputs.ui == 'true' || needs.detect-changes.outputs.linux == 'true' }} runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout Code @@ -264,11 +272,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 +283,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 @@ -381,6 +386,8 @@ jobs: # macos-14 and newer are Apple Silicon. Pinning a version rather than using # macos-latest keeps the runner architecture stable when the label moves. runs-on: macos-15 + permissions: + contents: read timeout-minutes: 30 steps: @@ -405,14 +412,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 +549,102 @@ 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 + permissions: + contents: read + 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 + permissions: {} 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..23d62900e --- /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@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Setup Node.js + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + 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..7237ca5d8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ .* !.github/ !.gitignore +!.agents/ +!.claude/ *.suo *.user @@ -21,6 +23,7 @@ docs/.vitepress/dist/ docs/.vitepress/cache/ # Build results +build.lock [Dd]ebug/ [Rr]elease/ @@ -171,3 +174,13 @@ _NCrunch* # Velopack releases releases/ Releases/ +.gitnexus + +# SampleCatalogs: machine-specific generated shortcuts. Never commit them. +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.reg +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.desktop +GenHub/GenHub/SampleCatalogs/register-genhub-scheme.app/ +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.url +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.desktop +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.command +GenHub/GenHub/SampleCatalogs/Subscribe-Test-Catalog.webloc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..91493a844 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,172 @@ +# 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. +- **UI & Styling:** Adhere strictly to `docs/dev/ui-styling.md` and `docs/dev/window-styling.md`. All views and controls must bind to semantic theme tokens from `ThemeResources.axaml` via `{DynamicResource ...}` and use shared controls from `GenHub.Common.Controls` (such as `SidebarLayout`). Never use hardcoded color hexes or custom sidebars. When working on UI, views, or styling, use relevant UI, UX, and design skills to verify layout, accessibility, and visual consistency. +- **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. +- **Cognitive Complexity:** Keep method cognitive complexity strictly below 15 (SonarCloud S3776). Break up complex orchestration into cohesive, single-responsibility private helper methods. +- **Constants & URIs:** Fallback and default gateway/API endpoints must be centralized in `GenHub.Core.Constants.ApiConstants` with environment variable override support (e.g., `GENHUB_UPLOAD_GATEWAY_URL`). Do not scatter URI literals across features. +- **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`), UI styling guide (`docs/dev/ui-styling.md`), Window styling standard (`docs/dev/window-styling.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.Build.props b/GenHub/Directory.Build.props index d5d411692..73f102695 100644 --- a/GenHub/Directory.Build.props +++ b/GenHub/Directory.Build.props @@ -14,6 +14,7 @@ <_NumericVersion Condition="!$(Version.Contains('-'))">$(Version) $(_NumericVersion) $(_NumericVersion) + true true diff --git a/GenHub/Directory.Packages.props b/GenHub/Directory.Packages.props index f76660ae2..8c7e0e70c 100644 --- a/GenHub/Directory.Packages.props +++ b/GenHub/Directory.Packages.props @@ -6,7 +6,7 @@ - + @@ -34,6 +34,8 @@ + + @@ -47,7 +49,7 @@ - + diff --git a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs index 7cf4bd7af..56d978056 100644 --- a/GenHub/GenHub.Core/Constants/AODMapsConstants.cs +++ b/GenHub/GenHub.Core/Constants/AODMapsConstants.cs @@ -1,8 +1,12 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants for AODMaps (Age of Defense Maps) provider. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] +[SuppressMessage("Minor Code Smell", "S101:Types should be named in PascalCase", Justification = "Domain acronym")] public static class AODMapsConstants { /// Gets the publisher type identifier for AODMaps. diff --git a/GenHub/GenHub.Core/Constants/ApiConstants.cs b/GenHub/GenHub.Core/Constants/ApiConstants.cs index 7ab83ccb9..16ec2f9df 100644 --- a/GenHub/GenHub.Core/Constants/ApiConstants.cs +++ b/GenHub/GenHub.Core/Constants/ApiConstants.cs @@ -1,8 +1,12 @@ +using System; +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// API and network related constants. /// +[SuppressMessage("Major Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized fallback API constants and endpoint definitions.")] public static class ApiConstants { // GitHub @@ -59,13 +63,76 @@ public static class ApiConstants /// public const string GitHubApiRunArtifactsFormat = "https://api.github.com/repos/{0}/{1}/actions/runs/{2}/artifacts"; - // UploadThing links + // Upload Gateway & Cloud Storage + + /// + /// Environment variable name for overriding the upload gateway base URL during local development/staging. + /// + public const string UploadGatewayBaseUrlEnvVar = "GENHUB_UPLOAD_GATEWAY_URL"; + + /// + /// Base URL for the GenHub community upload gateway. + /// + public const string DefaultUploadGatewayBaseUrl = "https://genhub-upload-gateway.mustafa2146.workers.dev"; + + /// + /// Gets the active base URL for the upload gateway, checking environment variable overrides first. + /// + public static string UploadGatewayBaseUrl => + Environment.GetEnvironmentVariable(UploadGatewayBaseUrlEnvVar) is { Length: > 0 } customUrl + ? customUrl.TrimEnd('/') + : DefaultUploadGatewayBaseUrl; + + /// + /// Endpoint path for cloud uploads. + /// + public const string UploadEndpoint = "/api/v1/uploads"; + + /// + /// Endpoint path for deleting cloud uploads. + /// + public const string UploadDeleteEndpoint = "/api/v1/uploads/delete"; + + /// + /// Gets the full default URL for cloud uploads. + /// + public static string DefaultUploadUrl => UploadGatewayBaseUrl + UploadEndpoint; + + /// + /// Gets the full default URL for deleting cloud uploads. + /// + public static string DefaultUploadDeleteUrl => UploadGatewayBaseUrl + UploadDeleteEndpoint; + + /// + /// Format string for constructing UploadThing public file URLs. + /// + public const string UploadThingPublicUrlFormat = "https://utfs.io/f/{0}"; /// /// UploadThing URL fragment for identification. /// public const string UploadThingUrlFragment = "utfs.io/f/"; + /// + /// Modern UploadThing (v7) UFS URL fragment for identification. + /// + public const string UploadThingUfsUrlFragment = ".ufs.sh/f/"; + + /// + /// Modern UploadThing (v7) UFS short URL fragment for identification. + /// + public const string UploadThingUfsShortUrlFragment = "ufs.sh/f/"; + + /// + /// Media type for ZIP archives. + /// + public const string MediaTypeZip = "application/zip"; + + /// + /// Default filename fallback for generic uploads when a source filename cannot be determined. + /// + public const string DefaultUploadFileName = "upload.zip"; + // GenTool /// @@ -80,6 +147,18 @@ public static class ApiConstants /// public const string GeneralsOnlineViewMatchFragment = "playgenerals.online/viewmatch"; + // GameReplays / Strata + + /// + /// GameReplays Strata domain URL fragment for identification. + /// + public const string StrataUrlFragment = "strata.gamereplays.org"; + + /// + /// GameReplays domain URL fragment for identification. + /// + public const string GameReplaysDomainFragment = "gamereplays.org"; + /// /// Format string for GitHub API Workflow Runs endpoint (owner, repo). /// 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..6b5bd656f 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. /// @@ -65,6 +80,11 @@ public static class AppUpdateConstants /// public const string InstallingMessage = "Installing..."; + /// + /// Loading message. + /// + public const string LoadingMessage = "Loading..."; + /// /// Install update action text. /// @@ -153,6 +173,211 @@ 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}."; + + /// + /// Default development branch name for CI artifact fallback. + /// + public const string DevelopmentBranch = "development"; + + /// + /// Default main branch name for release updates. + /// + public const string MainBranch = "main"; + + /// + /// Update available notification title when a subscribed PR has been merged or closed. + /// + public const string PrMergedUpdateAvailableNotificationTitle = "PR Merged — Update Available"; + + /// + /// Update available notification title when a subscribed branch is stale or has no artifacts. + /// + public const string BranchStaleUpdateAvailableNotificationTitle = "Branch Fallback: Update Available"; + + /// + /// PR merged or closed fallback notification format string ({0}: version, {1}: PR number). + /// + public const string PrMergedUpdateNotificationFormat = "PR #{1} was merged or closed. A new build ({0}) is available on development."; + + /// + /// PR merged or closed release fallback notification format string ({0}: version, {1}: PR number). + /// + public const string PrMergedReleaseNotificationFormat = "PR #{1} was merged or closed. A new release ({0}) is available."; + + /// + /// Branch stale fallback notification format string ({0}: version, {1}: branch name). + /// + public const string BranchStaleUpdateNotificationFormat = "Branch '{1}' has no newer builds. A new build ({0}) is available on development."; + + /// + /// Branch stale release fallback notification format string ({0}: version, {1}: branch name). + /// + public const string BranchStaleReleaseNotificationFormat = "Branch '{1}' has no newer builds. A new release ({0}) is available."; + + /// + /// PR merged or closed status message format ({0}: PR number). + /// + public const string PrMergedStatusMessageFormat = "PR #{0} has been merged or closed. Select a new PR or switch to MAIN."; + + /// + /// Branch stale status message format ({0}: branch name). + /// + public const string BranchStaleStatusMessageFormat = "Branch '{0}' has no available builds. Select a new branch or switch to MAIN."; + + /// + /// Message displayed when checking branch/PR artifacts without a configured GitHub PAT. + /// + public const string PatRequiredForArtifactsMessage = "GitHub Personal Access Token (PAT) required to check branch/PR builds."; + + /// + /// Identity prefix for PR update notification deduplication. + /// + public const string PrDedupePrefix = "pr:"; + + /// + /// Identity prefix for PR fallback update notification deduplication. + /// + public const string PrFallbackDedupePrefix = "pr-fallback:"; + + /// + /// Identity prefix for branch update notification deduplication. + /// + public const string BranchDedupePrefix = "branch:"; + + /// + /// Identity prefix for branch fallback update notification deduplication. + /// + public const string BranchFallbackDedupePrefix = "branch-fallback:"; + + /// + /// Identity prefix for release update notification deduplication. + /// + public const string ReleaseDedupePrefix = "release:"; + + /// + /// Identity prefix for GitHub API fallback update notification deduplication. + /// + public const string GitHubFallbackDedupePrefix = "github:"; + + /// + /// Log format string when skipping duplicate update notifications. + /// + public const string NotificationAlreadyShownLogFormat = "Update notification already shown for {Identity}, skipping duplicate notification"; + + /// + /// 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/CommunityOutpostCatalogConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs index 0ace072b1..260c6d2cc 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostCatalogConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants for the Community Outpost catalog parsing and metadata keys. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class CommunityOutpostCatalogConstants { /// The catalog format identifier for GenPatcher .dat files. diff --git a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs index 305d9555d..93780ed46 100644 --- a/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs +++ b/GenHub/GenHub.Core/Constants/CommunityOutpostConstants.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// @@ -8,6 +10,7 @@ namespace GenHub.Core.Constants; /// Endpoint URLs and timeouts are configured via data-driven configuration. /// See Providers/communityoutpost.provider.json for runtime-configurable values. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class CommunityOutpostConstants { /// @@ -85,6 +88,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/CsvConstants.cs b/GenHub/GenHub.Core/Constants/CsvConstants.cs new file mode 100644 index 000000000..65f6407c0 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/CsvConstants.cs @@ -0,0 +1,130 @@ +using System.Diagnostics.CodeAnalysis; + +namespace GenHub.Core.Constants; + +/// +/// Constants specific to CSV catalog discovery and content pipeline. +/// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants for remote catalog discovery")] +public static class CsvConstants +{ + /// + /// Default remote index.json source for CSV catalog discovery. + /// + public const string DefaultIndexFileUrl = "https://raw.githubusercontent.com/community-outpost/GenHub/main/docs/GameInstallationFilesRegistry/index.json"; + + /// + /// Source name for the CSV catalog discoverer. + /// + public const string SourceName = "Csv Discoverer"; + + /// + /// Description for the CSV catalog discoverer. + /// + public const string Description = "Discovers base game manifests from verified CSV catalogs."; + + /// + /// Source name for the CSV catalog content provider. + /// + public const string ProviderSourceName = "CSV Catalog Provider"; + + /// + /// Description for the CSV catalog content provider. + /// + public const string ProviderDescription = "Provides base game manifests from verified CSV catalogs."; + + /// + /// Resolver ID for CSV catalog content. + /// + public const string ResolverId = "CSVResolver"; + + /// + /// Metadata key for the CSV source URL. + /// + public const string CsvUrlMetadataKey = "csvUrl"; + + /// + /// Metadata key for the game type. + /// + public const string GameTypeMetadataKey = "gameType"; + + /// + /// Metadata key for the game version. + /// + public const string VersionMetadataKey = "version"; + + /// + /// Metadata key for the content language. + /// + public const string LanguageMetadataKey = "language"; + + /// + /// Metadata key for the expected file count. + /// + public const string FileCountMetadataKey = "fileCount"; + + /// + /// String representation for Generals game type in CSV catalogs. + /// + public const string GeneralsGameType = "Generals"; + + /// + /// String representation for Zero Hour game type in CSV catalogs. + /// + public const string ZeroHourGameType = "ZeroHour"; + + /// + /// Special language filter value to include all languages. + /// + public const string AllLanguagesFilter = "All"; + + /// + /// Canonical language code for English. + /// + public const string LanguageEn = "EN"; + + /// + /// Canonical language code for German. + /// + public const string LanguageDe = "DE"; + + /// + /// Canonical language code for French. + /// + public const string LanguageFr = "FR"; + + /// + /// Canonical language code for Polish. + /// + public const string LanguagePl = "PL"; + + /// + /// Canonical language code for Spanish. + /// + public const string LanguageEs = "ES"; + + /// + /// Canonical language code for Italian. + /// + public const string LanguageIt = "IT"; + + /// + /// Canonical language code for Korean. + /// + public const string LanguageKo = "KO"; + + /// + /// Canonical language code for Brazilian Portuguese. + /// + public const string LanguagePtBr = "PT-BR"; + + /// + /// Canonical language code for Simplified Chinese. + /// + public const string LanguageZhCn = "ZH-CN"; + + /// + /// Canonical language code for Traditional Chinese. + /// + public const string LanguageZhTw = "ZH-TW"; +} 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 0e1249755..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. /// @@ -84,4 +100,17 @@ public static class FileTypes /// File extension for user data manifest files. /// public const string UserDataManifestExtension = ".userdata.json"; -} \ No newline at end of file + + /// + /// Filename used to store the source directory path mapping for a manifest's content. + /// This file is written inside the manifest's data directory and contains the path + /// to the original source directory (e.g., local installation folder). + /// + public const string SourcePathFileName = "source.path"; + + /// + /// Sentinel value written to when content is stored + /// via CAS and has no source directory. + /// + public const string CasOnlySourceMarker = "CAS-ONLY"; +} diff --git a/GenHub/GenHub.Core/Constants/GameClientConstants.cs b/GenHub/GenHub.Core/Constants/GameClientConstants.cs index 4260feb43..6937d2e2c 100644 --- a/GenHub/GenHub.Core/Constants/GameClientConstants.cs +++ b/GenHub/GenHub.Core/Constants/GameClientConstants.cs @@ -21,6 +21,9 @@ public static class GameClientConstants /// Steam game.dat executable (primary for Steam installations, avoids launcher stubs). public const string SteamGameDatExecutable = "game.dat"; + /// Contra modded client executable filename. + public const string ContraExecutable = "generals.ctr"; + // ===== SuperHackers Client Detection ===== /// SuperHackers Generals executable filename. @@ -61,6 +64,20 @@ public static class GameClientConstants /// Standard retail Zero Hour directory name. public const string ZeroHourRetailDirectoryName = "Command & Conquer Generals Zero Hour"; + // ===== Core Game Archives ===== + + /// Primary Zero Hour INI archive filename. + public const string ZeroHourIniBig = "INIZH.big"; + + /// Primary Zero Hour Patch archive filename. + public const string ZeroHourPatchBig = "PatchZH.big"; + + /// Primary Generals Vanilla INI archive filename. + public const string GeneralsIniBig = "INI.big"; + + /// Primary Generals Vanilla Patch archive filename. + public const string GeneralsPatchBig = "Patch.big"; + // ===== GeneralsOnline Client Detection ===== /// GeneralsOnline 60Hz client executable name. @@ -69,6 +86,15 @@ public static class GameClientConstants /// GeneralsOnline default client executable name. public const string GeneralsOnlineDefaultExecutable = "generalsonlinezh.exe"; + /// + /// Easy Anti-Cheat bootstrapper shipped since GeneralsOnline 060526_QFE1. It launches the + /// binary named by EasyAntiCheat/Settings.json and is the supported launch target. + /// + public const string GeneralsOnlineEacLauncherExecutable = "EAC_LaunchGeneralsOnline.exe"; + + /// Epic Online Services Easy Anti-Cheat installer shipped in the GeneralsOnline portable. + public const string GeneralsOnlineEacSetupExecutable = "EasyAntiCheat_EOS_Setup.exe"; + /// Display name for GeneralsOnline 60Hz variant. public const string GeneralsOnline60HzDisplayName = "GeneralsOnline 60Hz"; @@ -180,12 +206,19 @@ public static class GameClientConstants ]; /// - /// List of GeneralsOnline executable names to detect. - /// Only includes 30Hz and 60Hz variants as these are the primary clients. - /// GeneralsOnline provides auto-updated clients for Command & Conquer Generals and Zero Hour. + /// The GeneralsOnline executable names that are supported launch entry points. + /// Since 060526_QFE1 the Easy Anti-Cheat bootstrapper starts the binary named by + /// EasyAntiCheat/Settings.json; older packages launch the 60Hz binary directly. + /// GeneralsOnlineZH.exe ships alongside both but is not wrapped, so it is workspace + /// content rather than an entry point. /// + /// + /// Membership only. When both are present the bootstrapper wins, but that precedence is + /// expressed in the resolving code rather than by the order of this list. + /// public static readonly IReadOnlyList GeneralsOnlineExecutableNames = [ + GeneralsOnlineEacLauncherExecutable, GeneralsOnline60HzExecutable, ]; 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..0efca04d8 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. @@ -86,4 +92,24 @@ public static class GameSettingsTheSuperHackersConstants /// Absent from the engine's ScreenEdgeScrollMode_Default. /// public const bool DefaultScreenEdgeScrollEnabledInWindowedApp = false; + + /// + /// Configuration key for game window transition speed multiplier. + /// + public const string GameWindowTransitionSpeedMultiplierKey = "GameWindowTransitionSpeedMultiplier"; + + /// + /// Minimum game window transition speed multiplier value. + /// + public const float MinGameWindowTransitionSpeedMultiplier = 1.0f; + + /// + /// Maximum game window transition speed multiplier value. + /// + public const float MaxGameWindowTransitionSpeedMultiplier = 4.0f; + + /// + /// Default game window transition speed multiplier value. + /// + public const float DefaultGameWindowTransitionSpeedMultiplier = 1.0f; } diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 242cb02ef..20ef97da7 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants specific to Generals Online content provider and multiplayer service. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class GeneralsOnlineConstants { // ===== Content Metadata ===== @@ -87,6 +90,9 @@ public static class GeneralsOnlineConstants /// Manifest name suffix for QuickMatch MapPack. public const string QuickMatchMapPackSuffix = "quickmatch-maps"; + /// Manifest name suffix for GeneralsOnlineGameData data patch. + public const string GameDataPatchSuffix = "gamedata"; + /// The default tick rate variant suffix. public const string DefaultVariantSuffix = Variant60HzSuffix; @@ -96,9 +102,18 @@ public static class GeneralsOnlineConstants /// Description for QuickMatch MapPack. public const string QuickMatchMapPackDescription = "Official map pack required for GeneralsOnline QuickMatch multiplayer. Contains competitively balanced maps."; + /// Display name for GeneralsOnlineGameData data patch. + public const string GameDataDisplayName = "GeneralsOnline Game Data"; + + /// Description for GeneralsOnlineGameData data patch. + public const string GameDataDescription = "Game data patch for GeneralsOnline containing community balance and core INI configuration."; + /// Subdirectory within the portable ZIP containing maps. public const string MapsSubdirectory = "Maps"; + /// Subdirectory within the portable ZIP containing GeneralsOnline game data. + public const string GameDataSubdirectory = "GeneralsOnlineGameData"; + // ===== Component Identifiers ===== /// Source name for Generals Online discoverer. @@ -113,6 +128,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. @@ -122,4 +154,9 @@ public static class GeneralsOnlineConstants /// Default tags for MapPack manifests. /// public static readonly string[] MapPackTags = ["mappack", "generalsonline", "quickmatch", "competitive"]; + + /// + /// Default tags for GameData patch manifests. + /// + public static readonly string[] GameDataTags = ["patch", "generalsonline"]; } diff --git a/GenHub/GenHub.Core/Constants/GitHubConstants.cs b/GenHub/GenHub.Core/Constants/GitHubConstants.cs index af738db30..a5625bbc3 100644 --- a/GenHub/GenHub.Core/Constants/GitHubConstants.cs +++ b/GenHub/GenHub.Core/Constants/GitHubConstants.cs @@ -14,6 +14,12 @@ public static class GitHubConstants /// Default rate limit reset period in hours. public const int DefaultRateLimitResetHours = 1; + /// Environment variable name for standard GitHub token. + public const string GitHubTokenEnvVar = "GITHUB_TOKEN"; + + /// Environment variable name for GenHub-specific GitHub token. + public const string GenHubTokenEnvVar = "GENHUB_GITHUB_TOKEN"; + // Build parsing constants /// String identifier for Zero Hour game variant. @@ -333,6 +339,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/InfoConstants.cs b/GenHub/GenHub.Core/Constants/InfoConstants.cs index e413a901c..de315df05 100644 --- a/GenHub/GenHub.Core/Constants/InfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/InfoConstants.cs @@ -1,10 +1,12 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace GenHub.Core.Constants; /// /// Constants for the Info and FAQ features. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class InfoConstants { /// @@ -17,6 +19,31 @@ public static class InfoConstants /// public const string FaqDefaultLanguage = "en"; + /// + /// Module name for GenHub Guide. + /// + public const string ModuleGuide = "GenHub Guide"; + + /// + /// Module name for Zero Hour. + /// + public const string ModuleZeroHour = "Zero Hour"; + + /// + /// Module name for GeneralsOnline. + /// + public const string ModuleGeneralsOnline = "GeneralsOnline"; + + /// + /// Section ID for FAQ. + /// + public const string SectionFaq = "faq"; + + /// + /// Section ID for GeneralsOnline changelog. + /// + public const string SectionGoChangelog = "go-changelog"; + /// /// The list of supported languages for the FAQ. /// 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/MapManagerConstants.cs b/GenHub/GenHub.Core/Constants/MapManagerConstants.cs index b0521d02d..2930c8f3d 100644 --- a/GenHub/GenHub.Core/Constants/MapManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/MapManagerConstants.cs @@ -10,6 +10,26 @@ public static class MapManagerConstants /// public const long MaxMapSizeBytes = 10 * 1024 * 1024; + /// + /// Maximum allowed entries in a map ZIP archive. + /// + public const int MaxZipEntries = 500; + + /// + /// Maximum aggregate uncompressed bytes for a map ZIP archive (200 MB). + /// + public const long MaxAggregateUncompressedBytes = 200 * 1024 * 1024; + + /// + /// Maximum file size for individual map assets in bytes (10 MB). + /// + public const long MaxAssetSizeBytes = 10 * 1024 * 1024; + + /// + /// Maximum compression ratio allowed for ZIP archives. + /// + public const double MaxCompressionRatio = 100.0; + /// /// Number of days for rate limit reset period. /// @@ -90,6 +110,31 @@ public static class MapManagerConstants /// public const string ToolDescription = "Manage, import, and share custom maps. Create MapPacks for easy profile switching."; + /// + /// Prefix for temporary share archives created for uploads. + /// + public const string TempShareFilePrefix = "genhub_maps_"; + + /// + /// Mock path separator indicator for demo environments on Windows. + /// + public const string WindowsMockPathSegment = ToolConstants.WindowsMockPathSegment; + + /// + /// Mock path separator indicator for demo environments on Unix. + /// + public const string UnixMockPathSegment = ToolConstants.UnixMockPathSegment; + + /// + /// Notification title for delete failure. + /// + public const string DeleteFailedTitle = ToolConstants.DeleteFailedTitle; + + /// + /// Category identifier for map uploads. + /// + public const string UploadCategory = "maps"; + /// /// Allowed file extensions for map packages. /// diff --git a/GenHub/GenHub.Core/Constants/ModDBConstants.cs b/GenHub/GenHub.Core/Constants/ModDBConstants.cs index 2deed935c..30096acb0 100644 --- a/GenHub/GenHub.Core/Constants/ModDBConstants.cs +++ b/GenHub/GenHub.Core/Constants/ModDBConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants specific to ModDB and its content pipeline components. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class ModDBConstants { // ===== Base URLs ===== diff --git a/GenHub/GenHub.Core/Constants/PlatformConstants.cs b/GenHub/GenHub.Core/Constants/PlatformConstants.cs index e81581558..f836fea66 100644 --- a/GenHub/GenHub.Core/Constants/PlatformConstants.cs +++ b/GenHub/GenHub.Core/Constants/PlatformConstants.cs @@ -1,3 +1,6 @@ +using System; +using System.IO; + namespace GenHub.Core.Constants; /// @@ -14,4 +17,28 @@ public static class PlatformConstants /// Windows Explorer select argument. /// public const string WindowsExplorerSelectArgument = "/select,\"{0}\""; + + /// + /// macOS open command executable name. + /// + public const string MacOSOpenExecutable = "open"; + + /// + /// Linux xdg-open command executable name. + /// + public const string LinuxXdgOpenExecutable = "xdg-open"; + + /// + /// Gets the absolute path to the Windows Explorer executable. + /// + public static string WindowsExplorerPath + { + get + { + var windowsDir = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + return string.IsNullOrEmpty(windowsDir) + ? WindowsExplorerExecutable + : Path.Combine(windowsDir, WindowsExplorerExecutable); + } + } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ProcessConstants.cs b/GenHub/GenHub.Core/Constants/ProcessConstants.cs index 1a438e8d5..d4d7187ff 100644 --- a/GenHub/GenHub.Core/Constants/ProcessConstants.cs +++ b/GenHub/GenHub.Core/Constants/ProcessConstants.cs @@ -82,4 +82,37 @@ public static class ProcessConstants /// Threshold in seconds to consider a process exit as "early" or "immediate". /// 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. 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; + + /// + /// Interval in milliseconds between polls for a launcher's expected child process. + /// + public const int SpawnedChildPollIntervalMs = 100; + + /// + /// How long to wait for an abandoned launcher to exit after it is killed. The launcher is + /// already being torn down on a cancelled launch, so this only bounds the cleanup. + /// + public const int AbandonedLauncherKillWaitMs = 2_000; + + /// + /// How long to keep polling for the expected child after the launcher itself exits cleanly. + /// Covers the race between the child being spawned and becoming enumerable, without waiting + /// out once the launcher is known to be gone. + /// + public const int LauncherExitGracePeriodMs = 1_000; } \ No newline at end of file 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/PublisherInfoConstants.cs b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs index db801fead..43b03d702 100644 --- a/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs +++ b/GenHub/GenHub.Core/Constants/PublisherInfoConstants.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using GenHub.Core.Models.Enums; namespace GenHub.Core.Constants; @@ -6,6 +7,7 @@ namespace GenHub.Core.Constants; /// Constants for publisher information including display names, websites, and support URLs. /// These constants provide standardized publisher metadata for content attribution and user interface display. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class PublisherInfoConstants { /// diff --git a/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs b/GenHub/GenHub.Core/Constants/PublisherTypeConstants.cs index 27f2cd99a..160115a2b 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; @@ -56,9 +58,25 @@ public static class PublisherTypeConstants /// Community Outpost platform. public const string CommunityOutpost = "communityoutpost"; + /// CSV registry publisher. + public const string CsvRegistry = "csvregistry"; + /// 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/RegexConstants.cs b/GenHub/GenHub.Core/Constants/RegexConstants.cs index 311312cd2..6b9a17e4e 100644 --- a/GenHub/GenHub.Core/Constants/RegexConstants.cs +++ b/GenHub/GenHub.Core/Constants/RegexConstants.cs @@ -14,4 +14,9 @@ public static class RegexConstants /// Regex pattern for GenTool replay links. /// public const string GenToolReplayPattern = @"href=""([^\""]+\.rep)"""; + + /// + /// Regex pattern for Strata / GameReplays replay links. + /// + public const string StrataReplayPattern = @"(?:href=[""'](?[^""']+\.(?:rep|zip))[""']|(?https?://[^""'\s<>]+\.(?:rep|zip)))"; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs index 634cbeec7..a605bbe36 100644 --- a/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs +++ b/GenHub/GenHub.Core/Constants/ReplayManagerConstants.cs @@ -10,6 +10,21 @@ public static class ReplayManagerConstants /// public const long MaxReplaySizeBytes = 1024 * 1024; + /// + /// Maximum allowed entries in a replay ZIP archive. + /// + public const int MaxZipEntries = 100; + + /// + /// Maximum aggregate uncompressed bytes for a replay ZIP archive (50 MB). + /// + public const long MaxAggregateUncompressedBytes = 50 * 1024 * 1024; + + /// + /// Maximum compression ratio allowed for replay ZIP archives. + /// + public const double MaxCompressionRatio = 50.0; + /// /// Maximum upload bytes per period (10 MB). /// @@ -29,4 +44,34 @@ public static class ReplayManagerConstants /// Default file name for imported replays. /// public const string DefaultImportedReplayFileName = "imported_replay.rep"; + + /// + /// File pattern for replay ZIP archives. + /// + public const string ZipFilePattern = "*.zip"; + + /// + /// Default name for exported replay ZIP files. + /// + public const string DefaultZipName = "replays"; + + /// + /// Notification title for delete failure. + /// + public const string DeleteFailedTitle = ToolConstants.DeleteFailedTitle; + + /// + /// Category identifier for replay uploads. + /// + public const string UploadCategory = "replays"; + + /// + /// Mock path separator indicator for demo environments on Windows. + /// + public const string WindowsMockPathSegment = ToolConstants.WindowsMockPathSegment; + + /// + /// Mock path separator indicator for demo environments on Unix. + /// + public const string UnixMockPathSegment = ToolConstants.UnixMockPathSegment; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/SettingsConstants.cs b/GenHub/GenHub.Core/Constants/SettingsConstants.cs new file mode 100644 index 000000000..ac1d945c9 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/SettingsConstants.cs @@ -0,0 +1,62 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the Settings feature sections and identifiers. +/// +public static class SettingsConstants +{ + /// + /// Section ID for Game Configuration. + /// + public const string SectionGameConfig = "game-config"; + + /// + /// Section ID for Downloads. + /// + public const string SectionDownloads = "downloads"; + + /// + /// Section ID for Appearance. + /// + public const string SectionAppearance = "appearance"; + + /// + /// Section ID for Data Directories. + /// + public const string SectionDataDirectories = "data-directories"; + + /// + /// Section ID for Logs. + /// + public const string SectionLogs = "logs"; + + /// + /// Section ID for Performance. + /// + public const string SectionPerformance = "performance"; + + /// + /// Section ID for Content-Addressable Storage (CAS). + /// + public const string SectionCas = "cas"; + + /// + /// Section ID for Local Content Directories. + /// + public const string SectionLocalContent = "local-content"; + + /// + /// Section ID for GitHub Discovery. + /// + public const string SectionGitHubDiscovery = "github-discovery"; + + /// + /// Section ID for Updates. + /// + public const string SectionUpdates = "updates"; + + /// + /// Section ID for Danger Zone. + /// + public const string SectionDangerZone = "danger-zone"; +} diff --git a/GenHub/GenHub.Core/Constants/SidebarConstants.cs b/GenHub/GenHub.Core/Constants/SidebarConstants.cs new file mode 100644 index 000000000..0f45f1c9b --- /dev/null +++ b/GenHub/GenHub.Core/Constants/SidebarConstants.cs @@ -0,0 +1,27 @@ +namespace GenHub.Core.Constants; + +/// +/// Constants for the sidebar layout and pane dimensions. +/// +public static class SidebarConstants +{ + /// + /// Default open width of the sidebar pane in pixels. + /// + public const double DefaultOpenPaneLength = 220.0; + + /// + /// Minimum allowed width of the sidebar pane in pixels when resizing. + /// + public const double MinPaneLength = 140.0; + + /// + /// Maximum allowed width of the sidebar pane in pixels when resizing. + /// + public const double MaxPaneLength = 360.0; + + /// + /// Width of the splitter divider column in pixels. + /// + public const double SplitterWidth = 4.0; +} 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/ThemeConstants.cs b/GenHub/GenHub.Core/Constants/ThemeConstants.cs new file mode 100644 index 000000000..30e8a1729 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ThemeConstants.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using GenHub.Core.Models.Theming; + +namespace GenHub.Core.Constants; + +/// +/// Constants and preset palettes for application color theming. +/// +public static class ThemeConstants +{ + /// + /// Default Void Purple theme. + /// + public static readonly ColorTheme DefaultTheme = new() + { + Id = "Purple", + DisplayName = "Void Purple", + PrimaryHex = "#A855F7", + LightHex = "#C084FC", + DarkHex = "#7E22CE", + GlowHex = "#33A855F7", + }; + + /// + /// Generals Command Orange theme. + /// + public static readonly ColorTheme GeneralsTheme = new() + { + Id = "Generals", + DisplayName = "Generals Orange", + PrimaryHex = "#F97316", + LightHex = "#FB923C", + DarkHex = "#C2410C", + GlowHex = "#33F97316", + }; + + /// + /// Zero Hour Tactical Cyan theme. + /// + public static readonly ColorTheme ZeroHourTheme = new() + { + Id = "ZeroHour", + DisplayName = "Zero Hour Cyan", + PrimaryHex = "#06B6D4", + LightHex = "#22D3EE", + DarkHex = "#0E7490", + GlowHex = "#3306B6D4", + }; + + /// + /// Emerald Toxic Green theme. + /// + public static readonly ColorTheme EmeraldTheme = new() + { + Id = "Emerald", + DisplayName = "Emerald Green", + PrimaryHex = "#10B981", + LightHex = "#34D399", + DarkHex = "#047857", + GlowHex = "#3310B981", + }; + + /// + /// NOD Crimson Red theme. + /// + public static readonly ColorTheme CrimsonTheme = new() + { + Id = "Crimson", + DisplayName = "Crimson Red", + PrimaryHex = "#EF4444", + LightHex = "#F87171", + DarkHex = "#B91C1C", + GlowHex = "#33EF4444", + }; + + /// + /// Cyber Amber Gold theme. + /// + public static readonly ColorTheme AmberTheme = new() + { + Id = "Amber", + DisplayName = "Cyber Amber", + PrimaryHex = "#F59E0B", + LightHex = "#FBBF24", + DarkHex = "#B45309", + GlowHex = "#33F59E0B", + }; + + /// + /// Cobalt Navy Blue theme. + /// + public static readonly ColorTheme CobaltTheme = new() + { + Id = "Cobalt", + DisplayName = "Cobalt Blue", + PrimaryHex = "#3B82F6", + LightHex = "#60A5FA", + DarkHex = "#1D4ED8", + GlowHex = "#333B82F6", + }; + + /// + /// Neon Rose Pink theme. + /// + public static readonly ColorTheme RoseTheme = new() + { + Id = "Rose", + DisplayName = "Neon Rose", + PrimaryHex = "#EC4899", + LightHex = "#F472B6", + DarkHex = "#BE185D", + GlowHex = "#33EC4899", + }; + + /// + /// Tiberium Lime theme. + /// + public static readonly ColorTheme TiberiumTheme = new() + { + Id = "Tiberium", + DisplayName = "Tiberium Lime", + PrimaryHex = "#84CC16", + LightHex = "#A3E635", + DarkHex = "#4D7C0F", + GlowHex = "#3384CC16", + }; + + /// + /// Deep Teal theme. + /// + public static readonly ColorTheme TealTheme = new() + { + Id = "Teal", + DisplayName = "Deep Teal", + PrimaryHex = "#14B8A6", + LightHex = "#2DD4BF", + DarkHex = "#0F766E", + GlowHex = "#3314B8A6", + }; + + /// + /// Electric Indigo theme. + /// + public static readonly ColorTheme IndigoTheme = new() + { + Id = "Indigo", + DisplayName = "Electric Indigo", + PrimaryHex = "#6366F1", + LightHex = "#818CF8", + DarkHex = "#4338CA", + GlowHex = "#336366F1", + }; + + /// + /// Blood Ruby theme. + /// + public static readonly ColorTheme RubyTheme = new() + { + Id = "Ruby", + DisplayName = "Blood Ruby", + PrimaryHex = "#F43F5E", + LightHex = "#FB7185", + DarkHex = "#BE123C", + GlowHex = "#33F43F5E", + }; + + /// + /// Gets all available color themes. + /// + public static readonly IReadOnlyList AllThemes = + [ + DefaultTheme, + GeneralsTheme, + ZeroHourTheme, + EmeraldTheme, + CrimsonTheme, + AmberTheme, + CobaltTheme, + RoseTheme, + TiberiumTheme, + TealTheme, + IndigoTheme, + RubyTheme, + ]; +} diff --git a/GenHub/GenHub.Core/Constants/ThemeResourceKeys.cs b/GenHub/GenHub.Core/Constants/ThemeResourceKeys.cs new file mode 100644 index 000000000..61f204b74 --- /dev/null +++ b/GenHub/GenHub.Core/Constants/ThemeResourceKeys.cs @@ -0,0 +1,202 @@ +namespace GenHub.Core.Constants; + +/// +/// Resource keys used for dynamic application theming in Avalonia resources. +/// +public static class ThemeResourceKeys +{ + /// + /// Accent color resource key. + /// + public const string AccentColor = "AccentColor"; + + /// + /// System accent color resource key. + /// + public const string SystemAccentColor = "SystemAccentColor"; + + /// + /// Primary button background dark color resource key. + /// + public const string PrimaryButtonBackgroundDark = "PrimaryButtonBackgroundDark"; + + /// + /// Accent badge background color resource key. + /// + public const string AccentBadgeBackgroundColor = "AccentBadgeBackgroundColor"; + + /// + /// Accent badge foreground color resource key. + /// + public const string AccentBadgeForegroundColor = "AccentBadgeForegroundColor"; + + /// + /// Accent glow color resource key. + /// + public const string AccentGlowColor = "AccentGlowColor"; + + /// + /// Primary gradient start color resource key. + /// + public const string PrimaryGradientStart = "PrimaryGradientStart"; + + /// + /// Primary gradient end color resource key. + /// + public const string PrimaryGradientEnd = "PrimaryGradientEnd"; + + /// + /// Accent brush resource key. + /// + public const string AccentBrush = "AccentBrush"; + + /// + /// Accent color brush resource key. + /// + public const string AccentColorBrush = "AccentColorBrush"; + + /// + /// Accent glow brush resource key. + /// + public const string AccentGlowBrush = "AccentGlowBrush"; + + /// + /// System accent color brush resource key. + /// + public const string SystemAccentColorBrush = "SystemAccentColorBrush"; + + /// + /// Primary button background brush resource key. + /// + public const string PrimaryButtonBackground = "PrimaryButtonBackground"; + + /// + /// Sidebar selected indicator brush resource key. + /// + public const string SidebarSelectedIndicator = "SidebarSelectedIndicator"; + + /// + /// Scrollbar pressed thumb brush resource key. + /// + public const string ScrollbarThumbPressedBrush = "ScrollbarThumbPressedBrush"; + + /// + /// Fluent ScrollBar pressed thumb fill brush resource key. + /// + public const string ScrollBarThumbFillPressed = "ScrollBarThumbFillPressed"; + + /// + /// Accent badge background brush resource key. + /// + public const string AccentBadgeBackgroundBrush = "AccentBadgeBackgroundBrush"; + + /// + /// Accent badge foreground brush resource key. + /// + public const string AccentBadgeForegroundBrush = "AccentBadgeForegroundBrush"; + + /// + /// Sidebar item selected background brush resource key. + /// + public const string SidebarItemSelectedBackground = "SidebarItemSelectedBackground"; + + /// + /// Sidebar item selected border brush resource key. + /// + public const string SidebarItemSelectedBorder = "SidebarItemSelectedBorder"; + + /// + /// Primary gradient brush resource key. + /// + public const string PrimaryGradientBrush = "PrimaryGradientBrush"; + + /// + /// Accent light color resource key. + /// + public const string AccentLightColor = "AccentLightColor"; + + /// + /// Accent dark color resource key. + /// + public const string AccentDarkColor = "AccentDarkColor"; + + /// + /// Accent tint background color resource key. + /// + public const string AccentTintBackgroundColor = "AccentTintBackgroundColor"; + + /// + /// Sidebar glass border color resource key. + /// + public const string SidebarGlassBorder = "SidebarGlassBorder"; + + /// + /// Sidebar glow color resource key. + /// + public const string SidebarGlowColor = "SidebarGlowColor"; + + /// + /// Accent light brush resource key. + /// + public const string AccentLightBrush = "AccentLightBrush"; + + /// + /// Accent dark brush resource key. + /// + public const string AccentDarkBrush = "AccentDarkBrush"; + + /// + /// Accent tint background brush resource key. + /// + public const string AccentTintBackgroundBrush = "AccentTintBackgroundBrush"; + + /// + /// Sidebar glass border brush resource key. + /// + public const string SidebarGlassBorderBrush = "SidebarGlassBorderBrush"; + + /// + /// Purple accent gradient brush resource key. + /// + public const string PurpleAccentGradient = "PurpleAccentGradient"; + + /// + /// Fluent ComboBox item background selected brush resource key. + /// + public const string ComboBoxItemBackgroundSelected = "ComboBoxItemBackgroundSelected"; + + /// + /// Fluent ComboBox item background selected pointer over brush resource key. + /// + public const string ComboBoxItemBackgroundSelectedPointerOver = "ComboBoxItemBackgroundSelectedPointerOver"; + + /// + /// Fluent ComboBox item background pointer over brush resource key. + /// + public const string ComboBoxItemBackgroundPointerOver = "ComboBoxItemBackgroundPointerOver"; + + /// + /// Fluent ComboBox item foreground pointer over brush resource key. + /// + public const string ComboBoxItemForegroundPointerOver = "ComboBoxItemForegroundPointerOver"; + + /// + /// Fluent Expander header background pointer over brush resource key. + /// + public const string ExpanderHeaderBackgroundPointerOver = "ExpanderHeaderBackgroundPointerOver"; + + /// + /// Fluent Expander header background pressed brush resource key. + /// + public const string ExpanderHeaderBackgroundPressed = "ExpanderHeaderBackgroundPressed"; + + /// + /// Fluent Expander chevron foreground pointer over brush resource key. + /// + public const string ExpanderChevronForegroundPointerOver = "ExpanderChevronForegroundPointerOver"; + + /// + /// Fluent Expander chevron foreground pressed brush resource key. + /// + public const string ExpanderChevronForegroundPressed = "ExpanderChevronForegroundPressed"; +} diff --git a/GenHub/GenHub.Core/Constants/ToolConstants.cs b/GenHub/GenHub.Core/Constants/ToolConstants.cs index 2967d13e0..129af5a47 100644 --- a/GenHub/GenHub.Core/Constants/ToolConstants.cs +++ b/GenHub/GenHub.Core/Constants/ToolConstants.cs @@ -1,10 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// Constants for tool plugin metadata and configuration. /// +[SuppressMessage("Major Code Smell", "S1075:URIs should not be hardcoded", Justification = "Mock URLs for demo tool services.")] public static class ToolConstants { + /// + /// Mock sharing URLs for demo tool services. + /// + public static class MockUrls + { + /// + /// Mock upload URL for replays. + /// + public const string MockReplayUploadUrl = "https://example.com/share/1234"; + + /// + /// Mock upload URL for maps. + /// + public const string MockMapUploadUrl = "https://example.com/maps/123"; + } + /// /// Constants for the Replay Manager tool plugin. /// @@ -50,4 +69,39 @@ public static class ReplayManager /// public static readonly string[] Tags = ["replays", "file-management", "sharing"]; } + + /// + /// Mock path separator indicator for demo environments on Windows. + /// + public const string WindowsMockPathSegment = "\\Mock\\"; + + /// + /// Mock path separator indicator for demo environments on Unix. + /// + public const string UnixMockPathSegment = "/Mock/"; + + /// + /// Notification title for delete failure. + /// + public const string DeleteFailedTitle = "Delete Failed"; + + /// + /// Default upload buffer size in bytes (8 KB). + /// + public const int DefaultUploadBufferSize = 8 * 1024; + + /// + /// Upload progress stage percentage threshold for compression stage. + /// + public const int UploadStageCompressionThresholdPercent = 25; + + /// + /// Upload progress stage percentage threshold for cloud upload stage. + /// + public const int UploadStageCloudThresholdPercent = 88; + + /// + /// Upload progress stage percentage threshold for completion stage. + /// + public const int UploadStageCompletePercent = 100; } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Constants/UriConstants.cs b/GenHub/GenHub.Core/Constants/UriConstants.cs index 5c6d82b8f..d623bdf74 100644 --- a/GenHub/GenHub.Core/Constants/UriConstants.cs +++ b/GenHub/GenHub.Core/Constants/UriConstants.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; + namespace GenHub.Core.Constants; /// /// URI scheme constants for handling different types of URIs and paths. /// +[SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "Centralized URI constants / mock demo paths")] public static class UriConstants { /// 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/Constants/WorkspaceConstants.cs b/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs index 034d5743c..3685685c8 100644 --- a/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs +++ b/GenHub/GenHub.Core/Constants/WorkspaceConstants.cs @@ -12,4 +12,10 @@ public static class WorkspaceConstants /// Default is HardLink as it provides space-efficient file management with good compatibility. /// public const WorkspaceStrategy DefaultWorkspaceStrategy = WorkspaceStrategy.HardLink; + + /// + /// Guidance message appended to errors when zero-copy hard links or symlinks cannot be created. + /// + public const string ZeroCopyElevationGuidance = + "To use zero-copy workspaces without copying game files, ensure GenHub has permission to create links (on Windows, enable Developer Mode or run as Administrator)."; } 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/GameInstallations/InstallationExtensions.cs b/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs index 132c753fc..866ae60bc 100644 --- a/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs +++ b/GenHub/GenHub.Core/Extensions/GameInstallations/InstallationExtensions.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using GenHub.Core.Constants; using GenHub.Core.Interfaces.GameInstallations; using GenHub.Core.Models.Enums; @@ -51,11 +52,74 @@ public static bool FileExistsCaseInsensitive(this string filePath) var files = directoryInfo.GetFiles(); return files.Any(f => string.Equals(f.Name, fileName, StringComparison.OrdinalIgnoreCase)); } - catch + catch (IOException) { // If directory enumeration fails, fall back to false return false; } + catch (UnauthorizedAccessException) + { + // If directory enumeration fails due to permissions, fall back to false + return false; + } + catch (ArgumentException) + { + // If path contains invalid characters, fall back to false + return false; + } + } + + /// + /// Checks if a subdirectory exists under a parent path in a case-insensitive manner, returning the matched directory path. + /// + /// The parent directory to search within. + /// The subdirectory name to look for. + /// The actual matched full path if found. + /// True if the subdirectory exists; otherwise false. + public static bool TryGetDirectoryCaseInsensitive(this string parentDirectory, string subDirectoryName, [NotNullWhen(true)] out string? matchedPath) + { + matchedPath = null; + if (string.IsNullOrEmpty(parentDirectory) || string.IsNullOrEmpty(subDirectoryName)) + { + return false; + } + + try + { + var candidate = Path.Combine(parentDirectory, subDirectoryName); + if (Directory.Exists(candidate)) + { + matchedPath = candidate; + return true; + } + + var directoryInfo = new DirectoryInfo(parentDirectory); + if (!directoryInfo.Exists) + { + return false; + } + + var matchingDir = directoryInfo.GetDirectories().FirstOrDefault(d => string.Equals(d.Name, subDirectoryName, StringComparison.OrdinalIgnoreCase)); + if (matchingDir is not null) + { + matchedPath = matchingDir.FullName; + return true; + } + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + + return false; } /// diff --git a/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs b/GenHub/GenHub.Core/Extensions/GameProfileExtensions.cs index b7660ef4d..8640ee339 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; @@ -13,6 +16,44 @@ public static class GameProfileExtensions /// The game profile. /// True if the profile has custom settings, false otherwise. public static bool HasCustomSettings(this GameProfile profile) + { + return HasCustomVideoSettings(profile) || + HasCustomAudioSettings(profile) || + HasCustomTshSettings(profile) || + HasCustomGeneralsOnlineSettings(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 || profile.VideoResolutionHeight.HasValue || @@ -31,14 +72,22 @@ public static bool HasCustomSettings(this GameProfile profile) profile.VideoRetaliation.HasValue || profile.VideoDynamicLOD.HasValue || profile.VideoMaxParticleCount.HasValue || - profile.VideoAntiAliasing.HasValue || - profile.AudioSoundVolume.HasValue || + profile.VideoAntiAliasing.HasValue; + } + + private static bool HasCustomAudioSettings(GameProfile profile) + { + return profile.AudioSoundVolume.HasValue || profile.AudioThreeDSoundVolume.HasValue || profile.AudioSpeechVolume.HasValue || profile.AudioMusicVolume.HasValue || profile.AudioEnabled.HasValue || - profile.AudioNumSounds.HasValue || - profile.TshArchiveReplays.HasValue || + profile.AudioNumSounds.HasValue; + } + + private static bool HasCustomTshSettings(GameProfile profile) + { + return profile.TshArchiveReplays.HasValue || profile.TshShowMoneyPerMinute.HasValue || profile.TshPlayerObserverEnabled.HasValue || profile.TshSystemTimeFontSize.HasValue || @@ -52,7 +101,12 @@ public static bool HasCustomSettings(this GameProfile profile) profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue || profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue || profile.TshMoneyTransactionVolume.HasValue || - profile.GoShowFps.HasValue || + profile.TshGameWindowTransitionSpeedMultiplier.HasValue; + } + + private static bool HasCustomGeneralsOnlineSettings(GameProfile profile) + { + return profile.GoShowFps.HasValue || profile.GoShowPing.HasValue || profile.GoAutoLogin.HasValue || profile.GoRememberUsername.HasValue || @@ -75,7 +129,11 @@ public static bool HasCustomSettings(this GameProfile profile) profile.GoSocialNotificationPlayerAcceptsRequestGameplay.HasValue || profile.GoSocialNotificationPlayerAcceptsRequestMenus.HasValue || profile.GoSocialNotificationPlayerSendsRequestGameplay.HasValue || - profile.GoSocialNotificationPlayerSendsRequestMenus.HasValue || - !string.IsNullOrEmpty(profile.GameSpyIPAddress); + profile.GoSocialNotificationPlayerSendsRequestMenus.HasValue; + } + + private static bool HasCustomNetworkSettings(GameProfile profile) + { + return !string.IsNullOrEmpty(profile.GameSpyIPAddress); } } 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 new file mode 100644 index 000000000..885befdeb --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/GameProcessSelector.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; + +namespace GenHub.Core.Helpers; + +/// +/// Decides which running process is the game a launch spawned. +/// +public static class GameProcessSelector +{ + /// + /// 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 selected candidate, or when none qualifies. + public static GameProcessCandidate? SelectSpawnedGameProcess( + IEnumerable candidates, + string processName, + string? workingDirectory, + DateTime now) + { + return Select( + candidates, + processName, + workingDirectory, + candidate => (now - candidate.StartTime).TotalSeconds < ProcessConstants.EarlyExitThresholdSeconds); + } + + /// + /// 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) + { + 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)) + { + matches = matches.Where(candidate => ResidesIn(candidate, workingDirectory)); + } + + return matches + .OrderByDescending(candidate => candidate.StartTime) + .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) + { + return false; + } + + var directory = Path.GetDirectoryName(candidate.ExecutablePath); + 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) + { + // 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 + { + return Path.GetFullPath(path); + } + catch (ArgumentException) + { + // A malformed path compares on its original spelling rather than aborting the scan. + } + catch (NotSupportedException) + { + // A malformed path compares on its original spelling rather than aborting the scan. + } + catch (PathTooLongException) + { + // A malformed path compares on its original spelling rather than aborting the scan. + } + + return null; + } +} diff --git a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs index 77e67d542..09dd3b578 100644 --- a/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs +++ b/GenHub/GenHub.Core/Helpers/GameSettingsMapper.cs @@ -1,3 +1,4 @@ +using System.Globalization; using GenHub.Core.Constants; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameProfile; @@ -18,8 +19,553 @@ public static class GameSettingsMapper /// The IniOptions containing the settings. /// The GameProfile to populate. public static void ApplyFromOptions(IniOptions options, GameProfile profile) + { + ApplyVideoFromOptions(options, profile); + ApplyAudioFromOptions(options, profile); + ApplyNetworkFromOptions(options, profile); + } + + /// + /// Applies settings from GeneralsOnlineSettings to a GameProfile. + /// Used when creating new profiles to inherit existing GO settings. + /// + /// The GeneralsOnlineSettings source. + /// The GameProfile to populate. + public static void ApplyFromGeneralsOnlineSettings(GeneralsOnlineSettings settings, GameProfile profile) + { + // GeneralsOnline settings + profile.GoShowFps = settings.ShowFps; + profile.GoShowPing = settings.ShowPing; + profile.GoShowPlayerRanks = settings.ShowPlayerRanks; + profile.GoAutoLogin = settings.AutoLogin; + profile.GoRememberUsername = settings.RememberUsername; + profile.GoEnableNotifications = settings.EnableNotifications; + profile.GoEnableSoundNotifications = settings.EnableSoundNotifications; + profile.GoChatFontSize = settings.ChatFontSize; + + // Camera settings + profile.GoCameraMaxHeightOnlyWhenLobbyHost = settings.Camera.MaxHeightOnlyWhenLobbyHost; + profile.GoCameraMinHeight = settings.Camera.MinHeight; + profile.GoCameraMoveSpeedRatio = settings.Camera.MoveSpeedRatio; + + // Chat settings + profile.GoChatDurationSecondsUntilFadeOut = settings.Chat.DurationSecondsUntilFadeOut; + + // Debug settings + profile.GoDebugVerboseLogging = settings.Debug.VerboseLogging; + + // Render settings + profile.GoRenderFpsLimit = settings.Render.FpsLimit; + profile.GoRenderLimitFramerate = settings.Render.LimitFramerate; + profile.GoRenderStatsOverlay = settings.Render.StatsOverlay; + + // Social notification settings + profile.GoSocialNotificationFriendComesOnlineGameplay = settings.Social.NotificationFriendComesOnlineGameplay; + profile.GoSocialNotificationFriendComesOnlineMenus = settings.Social.NotificationFriendComesOnlineMenus; + profile.GoSocialNotificationFriendGoesOfflineGameplay = settings.Social.NotificationFriendGoesOfflineGameplay; + profile.GoSocialNotificationFriendGoesOfflineMenus = settings.Social.NotificationFriendGoesOfflineMenus; + profile.GoSocialNotificationPlayerAcceptsRequestGameplay = settings.Social.NotificationPlayerAcceptsRequestGameplay; + profile.GoSocialNotificationPlayerAcceptsRequestMenus = settings.Social.NotificationPlayerAcceptsRequestMenus; + profile.GoSocialNotificationPlayerSendsRequestGameplay = settings.Social.NotificationPlayerSendsRequestGameplay; + profile.GoSocialNotificationPlayerSendsRequestMenus = settings.Social.NotificationPlayerSendsRequestMenus; + + // TSH settings (that exist in GeneralsOnlineSettings via inheritance) + profile.TshArchiveReplays = settings.ArchiveReplays; + profile.TshMoneyTransactionVolume = settings.MoneyTransactionVolume; + profile.TshShowMoneyPerMinute = settings.ShowMoneyPerMinute; + profile.TshPlayerObserverEnabled = settings.PlayerObserverEnabled; + profile.TshSystemTimeFontSize = settings.SystemTimeFontSize; + profile.TshNetworkLatencyFontSize = settings.NetworkLatencyFontSize; + profile.TshRenderFpsFontSize = settings.RenderFpsFontSize; + profile.TshResolutionFontAdjustment = settings.ResolutionFontAdjustment; + profile.TshCursorCaptureEnabledInFullscreenGame = settings.CursorCaptureEnabledInFullscreenGame; + profile.TshCursorCaptureEnabledInFullscreenMenu = settings.CursorCaptureEnabledInFullscreenMenu; + profile.TshCursorCaptureEnabledInWindowedGame = settings.CursorCaptureEnabledInWindowedGame; + profile.TshCursorCaptureEnabledInWindowedMenu = settings.CursorCaptureEnabledInWindowedMenu; + profile.TshScreenEdgeScrollEnabledInFullscreenApp = settings.ScreenEdgeScrollEnabledInFullscreenApp; + profile.TshScreenEdgeScrollEnabledInWindowedApp = settings.ScreenEdgeScrollEnabledInWindowedApp; + profile.TshGameWindowTransitionSpeedMultiplier = settings.GameWindowTransitionSpeedMultiplier; + } + + /// + /// 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); + ApplyGoSocialSettings(profile, settings); + ApplyGoTshSettings(profile, settings); + } + + /// + /// Applies profile settings to IniOptions with validation. + /// + /// The game profile containing the settings. + /// The IniOptions object to apply settings to. + /// Optional logger for validation warnings. + public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogger? logger = null) + { + ApplyVideoResolutionAndQualityToOptions(profile, options, logger); + ApplyVideoAdditionalToOptions(profile, options, logger); + ApplyAudioToOptions(profile, options, logger); + ApplyTshToOptions(profile, options); + } + + /// + /// Populates settings from a CreateProfileRequest into a GameProfile. + /// + /// The GameProfile to populate. + /// The request containing the settings. + public static void PopulateGameProfile(GameProfile profile, CreateProfileRequest request) + { + // Video settings + profile.VideoResolutionWidth = request.VideoResolutionWidth; + profile.VideoResolutionHeight = request.VideoResolutionHeight; + profile.VideoWindowed = request.VideoWindowed; + profile.VideoTextureQuality = request.VideoTextureQuality; + profile.EnableVideoShadows = request.EnableVideoShadows; + profile.VideoParticleEffects = request.VideoParticleEffects; + profile.VideoExtraAnimations = request.VideoExtraAnimations; + profile.VideoBuildingAnimations = request.VideoBuildingAnimations; + profile.VideoGamma = request.VideoGamma; + profile.VideoAlternateMouseSetup = request.VideoAlternateMouseSetup; + profile.VideoHeatEffects = request.VideoHeatEffects; + + // Audio settings + profile.AudioSoundVolume = request.AudioSoundVolume; + profile.AudioThreeDSoundVolume = request.AudioThreeDSoundVolume; + profile.AudioSpeechVolume = request.AudioSpeechVolume; + profile.AudioMusicVolume = request.AudioMusicVolume; + profile.AudioEnabled = request.AudioEnabled; + profile.AudioNumSounds = request.AudioNumSounds; + + // TheSuperHackers settings + profile.TshArchiveReplays = request.TshArchiveReplays; + profile.TshShowMoneyPerMinute = request.TshShowMoneyPerMinute; + profile.TshPlayerObserverEnabled = request.TshPlayerObserverEnabled; + profile.TshSystemTimeFontSize = request.TshSystemTimeFontSize; + profile.TshNetworkLatencyFontSize = request.TshNetworkLatencyFontSize; + profile.TshRenderFpsFontSize = request.TshRenderFpsFontSize; + profile.TshResolutionFontAdjustment = request.TshResolutionFontAdjustment; + profile.TshCursorCaptureEnabledInFullscreenGame = request.TshCursorCaptureEnabledInFullscreenGame; + profile.TshCursorCaptureEnabledInFullscreenMenu = request.TshCursorCaptureEnabledInFullscreenMenu; + profile.TshCursorCaptureEnabledInWindowedGame = request.TshCursorCaptureEnabledInWindowedGame; + profile.TshCursorCaptureEnabledInWindowedMenu = request.TshCursorCaptureEnabledInWindowedMenu; + profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp; + profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp; + profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier; + + // GeneralsOnline settings + profile.GoShowFps = request.GoShowFps; + profile.GoShowPing = request.GoShowPing; + profile.GoShowPlayerRanks = request.GoShowPlayerRanks; + profile.GoAutoLogin = request.GoAutoLogin; + profile.GoRememberUsername = request.GoRememberUsername; + profile.GoEnableNotifications = request.GoEnableNotifications; + profile.GoEnableSoundNotifications = request.GoEnableSoundNotifications; + profile.GoChatFontSize = request.GoChatFontSize; + + // Camera settings + profile.GoCameraMaxHeightOnlyWhenLobbyHost = request.GoCameraMaxHeightOnlyWhenLobbyHost; + profile.GoCameraMinHeight = request.GoCameraMinHeight; + profile.GoCameraMoveSpeedRatio = request.GoCameraMoveSpeedRatio; + + // Chat settings + profile.GoChatDurationSecondsUntilFadeOut = request.GoChatDurationSecondsUntilFadeOut; + + // Debug settings + profile.GoDebugVerboseLogging = request.GoDebugVerboseLogging; + + // Render settings + profile.GoRenderFpsLimit = request.GoRenderFpsLimit; + profile.GoRenderLimitFramerate = request.GoRenderLimitFramerate; + profile.GoRenderStatsOverlay = request.GoRenderStatsOverlay; + + // Social notification settings + profile.GoSocialNotificationFriendComesOnlineGameplay = request.GoSocialNotificationFriendComesOnlineGameplay; + profile.GoSocialNotificationFriendComesOnlineMenus = request.GoSocialNotificationFriendComesOnlineMenus; + profile.GoSocialNotificationFriendGoesOfflineGameplay = request.GoSocialNotificationFriendGoesOfflineGameplay; + profile.GoSocialNotificationFriendGoesOfflineMenus = request.GoSocialNotificationFriendGoesOfflineMenus; + profile.GoSocialNotificationPlayerAcceptsRequestGameplay = request.GoSocialNotificationPlayerAcceptsRequestGameplay; + profile.GoSocialNotificationPlayerAcceptsRequestMenus = request.GoSocialNotificationPlayerAcceptsRequestMenus; + profile.GoSocialNotificationPlayerSendsRequestGameplay = request.GoSocialNotificationPlayerSendsRequestGameplay; + profile.GoSocialNotificationPlayerSendsRequestMenus = request.GoSocialNotificationPlayerSendsRequestMenus; + + profile.GameSpyIPAddress = request.GameSpyIPAddress; + profile.VideoSkipEALogo = request.VideoSkipEALogo; + } + + /// + /// Populates settings from an UpdateProfileRequest into a GameProfile. + /// + /// The GameProfile to populate. + /// The request containing the settings. + public static void PopulateGameProfile(GameProfile profile, UpdateProfileRequest request) { // Video settings + profile.VideoResolutionWidth = request.VideoResolutionWidth; + profile.VideoResolutionHeight = request.VideoResolutionHeight; + profile.VideoWindowed = request.VideoWindowed; + profile.VideoTextureQuality = request.VideoTextureQuality; + profile.EnableVideoShadows = request.EnableVideoShadows; + profile.VideoParticleEffects = request.VideoParticleEffects; + profile.VideoExtraAnimations = request.VideoExtraAnimations; + profile.VideoBuildingAnimations = request.VideoBuildingAnimations; + profile.VideoGamma = request.VideoGamma; + profile.VideoAlternateMouseSetup = request.VideoAlternateMouseSetup; + profile.VideoHeatEffects = request.VideoHeatEffects; + profile.VideoStaticGameLOD = request.VideoStaticGameLOD; + profile.VideoIdealStaticGameLOD = request.VideoIdealStaticGameLOD; + profile.VideoUseDoubleClickAttackMove = request.VideoUseDoubleClickAttackMove; + profile.VideoScrollFactor = request.VideoScrollFactor; + profile.VideoRetaliation = request.VideoRetaliation; + profile.VideoDynamicLOD = request.VideoDynamicLOD; + profile.VideoMaxParticleCount = request.VideoMaxParticleCount; + profile.VideoAntiAliasing = request.VideoAntiAliasing; + profile.VideoUseLightMap = request.VideoUseLightMap; + profile.VideoSkipEALogo = request.VideoSkipEALogo; + + // Audio settings + profile.AudioSoundVolume = request.AudioSoundVolume; + profile.AudioThreeDSoundVolume = request.AudioThreeDSoundVolume; + profile.AudioSpeechVolume = request.AudioSpeechVolume; + profile.AudioMusicVolume = request.AudioMusicVolume; + profile.AudioEnabled = request.AudioEnabled; + profile.AudioNumSounds = request.AudioNumSounds; + + // TheSuperHackers settings + profile.TshArchiveReplays = request.TshArchiveReplays; + profile.TshShowMoneyPerMinute = request.TshShowMoneyPerMinute; + profile.TshPlayerObserverEnabled = request.TshPlayerObserverEnabled; + profile.TshSystemTimeFontSize = request.TshSystemTimeFontSize; + profile.TshNetworkLatencyFontSize = request.TshNetworkLatencyFontSize; + profile.TshRenderFpsFontSize = request.TshRenderFpsFontSize; + profile.TshResolutionFontAdjustment = request.TshResolutionFontAdjustment; + profile.TshCursorCaptureEnabledInFullscreenGame = request.TshCursorCaptureEnabledInFullscreenGame; + profile.TshCursorCaptureEnabledInFullscreenMenu = request.TshCursorCaptureEnabledInFullscreenMenu; + profile.TshCursorCaptureEnabledInWindowedGame = request.TshCursorCaptureEnabledInWindowedGame; + profile.TshCursorCaptureEnabledInWindowedMenu = request.TshCursorCaptureEnabledInWindowedMenu; + profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp; + profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp; + profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier; + + // GeneralsOnline settings + profile.GoShowFps = request.GoShowFps; + profile.GoShowPing = request.GoShowPing; + profile.GoShowPlayerRanks = request.GoShowPlayerRanks; + profile.GoAutoLogin = request.GoAutoLogin; + profile.GoRememberUsername = request.GoRememberUsername; + profile.GoEnableNotifications = request.GoEnableNotifications; + profile.GoEnableSoundNotifications = request.GoEnableSoundNotifications; + profile.GoChatFontSize = request.GoChatFontSize; + + // Camera settings + profile.GoCameraMaxHeightOnlyWhenLobbyHost = request.GoCameraMaxHeightOnlyWhenLobbyHost; + profile.GoCameraMinHeight = request.GoCameraMinHeight; + profile.GoCameraMoveSpeedRatio = request.GoCameraMoveSpeedRatio; + + // Chat settings + profile.GoChatDurationSecondsUntilFadeOut = request.GoChatDurationSecondsUntilFadeOut; + + // Debug settings + profile.GoDebugVerboseLogging = request.GoDebugVerboseLogging; + + // Render settings + profile.GoRenderFpsLimit = request.GoRenderFpsLimit; + profile.GoRenderLimitFramerate = request.GoRenderLimitFramerate; + profile.GoRenderStatsOverlay = request.GoRenderStatsOverlay; + + // Social notification settings + profile.GoSocialNotificationFriendComesOnlineGameplay = request.GoSocialNotificationFriendComesOnlineGameplay; + profile.GoSocialNotificationFriendComesOnlineMenus = request.GoSocialNotificationFriendComesOnlineMenus; + profile.GoSocialNotificationFriendGoesOfflineGameplay = request.GoSocialNotificationFriendGoesOfflineGameplay; + profile.GoSocialNotificationFriendGoesOfflineMenus = request.GoSocialNotificationFriendGoesOfflineMenus; + profile.GoSocialNotificationPlayerAcceptsRequestGameplay = request.GoSocialNotificationPlayerAcceptsRequestGameplay; + profile.GoSocialNotificationPlayerAcceptsRequestMenus = request.GoSocialNotificationPlayerAcceptsRequestMenus; + profile.GoSocialNotificationPlayerSendsRequestGameplay = request.GoSocialNotificationPlayerSendsRequestGameplay; + profile.GoSocialNotificationPlayerSendsRequestMenus = request.GoSocialNotificationPlayerSendsRequestMenus; + + profile.GameSpyIPAddress = request.GameSpyIPAddress; + profile.VideoSkipEALogo = request.VideoSkipEALogo; + } + + /// + /// Patches a GameProfile with non-null values from a CreateProfileRequest. + /// + /// The GameProfile to patch. + /// The request containing potentially partial settings. + public static void PatchGameProfile(GameProfile profile, CreateProfileRequest request) + { + PatchVideoSettings(profile, request); + PatchAudioSettings(profile, request); + PatchTshSettings(profile, request); + PatchGeneralsOnlineSettings(profile, request); + + profile.GameSpyIPAddress = request.GameSpyIPAddress ?? profile.GameSpyIPAddress; + profile.VideoSkipEALogo = request.VideoSkipEALogo ?? profile.VideoSkipEALogo; + } + + /// + /// Patches a GameProfile with non-null values from an UpdateProfileRequest. + /// + /// The GameProfile to patch. + /// The request containing potentially partial settings. + public static void UpdateFromRequest(GameProfile profile, UpdateProfileRequest request) + { + UpdateVideoFromRequest(profile, request); + UpdateAudioFromRequest(profile, request); + UpdateTshFromRequest(profile, request); + UpdateGeneralsOnlineFromRequest(profile, request); + + if (request.UseSteamLaunch.HasValue) + profile.UseSteamLaunch = request.UseSteamLaunch.Value; + + profile.GameSpyIPAddress = request.GameSpyIPAddress ?? profile.GameSpyIPAddress; + profile.VideoSkipEALogo = request.VideoSkipEALogo ?? profile.VideoSkipEALogo; + } + + /// + /// Populates settings from one UpdateProfileRequest into a CreateProfileRequest. + /// + /// The target CreateProfileRequest. + /// The source UpdateProfileRequest. + public static void PopulateRequest(CreateProfileRequest target, UpdateProfileRequest source) + { + target.VideoResolutionWidth = source.VideoResolutionWidth; + target.VideoResolutionHeight = source.VideoResolutionHeight; + target.VideoWindowed = source.VideoWindowed; + target.VideoTextureQuality = source.VideoTextureQuality; + target.EnableVideoShadows = source.EnableVideoShadows; + target.VideoParticleEffects = source.VideoParticleEffects; + target.VideoExtraAnimations = source.VideoExtraAnimations; + target.VideoBuildingAnimations = source.VideoBuildingAnimations; + target.VideoGamma = source.VideoGamma; + target.VideoAlternateMouseSetup = source.VideoAlternateMouseSetup; + target.VideoHeatEffects = source.VideoHeatEffects; + target.VideoStaticGameLOD = source.VideoStaticGameLOD; + target.VideoIdealStaticGameLOD = source.VideoIdealStaticGameLOD; + target.VideoUseDoubleClickAttackMove = source.VideoUseDoubleClickAttackMove; + target.VideoScrollFactor = source.VideoScrollFactor; + target.VideoRetaliation = source.VideoRetaliation; + target.VideoDynamicLOD = source.VideoDynamicLOD; + target.VideoMaxParticleCount = source.VideoMaxParticleCount; + target.VideoAntiAliasing = source.VideoAntiAliasing; + target.VideoDrawScrollAnchor = source.VideoDrawScrollAnchor; + target.VideoMoveScrollAnchor = source.VideoMoveScrollAnchor; + target.VideoGameTimeFontSize = source.VideoGameTimeFontSize; + target.GameLanguageFilter = source.GameLanguageFilter; + target.NetworkSendDelay = source.NetworkSendDelay; + target.VideoShowSoftWaterEdge = source.VideoShowSoftWaterEdge; + target.VideoShowTrees = source.VideoShowTrees; + target.VideoUseCloudMap = source.VideoUseCloudMap; + target.VideoUseLightMap = source.VideoUseLightMap; + target.VideoSkipEALogo = source.VideoSkipEALogo; + + target.AudioSoundVolume = source.AudioSoundVolume; + target.AudioThreeDSoundVolume = source.AudioThreeDSoundVolume; + target.AudioSpeechVolume = source.AudioSpeechVolume; + target.AudioMusicVolume = source.AudioMusicVolume; + target.AudioEnabled = source.AudioEnabled; + target.AudioNumSounds = source.AudioNumSounds; + + target.TshArchiveReplays = source.TshArchiveReplays; + target.TshShowMoneyPerMinute = source.TshShowMoneyPerMinute; + target.TshPlayerObserverEnabled = source.TshPlayerObserverEnabled; + target.TshSystemTimeFontSize = source.TshSystemTimeFontSize; + target.TshNetworkLatencyFontSize = source.TshNetworkLatencyFontSize; + target.TshRenderFpsFontSize = source.TshRenderFpsFontSize; + target.TshResolutionFontAdjustment = source.TshResolutionFontAdjustment; + target.TshCursorCaptureEnabledInFullscreenGame = source.TshCursorCaptureEnabledInFullscreenGame; + target.TshCursorCaptureEnabledInFullscreenMenu = source.TshCursorCaptureEnabledInFullscreenMenu; + target.TshCursorCaptureEnabledInWindowedGame = source.TshCursorCaptureEnabledInWindowedGame; + target.TshCursorCaptureEnabledInWindowedMenu = source.TshCursorCaptureEnabledInWindowedMenu; + target.TshScreenEdgeScrollEnabledInFullscreenApp = source.TshScreenEdgeScrollEnabledInFullscreenApp; + target.TshScreenEdgeScrollEnabledInWindowedApp = source.TshScreenEdgeScrollEnabledInWindowedApp; + target.TshMoneyTransactionVolume = source.TshMoneyTransactionVolume; + target.TshGameWindowTransitionSpeedMultiplier = source.TshGameWindowTransitionSpeedMultiplier; + + target.GoShowFps = source.GoShowFps; + target.GoShowPing = source.GoShowPing; + target.GoShowPlayerRanks = source.GoShowPlayerRanks; + target.GoAutoLogin = source.GoAutoLogin; + target.GoRememberUsername = source.GoRememberUsername; + target.GoEnableNotifications = source.GoEnableNotifications; + target.GoEnableSoundNotifications = source.GoEnableSoundNotifications; + target.GoChatFontSize = source.GoChatFontSize; + + target.GoCameraMaxHeightOnlyWhenLobbyHost = source.GoCameraMaxHeightOnlyWhenLobbyHost; + target.GoCameraMinHeight = source.GoCameraMinHeight; + target.GoCameraMoveSpeedRatio = source.GoCameraMoveSpeedRatio; + + target.GoChatDurationSecondsUntilFadeOut = source.GoChatDurationSecondsUntilFadeOut; + + target.GoDebugVerboseLogging = source.GoDebugVerboseLogging; + + target.GoRenderFpsLimit = source.GoRenderFpsLimit; + target.GoRenderLimitFramerate = source.GoRenderLimitFramerate; + target.GoRenderStatsOverlay = source.GoRenderStatsOverlay; + + target.GoSocialNotificationFriendComesOnlineGameplay = source.GoSocialNotificationFriendComesOnlineGameplay; + target.GoSocialNotificationFriendComesOnlineMenus = source.GoSocialNotificationFriendComesOnlineMenus; + target.GoSocialNotificationFriendGoesOfflineGameplay = source.GoSocialNotificationFriendGoesOfflineGameplay; + target.GoSocialNotificationFriendGoesOfflineMenus = source.GoSocialNotificationFriendGoesOfflineMenus; + target.GoSocialNotificationPlayerAcceptsRequestGameplay = source.GoSocialNotificationPlayerAcceptsRequestGameplay; + target.GoSocialNotificationPlayerAcceptsRequestMenus = source.GoSocialNotificationPlayerAcceptsRequestMenus; + target.GoSocialNotificationPlayerSendsRequestGameplay = source.GoSocialNotificationPlayerSendsRequestGameplay; + target.GoSocialNotificationPlayerSendsRequestMenus = source.GoSocialNotificationPlayerSendsRequestMenus; + + target.GameSpyIPAddress = source.GameSpyIPAddress; + } + + /// + /// Populates settings from one UpdateProfileRequest into another. + /// + /// The target UpdateProfileRequest. + /// The source UpdateProfileRequest. + public static void PopulateRequest(UpdateProfileRequest target, UpdateProfileRequest source) + { + target.VideoResolutionWidth = source.VideoResolutionWidth; + target.VideoResolutionHeight = source.VideoResolutionHeight; + target.VideoWindowed = source.VideoWindowed; + target.VideoTextureQuality = source.VideoTextureQuality; + target.EnableVideoShadows = source.EnableVideoShadows; + target.VideoParticleEffects = source.VideoParticleEffects; + target.VideoExtraAnimations = source.VideoExtraAnimations; + target.VideoBuildingAnimations = source.VideoBuildingAnimations; + target.VideoGamma = source.VideoGamma; + target.VideoAlternateMouseSetup = source.VideoAlternateMouseSetup; + target.VideoHeatEffects = source.VideoHeatEffects; + target.VideoStaticGameLOD = source.VideoStaticGameLOD; + target.VideoIdealStaticGameLOD = source.VideoIdealStaticGameLOD; + target.VideoUseDoubleClickAttackMove = source.VideoUseDoubleClickAttackMove; + target.VideoScrollFactor = source.VideoScrollFactor; + target.VideoRetaliation = source.VideoRetaliation; + target.VideoDynamicLOD = source.VideoDynamicLOD; + target.VideoMaxParticleCount = source.VideoMaxParticleCount; + target.VideoAntiAliasing = source.VideoAntiAliasing; + target.VideoDrawScrollAnchor = source.VideoDrawScrollAnchor; + target.VideoMoveScrollAnchor = source.VideoMoveScrollAnchor; + target.VideoGameTimeFontSize = source.VideoGameTimeFontSize; + target.GameLanguageFilter = source.GameLanguageFilter; + target.NetworkSendDelay = source.NetworkSendDelay; + target.VideoShowSoftWaterEdge = source.VideoShowSoftWaterEdge; + target.VideoShowTrees = source.VideoShowTrees; + target.VideoUseCloudMap = source.VideoUseCloudMap; + target.VideoUseLightMap = source.VideoUseLightMap; + target.VideoSkipEALogo = source.VideoSkipEALogo; + + target.AudioSoundVolume = source.AudioSoundVolume; + target.AudioThreeDSoundVolume = source.AudioThreeDSoundVolume; + target.AudioSpeechVolume = source.AudioSpeechVolume; + target.AudioMusicVolume = source.AudioMusicVolume; + target.AudioEnabled = source.AudioEnabled; + target.AudioNumSounds = source.AudioNumSounds; + + target.TshArchiveReplays = source.TshArchiveReplays; + target.TshShowMoneyPerMinute = source.TshShowMoneyPerMinute; + target.TshPlayerObserverEnabled = source.TshPlayerObserverEnabled; + target.TshSystemTimeFontSize = source.TshSystemTimeFontSize; + target.TshNetworkLatencyFontSize = source.TshNetworkLatencyFontSize; + target.TshRenderFpsFontSize = source.TshRenderFpsFontSize; + target.TshResolutionFontAdjustment = source.TshResolutionFontAdjustment; + target.TshCursorCaptureEnabledInFullscreenGame = source.TshCursorCaptureEnabledInFullscreenGame; + target.TshCursorCaptureEnabledInFullscreenMenu = source.TshCursorCaptureEnabledInFullscreenMenu; + target.TshCursorCaptureEnabledInWindowedGame = source.TshCursorCaptureEnabledInWindowedGame; + target.TshCursorCaptureEnabledInWindowedMenu = source.TshCursorCaptureEnabledInWindowedMenu; + target.TshScreenEdgeScrollEnabledInFullscreenApp = source.TshScreenEdgeScrollEnabledInFullscreenApp; + target.TshScreenEdgeScrollEnabledInWindowedApp = source.TshScreenEdgeScrollEnabledInWindowedApp; + target.TshMoneyTransactionVolume = source.TshMoneyTransactionVolume; + target.TshGameWindowTransitionSpeedMultiplier = source.TshGameWindowTransitionSpeedMultiplier; + + target.GoShowFps = source.GoShowFps; + target.GoShowPing = source.GoShowPing; + target.GoShowPlayerRanks = source.GoShowPlayerRanks; + target.GoAutoLogin = source.GoAutoLogin; + target.GoRememberUsername = source.GoRememberUsername; + target.GoEnableNotifications = source.GoEnableNotifications; + target.GoEnableSoundNotifications = source.GoEnableSoundNotifications; + target.GoChatFontSize = source.GoChatFontSize; + + target.GoCameraMaxHeightOnlyWhenLobbyHost = source.GoCameraMaxHeightOnlyWhenLobbyHost; + target.GoCameraMinHeight = source.GoCameraMinHeight; + target.GoCameraMoveSpeedRatio = source.GoCameraMoveSpeedRatio; + + target.GoChatDurationSecondsUntilFadeOut = source.GoChatDurationSecondsUntilFadeOut; + + target.GoDebugVerboseLogging = source.GoDebugVerboseLogging; + + target.GoRenderFpsLimit = source.GoRenderFpsLimit; + target.GoRenderLimitFramerate = source.GoRenderLimitFramerate; + target.GoRenderStatsOverlay = source.GoRenderStatsOverlay; + + target.GoSocialNotificationFriendComesOnlineGameplay = source.GoSocialNotificationFriendComesOnlineGameplay; + target.GoSocialNotificationFriendComesOnlineMenus = source.GoSocialNotificationFriendComesOnlineMenus; + target.GoSocialNotificationFriendGoesOfflineGameplay = source.GoSocialNotificationFriendGoesOfflineGameplay; + target.GoSocialNotificationFriendGoesOfflineMenus = source.GoSocialNotificationFriendGoesOfflineMenus; + target.GoSocialNotificationPlayerAcceptsRequestGameplay = source.GoSocialNotificationPlayerAcceptsRequestGameplay; + target.GoSocialNotificationPlayerAcceptsRequestMenus = source.GoSocialNotificationPlayerAcceptsRequestMenus; + target.GoSocialNotificationPlayerSendsRequestGameplay = source.GoSocialNotificationPlayerSendsRequestGameplay; + target.GoSocialNotificationPlayerSendsRequestMenus = source.GoSocialNotificationPlayerSendsRequestMenus; + + target.UseSteamLaunch = source.UseSteamLaunch; + target.GameSpyIPAddress = source.GameSpyIPAddress; + target.VideoSkipEALogo = source.VideoSkipEALogo; + } + + /// + /// Normalizes and clamps a transition speed multiplier value to the supported range. + /// + /// The float value. + /// The clamped float multiplier if finite and non-null; otherwise, null. + public static float? NormalizeTransitionSpeedMultiplier(float? value) + { + if (value.HasValue && float.IsFinite(value.Value)) + { + return Math.Clamp( + value.Value, + GameSettingsTheSuperHackersConstants.MinGameWindowTransitionSpeedMultiplier, + GameSettingsTheSuperHackersConstants.MaxGameWindowTransitionSpeedMultiplier); + } + + return null; + } + + /// + /// Parses and clamps the GameWindowTransitionSpeedMultiplier from a raw string value. + /// + /// The raw string value. + /// The clamped float multiplier if valid; otherwise, null. + public static float? ParseTransitionSpeedMultiplier(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (float.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var speed)) + { + return NormalizeTransitionSpeedMultiplier(speed); + } + + return null; + } + + private static void ApplyVideoFromOptions(IniOptions options, GameProfile profile) + { profile.VideoResolutionWidth = options.Video.ResolutionWidth; profile.VideoResolutionHeight = options.Video.ResolutionHeight; profile.VideoWindowed = options.Video.Windowed; @@ -57,7 +603,12 @@ public static void ApplyFromOptions(IniOptions options, GameProfile profile) profile.VideoAntiAliasing ??= options.Video.AntiAliasing; - // TSH settings from root (Flat format support) + ApplyTshFlatSettingsFromOptions(options, profile); + ApplyTshHierarchicalSettingsFromOptions(options, profile); + } + + private static void ApplyTshFlatSettingsFromOptions(IniOptions options, GameProfile profile) + { if (options.Video.AdditionalProperties.TryGetValue("UseDoubleClickAttackMove", out var doubleClick)) profile.VideoUseDoubleClickAttackMove = ParseBool(doubleClick); else if (options.Video.AdditionalProperties.TryGetValue("UseDoubleClick", out var dbl)) @@ -71,166 +622,125 @@ public static void ApplyFromOptions(IniOptions options, GameProfile profile) profile.VideoDynamicLOD = ParseBool(dynLOD); if (options.Video.AdditionalProperties.TryGetValue("MaxParticleCount", out var particles) && int.TryParse(particles, out var particleVal)) profile.VideoMaxParticleCount = particleVal; + if (options.Video.AdditionalProperties.TryGetValue(GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey, out var speed)) + { + var parsed = ParseTransitionSpeedMultiplier(speed); + if (parsed.HasValue) + { + profile.TshGameWindowTransitionSpeedMultiplier = parsed.Value; + } + } + } - // TSH-specific settings from the [TheSuperHackers] section (Hierarchical format support) + private static void ApplyTshHierarchicalSettingsFromOptions(IniOptions options, GameProfile profile) + { if (options.AdditionalSections.TryGetValue("TheSuperHackers", out var tsh)) { - if (tsh.TryGetValue("UseDoubleClickAttackMove", out var doubleClickTsh)) - profile.VideoUseDoubleClickAttackMove = ParseBool(doubleClickTsh); - if (tsh.TryGetValue("ScrollFactor", out var scrollTsh) && int.TryParse(scrollTsh, out var scrollTshVal)) - profile.VideoScrollFactor = scrollTshVal; - if (tsh.TryGetValue("Retaliation", out var retaliationTsh)) - profile.VideoRetaliation = ParseBool(retaliationTsh); - if (tsh.TryGetValue("DynamicLOD", out var dynLODTsh)) - profile.VideoDynamicLOD = ParseBool(dynLODTsh); - if (tsh.TryGetValue("MaxParticleCount", out var particlesTsh) && int.TryParse(particlesTsh, out var particlesTshVal)) - profile.VideoMaxParticleCount = particlesTshVal; + ApplyTshHierarchicalProperties(tsh, profile); } - - // Audio settings - profile.AudioSoundVolume = options.Audio.SFXVolume; - profile.AudioThreeDSoundVolume = options.Audio.SFX3DVolume; - profile.AudioSpeechVolume = options.Audio.VoiceVolume; - profile.AudioMusicVolume = options.Audio.MusicVolume; - profile.AudioEnabled = options.Audio.AudioEnabled; - profile.AudioNumSounds = options.Audio.NumSounds; - - // Network settings - profile.GameSpyIPAddress = options.Network.GameSpyIPAddress; } - /// - /// Applies settings from GeneralsOnlineSettings to a GameProfile. - /// Used when creating new profiles to inherit existing GO settings. - /// - /// The GeneralsOnlineSettings source. - /// The GameProfile to populate. - public static void ApplyFromGeneralsOnlineSettings(GeneralsOnlineSettings settings, GameProfile profile) + private static void ApplyTshHierarchicalProperties(Dictionary tsh, GameProfile profile) { - // GeneralsOnline settings - profile.GoShowFps = settings.ShowFps; - profile.GoShowPing = settings.ShowPing; - profile.GoShowPlayerRanks = settings.ShowPlayerRanks; - profile.GoAutoLogin = settings.AutoLogin; - profile.GoRememberUsername = settings.RememberUsername; - profile.GoEnableNotifications = settings.EnableNotifications; - profile.GoEnableSoundNotifications = settings.EnableSoundNotifications; - profile.GoChatFontSize = settings.ChatFontSize; - - // Camera settings - profile.GoCameraMaxHeightOnlyWhenLobbyHost = settings.Camera.MaxHeightOnlyWhenLobbyHost; - profile.GoCameraMinHeight = settings.Camera.MinHeight; - profile.GoCameraMoveSpeedRatio = settings.Camera.MoveSpeedRatio; - - // Chat settings - profile.GoChatDurationSecondsUntilFadeOut = settings.Chat.DurationSecondsUntilFadeOut; - - // Debug settings - profile.GoDebugVerboseLogging = settings.Debug.VerboseLogging; - - // Render settings - profile.GoRenderFpsLimit = settings.Render.FpsLimit; - profile.GoRenderLimitFramerate = settings.Render.LimitFramerate; - profile.GoRenderStatsOverlay = settings.Render.StatsOverlay; - - // Social notification settings - profile.GoSocialNotificationFriendComesOnlineGameplay = settings.Social.NotificationFriendComesOnlineGameplay; - profile.GoSocialNotificationFriendComesOnlineMenus = settings.Social.NotificationFriendComesOnlineMenus; - profile.GoSocialNotificationFriendGoesOfflineGameplay = settings.Social.NotificationFriendGoesOfflineGameplay; - profile.GoSocialNotificationFriendGoesOfflineMenus = settings.Social.NotificationFriendGoesOfflineMenus; - profile.GoSocialNotificationPlayerAcceptsRequestGameplay = settings.Social.NotificationPlayerAcceptsRequestGameplay; - profile.GoSocialNotificationPlayerAcceptsRequestMenus = settings.Social.NotificationPlayerAcceptsRequestMenus; - profile.GoSocialNotificationPlayerSendsRequestGameplay = settings.Social.NotificationPlayerSendsRequestGameplay; - profile.GoSocialNotificationPlayerSendsRequestMenus = settings.Social.NotificationPlayerSendsRequestMenus; + if (tsh.TryGetValue("UseDoubleClickAttackMove", out var doubleClickTsh)) + profile.VideoUseDoubleClickAttackMove = ParseBool(doubleClickTsh); + if (tsh.TryGetValue("ScrollFactor", out var scrollTsh) && int.TryParse(scrollTsh, out var scrollTshVal)) + profile.VideoScrollFactor = scrollTshVal; + if (tsh.TryGetValue("Retaliation", out var retaliationTsh)) + profile.VideoRetaliation = ParseBool(retaliationTsh); + if (tsh.TryGetValue("DynamicLOD", out var dynLODTsh)) + profile.VideoDynamicLOD = ParseBool(dynLODTsh); + if (tsh.TryGetValue("MaxParticleCount", out var particlesTsh) && int.TryParse(particlesTsh, out var particlesTshVal)) + profile.VideoMaxParticleCount = particlesTshVal; + if (tsh.TryGetValue(GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey, out var speedTsh)) + { + var parsed = ParseTransitionSpeedMultiplier(speedTsh); + if (parsed.HasValue) + { + profile.TshGameWindowTransitionSpeedMultiplier = parsed.Value; + } + } + } - // TSH settings (that exist in GeneralsOnlineSettings via inheritance) - profile.TshArchiveReplays = settings.ArchiveReplays; - profile.TshMoneyTransactionVolume = settings.MoneyTransactionVolume; - profile.TshShowMoneyPerMinute = settings.ShowMoneyPerMinute; - profile.TshPlayerObserverEnabled = settings.PlayerObserverEnabled; - profile.TshSystemTimeFontSize = settings.SystemTimeFontSize; - profile.TshNetworkLatencyFontSize = settings.NetworkLatencyFontSize; - profile.TshRenderFpsFontSize = settings.RenderFpsFontSize; - profile.TshResolutionFontAdjustment = settings.ResolutionFontAdjustment; - profile.TshCursorCaptureEnabledInFullscreenGame = settings.CursorCaptureEnabledInFullscreenGame; - profile.TshCursorCaptureEnabledInFullscreenMenu = settings.CursorCaptureEnabledInFullscreenMenu; - profile.TshCursorCaptureEnabledInWindowedGame = settings.CursorCaptureEnabledInWindowedGame; - profile.TshCursorCaptureEnabledInWindowedMenu = settings.CursorCaptureEnabledInWindowedMenu; - profile.TshScreenEdgeScrollEnabledInFullscreenApp = settings.ScreenEdgeScrollEnabledInFullscreenApp; - profile.TshScreenEdgeScrollEnabledInWindowedApp = settings.ScreenEdgeScrollEnabledInWindowedApp; + private static void ApplyAudioFromOptions(IniOptions options, GameProfile profile) + { + profile.AudioSoundVolume = options.Audio.SFXVolume; + profile.AudioThreeDSoundVolume = options.Audio.SFX3DVolume; + profile.AudioSpeechVolume = options.Audio.VoiceVolume; + profile.AudioMusicVolume = options.Audio.MusicVolume; + profile.AudioEnabled = options.Audio.AudioEnabled; + profile.AudioNumSounds = options.Audio.NumSounds; } - /// - /// Applies settings from a GameProfile to a GeneralsOnlineSettings object. - /// Used by GameLauncher to prepare settings.json for launch. - /// - /// The GameProfile source. - /// The GeneralsOnlineSettings to populate. - public static void ApplyToGeneralsOnlineSettings(GameProfile profile, GeneralsOnlineSettings settings) + private static void ApplyNetworkFromOptions(IniOptions options, GameProfile profile) { - // GeneralsOnline settings - use null-coalescing with model defaults - // This ensures predictable behavior: always set a value, never rely on constructor defaults - 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; + profile.GameSpyIPAddress = options.Network.GameSpyIPAddress; + } - // Camera settings - settings.Camera.MaxHeightOnlyWhenLobbyHost = profile.GoCameraMaxHeightOnlyWhenLobbyHost ?? 310.0f; - settings.Camera.MinHeight = profile.GoCameraMinHeight ?? 310.0f; - settings.Camera.MoveSpeedRatio = profile.GoCameraMoveSpeedRatio ?? 1.5f; + private static void ApplyGoGeneralSettings(GameProfile profile, GeneralsOnlineSettings settings) + { + 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; + } - // Chat settings - settings.Chat.DurationSecondsUntilFadeOut = profile.GoChatDurationSecondsUntilFadeOut ?? 30; + private static void ApplyGoCameraAndChatSettings(GameProfile profile, GeneralsOnlineSettings settings) + { + 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; + } - // Debug settings - settings.Debug.VerboseLogging = profile.GoDebugVerboseLogging ?? false; + private static void ApplyGoRenderAndDebugSettings(GameProfile profile, GeneralsOnlineSettings settings) + { + 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; + } - // Render settings - settings.Render.FpsLimit = profile.GoRenderFpsLimit ?? 144; - settings.Render.LimitFramerate = profile.GoRenderLimitFramerate ?? true; - settings.Render.StatsOverlay = profile.GoRenderStatsOverlay ?? true; + private static void ApplyGoSocialSettings(GameProfile profile, GeneralsOnlineSettings settings) + { + 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; + } - // Social notification 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; - - // TSH settings (that exist in settings.json) - use null-coalescing with defaults - 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; + private static void ApplyGoTshSettings(GameProfile profile, GeneralsOnlineSettings settings) + { + 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; + if (NormalizeTransitionSpeedMultiplier(profile.TshGameWindowTransitionSpeedMultiplier) is { } speedMult) + { + settings.GameWindowTransitionSpeedMultiplier = speedMult; + } } - /// - /// Applies profile settings to IniOptions with validation. - /// - /// The game profile containing the settings. - /// The IniOptions object to apply settings to. - /// Optional logger for validation warnings. - public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogger? logger = null) + private static void ApplyVideoResolutionAndQualityToOptions(GameProfile profile, IniOptions options, ILogger? logger) { - // Video settings with validation if (profile.VideoResolutionWidth.HasValue) { if (profile.VideoResolutionWidth.Value >= GameSettingsConstants.Resolution.MinWidth && @@ -274,8 +784,6 @@ public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogg if (profile.VideoTextureQuality.HasValue) { - // Engine Value (TextureReduction): 2=Low, 1=Medium, 0=High/Max - // Clamp anything higher than 'High' to Max Quality to prevent invalid values options.Video.TextureReduction = profile.VideoTextureQuality.Value switch { TextureQuality.Low => GameSettingsConstants.TextureQuality.TextureReductionLow, @@ -296,7 +804,10 @@ public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogg { options.Video.ExtraAnimations = profile.VideoExtraAnimations.Value; } + } + private static void ApplyVideoAdditionalToOptions(GameProfile profile, IniOptions options, ILogger? logger) + { if (profile.VideoGamma.HasValue) { if (profile.VideoGamma.Value >= GameSettingsConstants.Gamma.Min && @@ -318,22 +829,27 @@ public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogg if (profile.VideoAlternateMouseSetup.HasValue) { options.Video.AlternateMouseSetup = profile.VideoAlternateMouseSetup.Value; + options.Video.AdditionalProperties["UseAlternateMouse"] = profile.VideoAlternateMouseSetup.Value ? "yes" : "no"; } if (profile.VideoHeatEffects.HasValue) - { options.Video.HeatEffects = profile.VideoHeatEffects.Value; - } - // Additional video settings to AdditionalProperties (Standard root) + if (profile.VideoBuildingAnimations.HasValue) + options.Video.AdditionalProperties["GenHubBuildingAnimations"] = profile.VideoBuildingAnimations.Value ? "yes" : "no"; + + if (profile.VideoParticleEffects.HasValue) + options.Video.AdditionalProperties["GenHubParticleEffects"] = profile.VideoParticleEffects.Value ? "yes" : "no"; + if (profile.VideoStaticGameLOD != null) options.Video.AdditionalProperties["StaticGameLOD"] = profile.VideoStaticGameLOD; + if (profile.VideoIdealStaticGameLOD != null) options.Video.AdditionalProperties["IdealStaticGameLOD"] = profile.VideoIdealStaticGameLOD; + if (profile.VideoAntiAliasing.HasValue) options.Video.AntiAliasing = profile.VideoAntiAliasing.Value; - // TSH settings (writing to root for maximum compatibility as some clients prefer flat Options.ini) if (profile.VideoUseDoubleClickAttackMove.HasValue) { options.Video.AdditionalProperties["UseDoubleClickAttackMove"] = profile.VideoUseDoubleClickAttackMove.Value ? "yes" : "no"; @@ -342,20 +858,22 @@ public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogg if (profile.VideoScrollFactor.HasValue) options.Video.AdditionalProperties["ScrollFactor"] = profile.VideoScrollFactor.Value.ToString(); + if (profile.VideoRetaliation.HasValue) options.Video.AdditionalProperties["Retaliation"] = profile.VideoRetaliation.Value ? "yes" : "no"; + if (profile.VideoDynamicLOD.HasValue) options.Video.AdditionalProperties["DynamicLOD"] = profile.VideoDynamicLOD.Value ? "yes" : "no"; + if (profile.VideoMaxParticleCount.HasValue) options.Video.AdditionalProperties["MaxParticleCount"] = profile.VideoMaxParticleCount.Value.ToString(); + if (profile.VideoSkipEALogo.HasValue) options.Video.AdditionalProperties["SkipEALogo"] = profile.VideoSkipEALogo.Value ? "yes" : "no"; + } - // Mirror Alternate Mouse - if (profile.VideoAlternateMouseSetup.HasValue) - options.Video.AdditionalProperties["UseAlternateMouse"] = profile.VideoAlternateMouseSetup.Value ? "yes" : "no"; - - // Audio settings with validation + private static void ApplyAudioToOptions(GameProfile profile, IniOptions options, ILogger? logger) + { if (profile.AudioSoundVolume.HasValue) { if (profile.AudioSoundVolume.Value >= GameSettingsConstants.Volume.Min && @@ -450,214 +968,47 @@ public static void ApplyToOptions(GameProfile profile, IniOptions options, ILogg GameSettingsConstants.Audio.MaxNumSounds); } } + } - // TheSuperHackers settings + private static void ApplyTshToOptions(GameProfile profile, IniOptions options) + { var tshDict = new Dictionary(); - if (profile.TshArchiveReplays.HasValue) tshDict["ArchiveReplays"] = BoolToString(profile.TshArchiveReplays.Value); - if (profile.TshShowMoneyPerMinute.HasValue) tshDict["ShowMoneyPerMinute"] = BoolToString(profile.TshShowMoneyPerMinute.Value); - if (profile.TshPlayerObserverEnabled.HasValue) tshDict["PlayerObserverEnabled"] = BoolToString(profile.TshPlayerObserverEnabled.Value); - if (profile.TshSystemTimeFontSize.HasValue) tshDict["SystemTimeFontSize"] = profile.TshSystemTimeFontSize.Value.ToString(); - if (profile.TshNetworkLatencyFontSize.HasValue) tshDict["NetworkLatencyFontSize"] = profile.TshNetworkLatencyFontSize.Value.ToString(); - if (profile.TshRenderFpsFontSize.HasValue) tshDict["RenderFpsFontSize"] = profile.TshRenderFpsFontSize.Value.ToString(); - if (profile.TshResolutionFontAdjustment.HasValue) tshDict["ResolutionFontAdjustment"] = profile.TshResolutionFontAdjustment.Value.ToString(); - if (profile.TshCursorCaptureEnabledInFullscreenGame.HasValue) tshDict["CursorCaptureEnabledInFullscreenGame"] = BoolToString(profile.TshCursorCaptureEnabledInFullscreenGame.Value); - if (profile.TshCursorCaptureEnabledInFullscreenMenu.HasValue) tshDict["CursorCaptureEnabledInFullscreenMenu"] = BoolToString(profile.TshCursorCaptureEnabledInFullscreenMenu.Value); - if (profile.TshCursorCaptureEnabledInWindowedGame.HasValue) tshDict["CursorCaptureEnabledInWindowedGame"] = BoolToString(profile.TshCursorCaptureEnabledInWindowedGame.Value); - if (profile.TshCursorCaptureEnabledInWindowedMenu.HasValue) tshDict["CursorCaptureEnabledInWindowedMenu"] = BoolToString(profile.TshCursorCaptureEnabledInWindowedMenu.Value); - if (profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue) tshDict["ScreenEdgeScrollEnabledInFullscreenApp"] = BoolToString(profile.TshScreenEdgeScrollEnabledInFullscreenApp.Value); - if (profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue) tshDict["ScreenEdgeScrollEnabledInWindowedApp"] = BoolToString(profile.TshScreenEdgeScrollEnabledInWindowedApp.Value); - if (profile.TshMoneyTransactionVolume.HasValue) tshDict["MoneyTransactionVolume"] = profile.TshMoneyTransactionVolume.Value.ToString(); + ApplyTshUiSettingsToDict(profile, tshDict); + ApplyTshControlsSettingsToDict(profile, tshDict); if (tshDict.Count > 0) { - options.AdditionalSections["TheSuperHackers"] = tshDict; - } - } - - /// - /// Populates settings from a CreateProfileRequest into a GameProfile. - /// - /// The GameProfile to populate. - /// The request containing the settings. - public static void PopulateGameProfile(GameProfile profile, CreateProfileRequest request) - { - // Video settings - profile.VideoResolutionWidth = request.VideoResolutionWidth; - profile.VideoResolutionHeight = request.VideoResolutionHeight; - profile.VideoWindowed = request.VideoWindowed; - profile.VideoTextureQuality = request.VideoTextureQuality; - profile.EnableVideoShadows = request.EnableVideoShadows; - profile.VideoParticleEffects = request.VideoParticleEffects; - profile.VideoExtraAnimations = request.VideoExtraAnimations; - profile.VideoBuildingAnimations = request.VideoBuildingAnimations; - profile.VideoGamma = request.VideoGamma; - profile.VideoAlternateMouseSetup = request.VideoAlternateMouseSetup; - profile.VideoHeatEffects = request.VideoHeatEffects; - - // Audio settings - profile.AudioSoundVolume = request.AudioSoundVolume; - profile.AudioThreeDSoundVolume = request.AudioThreeDSoundVolume; - profile.AudioSpeechVolume = request.AudioSpeechVolume; - profile.AudioMusicVolume = request.AudioMusicVolume; - profile.AudioEnabled = request.AudioEnabled; - profile.AudioNumSounds = request.AudioNumSounds; - - // TheSuperHackers settings - profile.TshArchiveReplays = request.TshArchiveReplays; - profile.TshShowMoneyPerMinute = request.TshShowMoneyPerMinute; - profile.TshPlayerObserverEnabled = request.TshPlayerObserverEnabled; - profile.TshSystemTimeFontSize = request.TshSystemTimeFontSize; - profile.TshNetworkLatencyFontSize = request.TshNetworkLatencyFontSize; - profile.TshRenderFpsFontSize = request.TshRenderFpsFontSize; - profile.TshResolutionFontAdjustment = request.TshResolutionFontAdjustment; - profile.TshCursorCaptureEnabledInFullscreenGame = request.TshCursorCaptureEnabledInFullscreenGame; - profile.TshCursorCaptureEnabledInFullscreenMenu = request.TshCursorCaptureEnabledInFullscreenMenu; - profile.TshCursorCaptureEnabledInWindowedGame = request.TshCursorCaptureEnabledInWindowedGame; - profile.TshCursorCaptureEnabledInWindowedMenu = request.TshCursorCaptureEnabledInWindowedMenu; - profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp; - profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp; - profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume; - - // GeneralsOnline settings - profile.GoShowFps = request.GoShowFps; - profile.GoShowPing = request.GoShowPing; - profile.GoShowPlayerRanks = request.GoShowPlayerRanks; - profile.GoAutoLogin = request.GoAutoLogin; - profile.GoRememberUsername = request.GoRememberUsername; - profile.GoEnableNotifications = request.GoEnableNotifications; - profile.GoEnableSoundNotifications = request.GoEnableSoundNotifications; - profile.GoChatFontSize = request.GoChatFontSize; - - // Camera settings - profile.GoCameraMaxHeightOnlyWhenLobbyHost = request.GoCameraMaxHeightOnlyWhenLobbyHost; - profile.GoCameraMinHeight = request.GoCameraMinHeight; - profile.GoCameraMoveSpeedRatio = request.GoCameraMoveSpeedRatio; - - // Chat settings - profile.GoChatDurationSecondsUntilFadeOut = request.GoChatDurationSecondsUntilFadeOut; - - // Debug settings - profile.GoDebugVerboseLogging = request.GoDebugVerboseLogging; - - // Render settings - profile.GoRenderFpsLimit = request.GoRenderFpsLimit; - profile.GoRenderLimitFramerate = request.GoRenderLimitFramerate; - profile.GoRenderStatsOverlay = request.GoRenderStatsOverlay; - - // Social notification settings - profile.GoSocialNotificationFriendComesOnlineGameplay = request.GoSocialNotificationFriendComesOnlineGameplay; - profile.GoSocialNotificationFriendComesOnlineMenus = request.GoSocialNotificationFriendComesOnlineMenus; - profile.GoSocialNotificationFriendGoesOfflineGameplay = request.GoSocialNotificationFriendGoesOfflineGameplay; - profile.GoSocialNotificationFriendGoesOfflineMenus = request.GoSocialNotificationFriendGoesOfflineMenus; - profile.GoSocialNotificationPlayerAcceptsRequestGameplay = request.GoSocialNotificationPlayerAcceptsRequestGameplay; - profile.GoSocialNotificationPlayerAcceptsRequestMenus = request.GoSocialNotificationPlayerAcceptsRequestMenus; - profile.GoSocialNotificationPlayerSendsRequestGameplay = request.GoSocialNotificationPlayerSendsRequestGameplay; - profile.GoSocialNotificationPlayerSendsRequestMenus = request.GoSocialNotificationPlayerSendsRequestMenus; - - profile.GameSpyIPAddress = request.GameSpyIPAddress; - profile.VideoSkipEALogo = request.VideoSkipEALogo; - } - - /// - /// Populates settings from an UpdateProfileRequest into a GameProfile. - /// - /// The GameProfile to populate. - /// The request containing the settings. - public static void PopulateGameProfile(GameProfile profile, UpdateProfileRequest request) - { - // Video settings - profile.VideoResolutionWidth = request.VideoResolutionWidth; - profile.VideoResolutionHeight = request.VideoResolutionHeight; - profile.VideoWindowed = request.VideoWindowed; - profile.VideoTextureQuality = request.VideoTextureQuality; - profile.EnableVideoShadows = request.EnableVideoShadows; - profile.VideoParticleEffects = request.VideoParticleEffects; - profile.VideoExtraAnimations = request.VideoExtraAnimations; - profile.VideoBuildingAnimations = request.VideoBuildingAnimations; - profile.VideoGamma = request.VideoGamma; - profile.VideoAlternateMouseSetup = request.VideoAlternateMouseSetup; - profile.VideoHeatEffects = request.VideoHeatEffects; - profile.VideoStaticGameLOD = request.VideoStaticGameLOD; - profile.VideoIdealStaticGameLOD = request.VideoIdealStaticGameLOD; - profile.VideoUseDoubleClickAttackMove = request.VideoUseDoubleClickAttackMove; - profile.VideoScrollFactor = request.VideoScrollFactor; - profile.VideoRetaliation = request.VideoRetaliation; - profile.VideoDynamicLOD = request.VideoDynamicLOD; - profile.VideoMaxParticleCount = request.VideoMaxParticleCount; - profile.VideoAntiAliasing = request.VideoAntiAliasing; - profile.VideoUseLightMap = request.VideoUseLightMap; - profile.VideoSkipEALogo = request.VideoSkipEALogo; - - // Audio settings - profile.AudioSoundVolume = request.AudioSoundVolume; - profile.AudioThreeDSoundVolume = request.AudioThreeDSoundVolume; - profile.AudioSpeechVolume = request.AudioSpeechVolume; - profile.AudioMusicVolume = request.AudioMusicVolume; - profile.AudioEnabled = request.AudioEnabled; - profile.AudioNumSounds = request.AudioNumSounds; - - // TheSuperHackers settings - profile.TshArchiveReplays = request.TshArchiveReplays; - profile.TshShowMoneyPerMinute = request.TshShowMoneyPerMinute; - profile.TshPlayerObserverEnabled = request.TshPlayerObserverEnabled; - profile.TshSystemTimeFontSize = request.TshSystemTimeFontSize; - profile.TshNetworkLatencyFontSize = request.TshNetworkLatencyFontSize; - profile.TshRenderFpsFontSize = request.TshRenderFpsFontSize; - profile.TshResolutionFontAdjustment = request.TshResolutionFontAdjustment; - profile.TshCursorCaptureEnabledInFullscreenGame = request.TshCursorCaptureEnabledInFullscreenGame; - profile.TshCursorCaptureEnabledInFullscreenMenu = request.TshCursorCaptureEnabledInFullscreenMenu; - profile.TshCursorCaptureEnabledInWindowedGame = request.TshCursorCaptureEnabledInWindowedGame; - profile.TshCursorCaptureEnabledInWindowedMenu = request.TshCursorCaptureEnabledInWindowedMenu; - profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp; - profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp; - profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume; - - // GeneralsOnline settings - profile.GoShowFps = request.GoShowFps; - profile.GoShowPing = request.GoShowPing; - profile.GoShowPlayerRanks = request.GoShowPlayerRanks; - profile.GoAutoLogin = request.GoAutoLogin; - profile.GoRememberUsername = request.GoRememberUsername; - profile.GoEnableNotifications = request.GoEnableNotifications; - profile.GoEnableSoundNotifications = request.GoEnableSoundNotifications; - profile.GoChatFontSize = request.GoChatFontSize; - - // Camera settings - profile.GoCameraMaxHeightOnlyWhenLobbyHost = request.GoCameraMaxHeightOnlyWhenLobbyHost; - profile.GoCameraMinHeight = request.GoCameraMinHeight; - profile.GoCameraMoveSpeedRatio = request.GoCameraMoveSpeedRatio; - - // Chat settings - profile.GoChatDurationSecondsUntilFadeOut = request.GoChatDurationSecondsUntilFadeOut; - - // Debug settings - profile.GoDebugVerboseLogging = request.GoDebugVerboseLogging; - - // Render settings - profile.GoRenderFpsLimit = request.GoRenderFpsLimit; - profile.GoRenderLimitFramerate = request.GoRenderLimitFramerate; - profile.GoRenderStatsOverlay = request.GoRenderStatsOverlay; + options.AdditionalSections["TheSuperHackers"] = tshDict; + } + } - // Social notification settings - profile.GoSocialNotificationFriendComesOnlineGameplay = request.GoSocialNotificationFriendComesOnlineGameplay; - profile.GoSocialNotificationFriendComesOnlineMenus = request.GoSocialNotificationFriendComesOnlineMenus; - profile.GoSocialNotificationFriendGoesOfflineGameplay = request.GoSocialNotificationFriendGoesOfflineGameplay; - profile.GoSocialNotificationFriendGoesOfflineMenus = request.GoSocialNotificationFriendGoesOfflineMenus; - profile.GoSocialNotificationPlayerAcceptsRequestGameplay = request.GoSocialNotificationPlayerAcceptsRequestGameplay; - profile.GoSocialNotificationPlayerAcceptsRequestMenus = request.GoSocialNotificationPlayerAcceptsRequestMenus; - profile.GoSocialNotificationPlayerSendsRequestGameplay = request.GoSocialNotificationPlayerSendsRequestGameplay; - profile.GoSocialNotificationPlayerSendsRequestMenus = request.GoSocialNotificationPlayerSendsRequestMenus; + private static void ApplyTshUiSettingsToDict(GameProfile profile, Dictionary tshDict) + { + if (profile.TshArchiveReplays.HasValue) tshDict["ArchiveReplays"] = BoolToString(profile.TshArchiveReplays.Value); + if (profile.TshShowMoneyPerMinute.HasValue) tshDict["ShowMoneyPerMinute"] = BoolToString(profile.TshShowMoneyPerMinute.Value); + if (profile.TshPlayerObserverEnabled.HasValue) tshDict["PlayerObserverEnabled"] = BoolToString(profile.TshPlayerObserverEnabled.Value); + if (profile.TshSystemTimeFontSize.HasValue) tshDict["SystemTimeFontSize"] = profile.TshSystemTimeFontSize.Value.ToString(); + if (profile.TshNetworkLatencyFontSize.HasValue) tshDict["NetworkLatencyFontSize"] = profile.TshNetworkLatencyFontSize.Value.ToString(); + if (profile.TshRenderFpsFontSize.HasValue) tshDict["RenderFpsFontSize"] = profile.TshRenderFpsFontSize.Value.ToString(); + if (profile.TshResolutionFontAdjustment.HasValue) tshDict["ResolutionFontAdjustment"] = profile.TshResolutionFontAdjustment.Value.ToString(); + } - profile.GameSpyIPAddress = request.GameSpyIPAddress; - profile.VideoSkipEALogo = request.VideoSkipEALogo; + private static void ApplyTshControlsSettingsToDict(GameProfile profile, Dictionary tshDict) + { + if (profile.TshCursorCaptureEnabledInFullscreenGame.HasValue) tshDict["CursorCaptureEnabledInFullscreenGame"] = BoolToString(profile.TshCursorCaptureEnabledInFullscreenGame.Value); + if (profile.TshCursorCaptureEnabledInFullscreenMenu.HasValue) tshDict["CursorCaptureEnabledInFullscreenMenu"] = BoolToString(profile.TshCursorCaptureEnabledInFullscreenMenu.Value); + if (profile.TshCursorCaptureEnabledInWindowedGame.HasValue) tshDict["CursorCaptureEnabledInWindowedGame"] = BoolToString(profile.TshCursorCaptureEnabledInWindowedGame.Value); + if (profile.TshCursorCaptureEnabledInWindowedMenu.HasValue) tshDict["CursorCaptureEnabledInWindowedMenu"] = BoolToString(profile.TshCursorCaptureEnabledInWindowedMenu.Value); + if (profile.TshScreenEdgeScrollEnabledInFullscreenApp.HasValue) tshDict["ScreenEdgeScrollEnabledInFullscreenApp"] = BoolToString(profile.TshScreenEdgeScrollEnabledInFullscreenApp.Value); + if (profile.TshScreenEdgeScrollEnabledInWindowedApp.HasValue) tshDict["ScreenEdgeScrollEnabledInWindowedApp"] = BoolToString(profile.TshScreenEdgeScrollEnabledInWindowedApp.Value); + if (profile.TshMoneyTransactionVolume.HasValue) tshDict["MoneyTransactionVolume"] = profile.TshMoneyTransactionVolume.Value.ToString(); + if (NormalizeTransitionSpeedMultiplier(profile.TshGameWindowTransitionSpeedMultiplier) is { } speedMultiplier) + { + tshDict[GameSettingsTheSuperHackersConstants.GameWindowTransitionSpeedMultiplierKey] = speedMultiplier.ToString(CultureInfo.InvariantCulture); + } } - /// - /// Patches a GameProfile with non-null values from a CreateProfileRequest. - /// - /// The GameProfile to patch. - /// The request containing potentially partial settings. - public static void PatchGameProfile(GameProfile profile, CreateProfileRequest request) + private static void PatchVideoSettings(GameProfile profile, CreateProfileRequest request) { profile.VideoResolutionWidth = request.VideoResolutionWidth ?? profile.VideoResolutionWidth; profile.VideoResolutionHeight = request.VideoResolutionHeight ?? profile.VideoResolutionHeight; @@ -670,14 +1021,20 @@ public static void PatchGameProfile(GameProfile profile, CreateProfileRequest re profile.VideoGamma = request.VideoGamma ?? profile.VideoGamma; profile.VideoAlternateMouseSetup = request.VideoAlternateMouseSetup ?? profile.VideoAlternateMouseSetup; profile.VideoHeatEffects = request.VideoHeatEffects ?? profile.VideoHeatEffects; + } + private static void PatchAudioSettings(GameProfile profile, CreateProfileRequest request) + { profile.AudioSoundVolume = request.AudioSoundVolume ?? profile.AudioSoundVolume; profile.AudioThreeDSoundVolume = request.AudioThreeDSoundVolume ?? profile.AudioThreeDSoundVolume; profile.AudioSpeechVolume = request.AudioSpeechVolume ?? profile.AudioSpeechVolume; profile.AudioMusicVolume = request.AudioMusicVolume ?? profile.AudioMusicVolume; profile.AudioEnabled = request.AudioEnabled ?? profile.AudioEnabled; profile.AudioNumSounds = request.AudioNumSounds ?? profile.AudioNumSounds; + } + private static void PatchTshSettings(GameProfile profile, CreateProfileRequest request) + { profile.TshArchiveReplays = request.TshArchiveReplays ?? profile.TshArchiveReplays; profile.TshShowMoneyPerMinute = request.TshShowMoneyPerMinute ?? profile.TshShowMoneyPerMinute; profile.TshPlayerObserverEnabled = request.TshPlayerObserverEnabled ?? profile.TshPlayerObserverEnabled; @@ -692,7 +1049,11 @@ public static void PatchGameProfile(GameProfile profile, CreateProfileRequest re profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp ?? profile.TshScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp ?? profile.TshScreenEdgeScrollEnabledInWindowedApp; profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume ?? profile.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier ?? profile.TshGameWindowTransitionSpeedMultiplier; + } + private static void PatchGeneralsOnlineSettings(GameProfile profile, CreateProfileRequest request) + { profile.GoShowFps = request.GoShowFps ?? profile.GoShowFps; profile.GoShowPing = request.GoShowPing ?? profile.GoShowPing; profile.GoShowPlayerRanks = request.GoShowPlayerRanks ?? profile.GoShowPlayerRanks; @@ -705,9 +1066,7 @@ public static void PatchGameProfile(GameProfile profile, CreateProfileRequest re profile.GoCameraMaxHeightOnlyWhenLobbyHost = request.GoCameraMaxHeightOnlyWhenLobbyHost ?? profile.GoCameraMaxHeightOnlyWhenLobbyHost; profile.GoCameraMinHeight = request.GoCameraMinHeight ?? profile.GoCameraMinHeight; profile.GoCameraMoveSpeedRatio = request.GoCameraMoveSpeedRatio ?? profile.GoCameraMoveSpeedRatio; - profile.GoChatDurationSecondsUntilFadeOut = request.GoChatDurationSecondsUntilFadeOut ?? profile.GoChatDurationSecondsUntilFadeOut; - profile.GoDebugVerboseLogging = request.GoDebugVerboseLogging ?? profile.GoDebugVerboseLogging; profile.GoRenderFpsLimit = request.GoRenderFpsLimit ?? profile.GoRenderFpsLimit; @@ -722,17 +1081,9 @@ public static void PatchGameProfile(GameProfile profile, CreateProfileRequest re profile.GoSocialNotificationPlayerAcceptsRequestMenus = request.GoSocialNotificationPlayerAcceptsRequestMenus ?? profile.GoSocialNotificationPlayerAcceptsRequestMenus; profile.GoSocialNotificationPlayerSendsRequestGameplay = request.GoSocialNotificationPlayerSendsRequestGameplay ?? profile.GoSocialNotificationPlayerSendsRequestGameplay; profile.GoSocialNotificationPlayerSendsRequestMenus = request.GoSocialNotificationPlayerSendsRequestMenus ?? profile.GoSocialNotificationPlayerSendsRequestMenus; - - profile.GameSpyIPAddress = request.GameSpyIPAddress ?? profile.GameSpyIPAddress; - profile.VideoSkipEALogo = request.VideoSkipEALogo ?? profile.VideoSkipEALogo; } - /// - /// Patches a GameProfile with non-null values from an UpdateProfileRequest. - /// - /// The GameProfile to patch. - /// The request containing potentially partial settings. - public static void UpdateFromRequest(GameProfile profile, UpdateProfileRequest request) + private static void UpdateVideoFromRequest(GameProfile profile, UpdateProfileRequest request) { profile.VideoResolutionWidth = request.VideoResolutionWidth ?? profile.VideoResolutionWidth; profile.VideoResolutionHeight = request.VideoResolutionHeight ?? profile.VideoResolutionHeight; @@ -753,14 +1104,20 @@ public static void UpdateFromRequest(GameProfile profile, UpdateProfileRequest r profile.VideoDynamicLOD = request.VideoDynamicLOD ?? profile.VideoDynamicLOD; profile.VideoMaxParticleCount = request.VideoMaxParticleCount ?? profile.VideoMaxParticleCount; profile.VideoAntiAliasing = request.VideoAntiAliasing ?? profile.VideoAntiAliasing; + } + private static void UpdateAudioFromRequest(GameProfile profile, UpdateProfileRequest request) + { profile.AudioSoundVolume = request.AudioSoundVolume ?? profile.AudioSoundVolume; profile.AudioThreeDSoundVolume = request.AudioThreeDSoundVolume ?? profile.AudioThreeDSoundVolume; profile.AudioSpeechVolume = request.AudioSpeechVolume ?? profile.AudioSpeechVolume; profile.AudioMusicVolume = request.AudioMusicVolume ?? profile.AudioMusicVolume; profile.AudioEnabled = request.AudioEnabled ?? profile.AudioEnabled; profile.AudioNumSounds = request.AudioNumSounds ?? profile.AudioNumSounds; + } + private static void UpdateTshFromRequest(GameProfile profile, UpdateProfileRequest request) + { profile.TshArchiveReplays = request.TshArchiveReplays ?? profile.TshArchiveReplays; profile.TshShowMoneyPerMinute = request.TshShowMoneyPerMinute ?? profile.TshShowMoneyPerMinute; profile.TshPlayerObserverEnabled = request.TshPlayerObserverEnabled ?? profile.TshPlayerObserverEnabled; @@ -775,7 +1132,11 @@ public static void UpdateFromRequest(GameProfile profile, UpdateProfileRequest r profile.TshScreenEdgeScrollEnabledInFullscreenApp = request.TshScreenEdgeScrollEnabledInFullscreenApp ?? profile.TshScreenEdgeScrollEnabledInFullscreenApp; profile.TshScreenEdgeScrollEnabledInWindowedApp = request.TshScreenEdgeScrollEnabledInWindowedApp ?? profile.TshScreenEdgeScrollEnabledInWindowedApp; profile.TshMoneyTransactionVolume = request.TshMoneyTransactionVolume ?? profile.TshMoneyTransactionVolume; + profile.TshGameWindowTransitionSpeedMultiplier = request.TshGameWindowTransitionSpeedMultiplier ?? profile.TshGameWindowTransitionSpeedMultiplier; + } + private static void UpdateGeneralsOnlineFromRequest(GameProfile profile, UpdateProfileRequest request) + { profile.GoShowFps = request.GoShowFps ?? profile.GoShowFps; profile.GoShowPing = request.GoShowPing ?? profile.GoShowPing; profile.GoShowPlayerRanks = request.GoShowPlayerRanks ?? profile.GoShowPlayerRanks; @@ -788,9 +1149,7 @@ public static void UpdateFromRequest(GameProfile profile, UpdateProfileRequest r profile.GoCameraMaxHeightOnlyWhenLobbyHost = request.GoCameraMaxHeightOnlyWhenLobbyHost ?? profile.GoCameraMaxHeightOnlyWhenLobbyHost; profile.GoCameraMinHeight = request.GoCameraMinHeight ?? profile.GoCameraMinHeight; profile.GoCameraMoveSpeedRatio = request.GoCameraMoveSpeedRatio ?? profile.GoCameraMoveSpeedRatio; - profile.GoChatDurationSecondsUntilFadeOut = request.GoChatDurationSecondsUntilFadeOut ?? profile.GoChatDurationSecondsUntilFadeOut; - profile.GoDebugVerboseLogging = request.GoDebugVerboseLogging ?? profile.GoDebugVerboseLogging; profile.GoRenderFpsLimit = request.GoRenderFpsLimit ?? profile.GoRenderFpsLimit; @@ -805,198 +1164,6 @@ public static void UpdateFromRequest(GameProfile profile, UpdateProfileRequest r profile.GoSocialNotificationPlayerAcceptsRequestMenus = request.GoSocialNotificationPlayerAcceptsRequestMenus ?? profile.GoSocialNotificationPlayerAcceptsRequestMenus; profile.GoSocialNotificationPlayerSendsRequestGameplay = request.GoSocialNotificationPlayerSendsRequestGameplay ?? profile.GoSocialNotificationPlayerSendsRequestGameplay; profile.GoSocialNotificationPlayerSendsRequestMenus = request.GoSocialNotificationPlayerSendsRequestMenus ?? profile.GoSocialNotificationPlayerSendsRequestMenus; - - if (request.UseSteamLaunch.HasValue) - profile.UseSteamLaunch = request.UseSteamLaunch.Value; - - profile.GameSpyIPAddress = request.GameSpyIPAddress ?? profile.GameSpyIPAddress; - profile.VideoSkipEALogo = request.VideoSkipEALogo ?? profile.VideoSkipEALogo; - } - - /// - /// Populates settings from one UpdateProfileRequest into a CreateProfileRequest. - /// - /// The target CreateProfileRequest. - /// The source UpdateProfileRequest. - public static void PopulateRequest(CreateProfileRequest target, UpdateProfileRequest source) - { - target.VideoResolutionWidth = source.VideoResolutionWidth; - target.VideoResolutionHeight = source.VideoResolutionHeight; - target.VideoWindowed = source.VideoWindowed; - target.VideoTextureQuality = source.VideoTextureQuality; - target.EnableVideoShadows = source.EnableVideoShadows; - target.VideoParticleEffects = source.VideoParticleEffects; - target.VideoExtraAnimations = source.VideoExtraAnimations; - target.VideoBuildingAnimations = source.VideoBuildingAnimations; - target.VideoGamma = source.VideoGamma; - target.VideoAlternateMouseSetup = source.VideoAlternateMouseSetup; - target.VideoHeatEffects = source.VideoHeatEffects; - target.VideoStaticGameLOD = source.VideoStaticGameLOD; - target.VideoIdealStaticGameLOD = source.VideoIdealStaticGameLOD; - target.VideoUseDoubleClickAttackMove = source.VideoUseDoubleClickAttackMove; - target.VideoScrollFactor = source.VideoScrollFactor; - target.VideoRetaliation = source.VideoRetaliation; - target.VideoDynamicLOD = source.VideoDynamicLOD; - target.VideoMaxParticleCount = source.VideoMaxParticleCount; - target.VideoAntiAliasing = source.VideoAntiAliasing; - target.VideoDrawScrollAnchor = source.VideoDrawScrollAnchor; - target.VideoMoveScrollAnchor = source.VideoMoveScrollAnchor; - target.VideoGameTimeFontSize = source.VideoGameTimeFontSize; - target.GameLanguageFilter = source.GameLanguageFilter; - target.NetworkSendDelay = source.NetworkSendDelay; - target.VideoShowSoftWaterEdge = source.VideoShowSoftWaterEdge; - target.VideoShowTrees = source.VideoShowTrees; - target.VideoUseCloudMap = source.VideoUseCloudMap; - target.VideoUseLightMap = source.VideoUseLightMap; - target.VideoSkipEALogo = source.VideoSkipEALogo; - - target.AudioSoundVolume = source.AudioSoundVolume; - target.AudioThreeDSoundVolume = source.AudioThreeDSoundVolume; - target.AudioSpeechVolume = source.AudioSpeechVolume; - target.AudioMusicVolume = source.AudioMusicVolume; - target.AudioEnabled = source.AudioEnabled; - target.AudioNumSounds = source.AudioNumSounds; - - target.TshArchiveReplays = source.TshArchiveReplays; - target.TshShowMoneyPerMinute = source.TshShowMoneyPerMinute; - target.TshPlayerObserverEnabled = source.TshPlayerObserverEnabled; - target.TshSystemTimeFontSize = source.TshSystemTimeFontSize; - target.TshNetworkLatencyFontSize = source.TshNetworkLatencyFontSize; - target.TshRenderFpsFontSize = source.TshRenderFpsFontSize; - target.TshResolutionFontAdjustment = source.TshResolutionFontAdjustment; - target.TshCursorCaptureEnabledInFullscreenGame = source.TshCursorCaptureEnabledInFullscreenGame; - target.TshCursorCaptureEnabledInFullscreenMenu = source.TshCursorCaptureEnabledInFullscreenMenu; - target.TshCursorCaptureEnabledInWindowedGame = source.TshCursorCaptureEnabledInWindowedGame; - target.TshCursorCaptureEnabledInWindowedMenu = source.TshCursorCaptureEnabledInWindowedMenu; - target.TshScreenEdgeScrollEnabledInFullscreenApp = source.TshScreenEdgeScrollEnabledInFullscreenApp; - target.TshScreenEdgeScrollEnabledInWindowedApp = source.TshScreenEdgeScrollEnabledInWindowedApp; - target.TshMoneyTransactionVolume = source.TshMoneyTransactionVolume; - - target.GoShowFps = source.GoShowFps; - target.GoShowPing = source.GoShowPing; - target.GoShowPlayerRanks = source.GoShowPlayerRanks; - target.GoAutoLogin = source.GoAutoLogin; - target.GoRememberUsername = source.GoRememberUsername; - target.GoEnableNotifications = source.GoEnableNotifications; - target.GoEnableSoundNotifications = source.GoEnableSoundNotifications; - target.GoChatFontSize = source.GoChatFontSize; - - target.GoCameraMaxHeightOnlyWhenLobbyHost = source.GoCameraMaxHeightOnlyWhenLobbyHost; - target.GoCameraMinHeight = source.GoCameraMinHeight; - target.GoCameraMoveSpeedRatio = source.GoCameraMoveSpeedRatio; - - target.GoChatDurationSecondsUntilFadeOut = source.GoChatDurationSecondsUntilFadeOut; - - target.GoDebugVerboseLogging = source.GoDebugVerboseLogging; - - target.GoRenderFpsLimit = source.GoRenderFpsLimit; - target.GoRenderLimitFramerate = source.GoRenderLimitFramerate; - target.GoRenderStatsOverlay = source.GoRenderStatsOverlay; - - target.GoSocialNotificationFriendComesOnlineGameplay = source.GoSocialNotificationFriendComesOnlineGameplay; - target.GoSocialNotificationFriendComesOnlineMenus = source.GoSocialNotificationFriendComesOnlineMenus; - target.GoSocialNotificationFriendGoesOfflineGameplay = source.GoSocialNotificationFriendGoesOfflineGameplay; - target.GoSocialNotificationFriendGoesOfflineMenus = source.GoSocialNotificationFriendGoesOfflineMenus; - target.GoSocialNotificationPlayerAcceptsRequestGameplay = source.GoSocialNotificationPlayerAcceptsRequestGameplay; - target.GoSocialNotificationPlayerAcceptsRequestMenus = source.GoSocialNotificationPlayerAcceptsRequestMenus; - target.GoSocialNotificationPlayerSendsRequestGameplay = source.GoSocialNotificationPlayerSendsRequestGameplay; - target.GoSocialNotificationPlayerSendsRequestMenus = source.GoSocialNotificationPlayerSendsRequestMenus; - - target.GameSpyIPAddress = source.GameSpyIPAddress; - } - - /// - /// Populates settings from one UpdateProfileRequest into another. - /// - /// The target UpdateProfileRequest. - /// The source UpdateProfileRequest. - public static void PopulateRequest(UpdateProfileRequest target, UpdateProfileRequest source) - { - target.VideoResolutionWidth = source.VideoResolutionWidth; - target.VideoResolutionHeight = source.VideoResolutionHeight; - target.VideoWindowed = source.VideoWindowed; - target.VideoTextureQuality = source.VideoTextureQuality; - target.EnableVideoShadows = source.EnableVideoShadows; - target.VideoParticleEffects = source.VideoParticleEffects; - target.VideoExtraAnimations = source.VideoExtraAnimations; - target.VideoBuildingAnimations = source.VideoBuildingAnimations; - target.VideoGamma = source.VideoGamma; - target.VideoAlternateMouseSetup = source.VideoAlternateMouseSetup; - target.VideoHeatEffects = source.VideoHeatEffects; - target.VideoStaticGameLOD = source.VideoStaticGameLOD; - target.VideoIdealStaticGameLOD = source.VideoIdealStaticGameLOD; - target.VideoUseDoubleClickAttackMove = source.VideoUseDoubleClickAttackMove; - target.VideoScrollFactor = source.VideoScrollFactor; - target.VideoRetaliation = source.VideoRetaliation; - target.VideoDynamicLOD = source.VideoDynamicLOD; - target.VideoMaxParticleCount = source.VideoMaxParticleCount; - target.VideoAntiAliasing = source.VideoAntiAliasing; - target.VideoDrawScrollAnchor = source.VideoDrawScrollAnchor; - target.VideoMoveScrollAnchor = source.VideoMoveScrollAnchor; - target.VideoGameTimeFontSize = source.VideoGameTimeFontSize; - target.GameLanguageFilter = source.GameLanguageFilter; - target.NetworkSendDelay = source.NetworkSendDelay; - target.VideoShowSoftWaterEdge = source.VideoShowSoftWaterEdge; - target.VideoShowTrees = source.VideoShowTrees; - target.VideoUseCloudMap = source.VideoUseCloudMap; - target.VideoUseLightMap = source.VideoUseLightMap; - target.VideoSkipEALogo = source.VideoSkipEALogo; - - target.AudioSoundVolume = source.AudioSoundVolume; - target.AudioThreeDSoundVolume = source.AudioThreeDSoundVolume; - target.AudioSpeechVolume = source.AudioSpeechVolume; - target.AudioMusicVolume = source.AudioMusicVolume; - target.AudioEnabled = source.AudioEnabled; - target.AudioNumSounds = source.AudioNumSounds; - - target.TshArchiveReplays = source.TshArchiveReplays; - target.TshShowMoneyPerMinute = source.TshShowMoneyPerMinute; - target.TshPlayerObserverEnabled = source.TshPlayerObserverEnabled; - target.TshSystemTimeFontSize = source.TshSystemTimeFontSize; - target.TshNetworkLatencyFontSize = source.TshNetworkLatencyFontSize; - target.TshRenderFpsFontSize = source.TshRenderFpsFontSize; - target.TshResolutionFontAdjustment = source.TshResolutionFontAdjustment; - target.TshCursorCaptureEnabledInFullscreenGame = source.TshCursorCaptureEnabledInFullscreenGame; - target.TshCursorCaptureEnabledInFullscreenMenu = source.TshCursorCaptureEnabledInFullscreenMenu; - target.TshCursorCaptureEnabledInWindowedGame = source.TshCursorCaptureEnabledInWindowedGame; - target.TshCursorCaptureEnabledInWindowedMenu = source.TshCursorCaptureEnabledInWindowedMenu; - target.TshScreenEdgeScrollEnabledInFullscreenApp = source.TshScreenEdgeScrollEnabledInFullscreenApp; - target.TshScreenEdgeScrollEnabledInWindowedApp = source.TshScreenEdgeScrollEnabledInWindowedApp; - target.TshMoneyTransactionVolume = source.TshMoneyTransactionVolume; - - target.GoShowFps = source.GoShowFps; - target.GoShowPing = source.GoShowPing; - target.GoShowPlayerRanks = source.GoShowPlayerRanks; - target.GoAutoLogin = source.GoAutoLogin; - target.GoRememberUsername = source.GoRememberUsername; - target.GoEnableNotifications = source.GoEnableNotifications; - target.GoEnableSoundNotifications = source.GoEnableSoundNotifications; - target.GoChatFontSize = source.GoChatFontSize; - - target.GoCameraMaxHeightOnlyWhenLobbyHost = source.GoCameraMaxHeightOnlyWhenLobbyHost; - target.GoCameraMinHeight = source.GoCameraMinHeight; - target.GoCameraMoveSpeedRatio = source.GoCameraMoveSpeedRatio; - - target.GoChatDurationSecondsUntilFadeOut = source.GoChatDurationSecondsUntilFadeOut; - - target.GoDebugVerboseLogging = source.GoDebugVerboseLogging; - - target.GoRenderFpsLimit = source.GoRenderFpsLimit; - target.GoRenderLimitFramerate = source.GoRenderLimitFramerate; - target.GoRenderStatsOverlay = source.GoRenderStatsOverlay; - - target.GoSocialNotificationFriendComesOnlineGameplay = source.GoSocialNotificationFriendComesOnlineGameplay; - target.GoSocialNotificationFriendComesOnlineMenus = source.GoSocialNotificationFriendComesOnlineMenus; - target.GoSocialNotificationFriendGoesOfflineGameplay = source.GoSocialNotificationFriendGoesOfflineGameplay; - target.GoSocialNotificationFriendGoesOfflineMenus = source.GoSocialNotificationFriendGoesOfflineMenus; - target.GoSocialNotificationPlayerAcceptsRequestGameplay = source.GoSocialNotificationPlayerAcceptsRequestGameplay; - target.GoSocialNotificationPlayerAcceptsRequestMenus = source.GoSocialNotificationPlayerAcceptsRequestMenus; - target.GoSocialNotificationPlayerSendsRequestGameplay = source.GoSocialNotificationPlayerSendsRequestGameplay; - target.GoSocialNotificationPlayerSendsRequestMenus = source.GoSocialNotificationPlayerSendsRequestMenus; - - target.UseSteamLaunch = source.UseSteamLaunch; - target.GameSpyIPAddress = source.GameSpyIPAddress; - target.VideoSkipEALogo = source.VideoSkipEALogo; } private static bool ParseBool(string value) => diff --git a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs index 6f3945de5..8e92068ef 100644 --- a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs +++ b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs @@ -22,30 +22,36 @@ public static int ExtractVersionFromVersionString(string? version) return 0; } + // Try extracting an 8-digit date pattern first (e.g., "2025-11-07", "weekly-2025-11-21", "1.20260116") + var dateMatch = Regex.Match(version, @"\b(\d{4})[-_.]?(\d{2})[-_.]?(\d{2})\b", RegexOptions.None, TimeSpan.FromSeconds(1)); + if (dateMatch.Success && int.TryParse($"{dateMatch.Groups[1].Value}{dateMatch.Groups[2].Value}{dateMatch.Groups[3].Value}", NumberStyles.Integer, CultureInfo.InvariantCulture, out var dateVal)) + { + return dateVal; + } + // Extract all digits from the version string var digits = NonDigitRegex().Replace(version, string.Empty); + if (string.IsNullOrEmpty(digits)) + { + return 0; + } - // If it looks like a long date (YYYYMMDD) preceded by a segment (e.g. "1.20260116"), - // we might want the latter part if it's the date. - // But for now, let's just avoid the 8-digit truncation if it causes mangling - // and only truncate IF it would actually overflow int. - if (digits.Length > 9 && digits.StartsWith('0')) + digits = digits.TrimStart('0'); + if (digits.Length == 0) { - digits = digits.TrimStart('0'); + return 0; } if (digits.Length > 10) { - // int.MaxValue is ~2.1 billion (10 digits) + // int.MaxValue is 10 digits; truncate to 10 digits for legacy manifest ID compatibility digits = digits[..10]; } - if (long.TryParse(digits, out var longResult)) + if (long.TryParse(digits, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longResult)) { if (longResult > int.MaxValue) { - // If it's still too large for int, cap at int.MaxValue to prevent incorrect comparisons - // Most callers expect int. return int.MaxValue; } @@ -162,23 +168,19 @@ public static int GetGeneralsOnlineManifestIdComponent(string? version) || qfeDigits.Length == 0 || !qfeDigits.All(character => character is >= '0' and <= '9') || !int.TryParse(qfeDigits, NumberStyles.None, CultureInfo.InvariantCulture, out var qfe) - || !int.TryParse(datePart[0..2], NumberStyles.None, CultureInfo.InvariantCulture, out var month) - || !int.TryParse(datePart[2..4], NumberStyles.None, CultureInfo.InvariantCulture, out var day) - || !int.TryParse(datePart[4..6], NumberStyles.None, CultureInfo.InvariantCulture, out var twoDigitYear)) + || !DateOnly.TryParseExact(datePart, "MMddyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) { return ExtractVersionFromVersionString(version); } try { - _ = new DateTime(2000 + twoDigitYear, month, day); + var month = date.Month; + var day = date.Day; + var twoDigitYear = date.Year % 100; var mmddyy = (month * 10000) + (day * 100) + twoDigitYear; return checked((mmddyy * 10) + qfe); } - catch (ArgumentOutOfRangeException) - { - return ExtractVersionFromVersionString(version); - } catch (OverflowException) { return ExtractVersionFromVersionString(version); diff --git a/GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs b/GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs new file mode 100644 index 000000000..89c9d67c8 --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/LaunchEntryPointResolver.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using GenHub.Core.Constants; + +namespace GenHub.Core.Helpers; + +/// +/// Relates a launch entry point to the process that ends up owning the game session. +/// +public static class LaunchEntryPointResolver +{ + /// + /// Resolves the process that is expected to spawn and hand + /// the session to. + /// + /// The executable being launched. + /// + /// The expected child process name without extension, or when the + /// launched executable is itself the game. + /// + public static string? ResolveExpectedChildProcessName(string? executablePath) + { + if (string.IsNullOrEmpty(executablePath)) + { + return null; + } + + var fileName = Path.GetFileName(executablePath); + if (fileName.Equals(GameClientConstants.GeneralsOnlineEacLauncherExecutable, StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFileNameWithoutExtension(GameClientConstants.GeneralsOnline60HzExecutable); + } + + return null; + } +} diff --git a/GenHub/GenHub.Core/Helpers/PathHelper.cs b/GenHub/GenHub.Core/Helpers/PathHelper.cs index e249b1e1e..39dc9f38d 100644 --- a/GenHub/GenHub.Core/Helpers/PathHelper.cs +++ b/GenHub/GenHub.Core/Helpers/PathHelper.cs @@ -1,5 +1,12 @@ using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Linq; +using System.Security; +using GenHub.Core.Constants; namespace GenHub.Core.Helpers; @@ -8,6 +15,13 @@ namespace GenHub.Core.Helpers; /// public static class PathHelper { + private static readonly HashSet ReservedDeviceNames = new(StringComparer.OrdinalIgnoreCase) + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + }; + /// /// Gets the string comparison to use when comparing filesystem paths. Windows paths /// are compared case-insensitively; other platforms use conservative case-sensitive semantics. @@ -26,6 +40,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 +91,299 @@ 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 (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (SecurityException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (ArgumentException) + { + 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); + } + + /// + /// Generates a unique destination path in the directory, appending (1), (2), etc. if the file exists. + /// + /// The candidate destination path. + /// A unique non-colliding file path. + public static string GetUniqueNumberedPath(string destinationPath) + { + if (!File.Exists(destinationPath)) + { + return destinationPath; + } + + var dir = Path.GetDirectoryName(destinationPath) ?? string.Empty; + var nameOnly = Path.GetFileNameWithoutExtension(destinationPath); + var ext = Path.GetExtension(destinationPath); + int count = 1; + var current = destinationPath; + while (File.Exists(current)) + { + current = Path.Combine(dir, $"{nameOnly} ({count}){ext}"); + count++; + } + + return current; + } + + /// + /// Sanitizes a file name by removing invalid filesystem characters, trimming trailing dots and whitespace, and prefixing Windows reserved device names. + /// + /// The file name to sanitize. + /// The sanitized file name. + public static string SanitizeFileName(string fileName) + { + if (string.IsNullOrEmpty(fileName)) + { + return string.Empty; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = string.Concat(fileName.Where(c => !invalidChars.Contains(c))).Trim().TrimEnd('.'); + if (string.IsNullOrEmpty(sanitized)) + { + return string.Empty; + } + + var nameWithoutExtension = Path.GetFileNameWithoutExtension(sanitized); + if (ReservedDeviceNames.Contains(nameWithoutExtension)) + { + sanitized = $"_{sanitized}"; + } + + return sanitized; + } + + /// + /// Opens the native file explorer and selects the specified file or folder, or ignores if not supported. + /// + /// The absolute path to the file to reveal. + public static void RevealInExplorer(string filePath) + { + try + { + var startInfo = CreateRevealStartInfo(filePath); + if (startInfo != null) + { + Process.Start(startInfo); + } + } + catch (Win32Exception) + { + /* Ignore explorer errors */ + } + catch (IOException) + { + /* Ignore explorer errors */ + } + catch (UnauthorizedAccessException) + { + /* Ignore explorer errors */ + } + catch (InvalidOperationException) + { + /* Ignore explorer errors */ + } + } + + [SuppressMessage("Security", "S4036:Make sure the executable exists, and provide an absolute path or configure PATH securely", Justification = "Resolves standard desktop launch utilities (open, xdg-open) from PATH across heterogeneous Unix distributions.")] + private static ProcessStartInfo? CreateRevealStartInfo(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + return null; + } + + if (OperatingSystem.IsWindows()) + { + var info = new ProcessStartInfo + { + FileName = PlatformConstants.WindowsExplorerPath, + Arguments = string.Format(PlatformConstants.WindowsExplorerSelectArgument, filePath), + UseShellExecute = false, + }; + return info; + } + + if (OperatingSystem.IsMacOS()) + { + var info = new ProcessStartInfo + { + FileName = PlatformConstants.MacOSOpenExecutable, + UseShellExecute = false, + }; + info.ArgumentList.Add("-R"); + info.ArgumentList.Add(filePath); + return info; + } + + if (OperatingSystem.IsLinux()) + { + string? targetDir; + if (File.Exists(filePath)) + { + targetDir = Path.GetDirectoryName(filePath); + } + else if (Directory.Exists(filePath)) + { + targetDir = filePath; + } + else + { + targetDir = null; + } + + if (string.IsNullOrEmpty(targetDir)) + { + return null; + } + + var info = new ProcessStartInfo + { + FileName = PlatformConstants.LinuxXdgOpenExecutable, + UseShellExecute = false, + }; + info.ArgumentList.Add(targetDir); + return info; + } + + return null; + } + + 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/Helpers/SteamAppIdResolver.cs b/GenHub/GenHub.Core/Helpers/SteamAppIdResolver.cs index 2e030ae65..12f36495d 100644 --- a/GenHub/GenHub.Core/Helpers/SteamAppIdResolver.cs +++ b/GenHub/GenHub.Core/Helpers/SteamAppIdResolver.cs @@ -45,7 +45,7 @@ public static bool TryResolveSteamAppIdFromInstallationPath(string installationP return false; } - IEnumerable manifests; + IEnumerable manifests = []; try { manifests = Directory.EnumerateFiles(steamAppsDir.FullName, "appmanifest_*.acf", SearchOption.TopDirectoryOnly); @@ -57,7 +57,7 @@ public static bool TryResolveSteamAppIdFromInstallationPath(string installationP foreach (var manifestPath in manifests) { - string raw; + string raw = string.Empty; try { raw = File.ReadAllText(manifestPath); diff --git a/GenHub/GenHub.Core/Helpers/ToolUploadHelper.cs b/GenHub/GenHub.Core/Helpers/ToolUploadHelper.cs new file mode 100644 index 000000000..b4453602d --- /dev/null +++ b/GenHub/GenHub.Core/Helpers/ToolUploadHelper.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Models.Tools.ReplayManager; + +namespace GenHub.Core.Helpers; + +/// +/// Shared helper methods for upload workflows in tools (MapManager, ReplayManager). +/// +public static class ToolUploadHelper +{ + /// + /// Formats the upload stage message based on progress percentage and archive mode. + /// The percentage itself is rendered separately next to the progress bar. + /// + /// The entity name (e.g., "maps" or "replays"). + /// Whether the upload is a single zip file. + /// The completion percentage. + /// A formatted status string. + public static string FormatUploadStageMessage(string entityName, bool isZip, int percent) + { + if (!isZip && percent < ToolConstants.UploadStageCompressionThresholdPercent) + { + return $"Compressing {entityName}..."; + } + + if (percent < ToolConstants.UploadStageCloudThresholdPercent) + { + return "Uploading to cloud..."; + } + + if (percent < ToolConstants.UploadStageCompletePercent) + { + return "Finalizing cloud upload..."; + } + + return "Upload complete!"; + } + + /// + /// Formats the error message when upload rate limit is exceeded. + /// + /// Total bytes of file being uploaded. + /// Used bytes in period. + /// Total allowed limit bytes. + /// A human-readable error description. + public static string FormatUploadLimitExceededMessage(long totalSizeBytes, long usedBytes, long limitBytes) + { + var bytesPerMb = (double)ConversionConstants.BytesPerMegabyte; + var remainingMb = Math.Max(0, (limitBytes - usedBytes) / bytesPerMb); + var fileMb = totalSizeBytes / bytesPerMb; + var limitMb = limitBytes / bytesPerMb; + + return $"Upload limit exceeded. You have {remainingMb:F1} MB remaining of your {limitMb:F0} MB limit. This file requires {fileMb:F1} MB."; + } + + /// + /// Computes the lowercase SHA256 hex string of a file if it exists. + /// + /// The absolute path to the file. + /// Cancellation token. + /// The lowercase hex string if successful; otherwise . + public static async Task ComputeFileSha256Async(string filePath, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(filePath) || !File.Exists(filePath)) + { + return null; + } + + try + { + await using var stream = File.OpenRead(filePath); + var hashBytes = await System.Security.Cryptography.SHA256.HashDataAsync(stream, ct); + return Convert.ToHexString(hashBytes).ToLowerInvariant(); + } + catch (IOException) + { + return null; + } + catch (UnauthorizedAccessException) + { + return null; + } + } + + /// + /// Verifies if a share URL is accessible and returns HTTP success. + /// + /// The URL to test. + /// Cancellation token. + /// if the URL returns a success status code; otherwise . + public static async Task VerifyShareUrlAliveAsync(string url, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(url)) + { + return false; + } + + try + { + using var httpClient = new System.Net.Http.HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Head, url); + using var response = await httpClient.SendAsync(request, ct); + return response.IsSuccessStatusCode; + } + catch (System.Net.Http.HttpRequestException) + { + return false; + } + catch (IOException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + /// + /// Calculates the total size in bytes of a collection of map files including their directory assets. + /// + /// The map files. + /// Total size in bytes. + public static long CalculateMapsSize(IEnumerable maps) + { + long total = 0; + foreach (var map in maps) + { + total += GetMapFileSize(map); + } + + return total; + } + + /// + /// Calculates the total size in bytes of a collection of replay files. + /// + /// The replay files. + /// Total size in bytes. + public static long CalculateReplaysSize(IEnumerable replays) + { + long total = 0; + foreach (var replay in replays.Where(r => File.Exists(r.FullPath))) + { + try + { + total += new FileInfo(replay.FullPath).Length; + } + catch (IOException) + { + // Ignore missing or inaccessible files in size estimate + } + catch (UnauthorizedAccessException) + { + // Ignore missing or inaccessible files in size estimate + } + } + + return total; + } + + private static long GetMapFileSize(MapFile map) + { + long mapSize = 0; + try + { + if (File.Exists(map.FullPath)) + { + mapSize += new FileInfo(map.FullPath).Length; + } + + if (map.IsDirectory && map.AssetFiles != null) + { + foreach (var asset in map.AssetFiles.Where(File.Exists)) + { + mapSize += new FileInfo(asset).Length; + } + } + } + catch (IOException) + { + // Ignore missing or inaccessible files in size estimate + } + catch (UnauthorizedAccessException) + { + // Ignore missing or inaccessible files in size estimate + } + + return mapSize; + } +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs index 38b0ec736..f508f5197 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IAppConfiguration.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using Microsoft.Extensions.Logging; @@ -12,6 +13,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(); @@ -81,4 +86,10 @@ public interface IAppConfiguration /// Gets the maximum allowed download buffer size in bytes. /// The maximum buffer size in bytes. int GetMaxDownloadBufferSizeBytes(); + + /// + /// Gets the CSV catalog configuration. + /// + /// The CSV catalog configuration. + CsvCatalogConfiguration GetCsvCatalogConfiguration(); } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs index d97f2f8cf..b55bcd4c6 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Storage; @@ -53,45 +55,33 @@ public interface IConfigurationProviderService int GetDownloadBufferSize(); /// - /// Gets the effective default workspace strategy. + /// Gets the effective workspace strategy. /// - /// The default workspace strategy. + /// The effective workspace strategy. WorkspaceStrategy GetDefaultWorkspaceStrategy(); - /// - /// Gets whether to automatically check for updates on startup. - /// - /// True if auto-check is enabled; otherwise, false. - bool GetAutoCheckForUpdatesOnStartup(); - - /// - /// Gets whether detailed logging is enabled. - /// - /// True if detailed logging is enabled; otherwise, false. - bool GetEnableDetailedLogging(); - /// /// Gets the effective UI theme. /// - /// The theme string. + /// The effective UI theme. string GetTheme(); /// /// Gets the effective window width. /// - /// The window width in pixels. + /// The effective window width. double GetWindowWidth(); /// /// Gets the effective window height. /// - /// The window height in pixels. + /// The effective window height. double GetWindowHeight(); /// - /// Gets whether the window should be maximized. + /// Gets whether the window is maximized. /// - /// True if window should be maximized; otherwise, false. + /// True if the window is maximized, otherwise false. bool GetIsWindowMaximized(); /// @@ -101,39 +91,61 @@ public interface IConfigurationProviderService NavigationTab GetLastSelectedTab(); /// - /// Gets the effective settings with all defaults applied. - /// This provides a complete UserSettings object with all values resolved. + /// Gets whether automatic update checks on startup are enabled. /// - /// A UserSettings object with all effective values. - UserSettings GetEffectiveSettings(); + /// True if automatic update checks on startup are enabled, otherwise false. + bool GetAutoCheckForUpdatesOnStartup(); /// - /// Gets the effective content directories for local discovery. + /// Gets whether periodic update checks are enabled. /// - /// List of content directories. + /// True if periodic update checks are enabled, otherwise false. + bool GetAutoCheckForUpdatesPeriodically(); + + /// + /// Gets the interval for periodic update checks in minutes. + /// + /// The interval in minutes. + int GetPeriodicUpdateCheckIntervalMinutes(); + + /// + /// Gets whether detailed logging is enabled. + /// + /// True if detailed logging is enabled, otherwise false. + bool GetEnableDetailedLogging(); + + /// + /// Gets the list of content directories. + /// + /// The list of content directories. List GetContentDirectories(); /// - /// Gets the effective GitHub repositories for discovery. + /// Gets the list of GitHub discovery repositories. /// - /// List of GitHub repositories in "owner/repo" format. + /// The list of repository names. List GetGitHubDiscoveryRepositories(); /// - /// Gets the application data directory path where metadata (workspaces, manifests) is stored. - /// Note: Actual game content files are stored in CAS, not here. + /// Gets all effective user settings in a single object. /// - /// The application data path as a string. + /// The effective user settings. + UserSettings GetEffectiveSettings(); + + /// + /// Gets the effective application data path. + /// + /// The effective application data path. string GetApplicationDataPath(); /// - /// Gets the root application data directory path. + /// Gets the root application data path across all components. /// /// The root application data path. string GetRootAppDataPath(); /// - /// Gets the directory path where game profiles are stored. + /// Gets the directory path where profiles are stored. /// /// The profiles directory path. string GetProfilesPath(); @@ -155,4 +167,10 @@ public interface IConfigurationProviderService /// /// The logs directory path. string GetLogsPath(); + + /// + /// Gets the CSV catalog configuration. + /// + /// The CSV catalog configuration. + CsvCatalogConfiguration GetCsvCatalogConfiguration(); } diff --git a/GenHub/GenHub.Core/Interfaces/Common/IThemeService.cs b/GenHub/GenHub.Core/Interfaces/Common/IThemeService.cs new file mode 100644 index 000000000..400c4ad2a --- /dev/null +++ b/GenHub/GenHub.Core/Interfaces/Common/IThemeService.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using GenHub.Core.Models.Theming; + +namespace GenHub.Core.Interfaces.Common; + +/// +/// Service responsible for managing and applying application color themes at runtime. +/// +public interface IThemeService +{ + /// + /// Gets all available built-in color themes. + /// + IReadOnlyList AvailableThemes { get; } + + /// + /// Gets the currently active color theme. + /// + ColorTheme CurrentTheme { get; } + + /// + /// Applies the specified color theme by its unique identifier or display name. + /// + /// The ID or display name of the theme to apply. + void ApplyTheme(string themeId); + + /// + /// Applies the specified color theme. + /// + /// The theme to apply. + void ApplyTheme(ColorTheme theme); + + /// + /// Initializes and restores the theme saved in user settings. + /// + void InitializeTheme(); +} diff --git a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs index 7af1df63a..44a645364 100644 --- a/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs +++ b/GenHub/GenHub.Core/Interfaces/Common/IUploadHistoryService.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Threading.Tasks; using GenHub.Core.Models.Common; using GenHub.Core.Models.Tools; @@ -10,22 +11,24 @@ namespace GenHub.Core.Interfaces.Common; public interface IUploadHistoryService { /// - /// Gets the maximum upload bytes per period. + /// Gets the default maximum upload bytes per period. /// long MaxUploadBytesPerPeriod { get; } /// - /// Checks if an upload of the specified size is allowed. + /// Checks if an upload of the specified size is allowed, optionally within a category quota. /// /// The file size in bytes. + /// Optional category to check quota against. /// A task representing the asynchronous operation, with a boolean indicating if the upload is allowed. - Task CanUploadAsync(long fileSizeBytes); + Task CanUploadAsync(long fileSizeBytes, string? category = null); /// - /// Gets the usage info. + /// Gets the usage info, optionally filtered by category. /// + /// Optional category to evaluate usage for. /// A task representing the asynchronous operation, with the usage info. - Task GetUsageInfoAsync(); + Task GetUsageInfoAsync(string? category = null); /// /// Records an upload. @@ -33,24 +36,39 @@ public interface IUploadHistoryService /// The file size in bytes. /// The URL. /// The file name. - void RecordUpload(long fileSizeBytes, string url, string fileName); + /// Optional file key in cloud storage. + /// Optional cryptographic deletion token. + /// Optional SHA-256 hash of the uploaded file for deduplication. + /// Optional tool or content category (e.g. "replays", "maps"). + void RecordUpload(long fileSizeBytes, string url, string fileName, string? fileKey = null, string? deleteToken = null, string? fileHash = null, string? category = null); /// - /// Gets the upload history. + /// Finds an existing active upload record matching the specified file hash. /// + /// The SHA-256 hex string of the file. + /// A task representing the asynchronous operation, returning the matching if found. + Task FindExistingUploadAsync(string fileHash); + + /// + /// Gets the upload history, optionally filtered by category. + /// + /// Optional category filter (e.g. "replays", "maps"). If null, returns all history. /// A task representing the asynchronous operation, with the history items. - Task> GetUploadHistoryAsync(); + Task> GetUploadHistoryAsync(string? category = null); /// - /// Removes an item from local history without deleting the hosted file. + /// Removes an item from upload history and deletes the hosted file from cloud storage if a delete token is present. /// /// The URL. - /// A task representing the asynchronous operation. - Task RemoveHistoryItemAsync(string url); + /// Whether to delete the file from cloud storage. Defaults to true. + /// A task representing the asynchronous operation, returning true if removal succeeded. + Task RemoveHistoryItemAsync(string url, bool deleteFromCloud = true); /// - /// Clears local history without deleting hosted files. + /// Clears upload history and deletes all hosted files from cloud storage if delete tokens are present. /// - /// A task representing the asynchronous operation. - Task ClearHistoryAsync(); + /// Whether to delete all files from cloud storage. Defaults to true. + /// Optional category filter (e.g. "replays", "maps"). If null, clears all history. + /// A task representing the asynchronous operation returning the count of deleted and failed cloud deletions. + Task<(int Deleted, int Failed)> ClearHistoryAsync(bool deleteFromCloud = true, string? category = null); } 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/Github/IGitHubApiClient.cs b/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs index 4124aa0d2..ac0a7f8bc 100644 --- a/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs +++ b/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs @@ -13,6 +13,11 @@ public interface IGitHubApiClient /// bool IsAuthenticated { get; } + /// + /// Gets a value indicating whether the GitHub API rate limit is reached. + /// + bool IsRateLimited { get; } + /// /// Gets the latest release from the specified repository. /// @@ -121,6 +126,11 @@ Task DownloadArtifactAsync( /// The GitHub token. void SetAuthenticationToken(SecureString token); + /// + /// Clears any configured authentication token. + /// + void ClearAuthenticationToken(); + /// /// Gets the currently authenticated user. /// 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/Services/IUploadThingService.cs b/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs index a0adb1174..38d14ee84 100644 --- a/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs +++ b/GenHub/GenHub.Core/Interfaces/Services/IUploadThingService.cs @@ -1,31 +1,34 @@ using System; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.UploadThing; namespace GenHub.Core.Interfaces.Services; /// -/// Service for uploading files to UploadThing cloud storage. +/// Service for uploading files to cloud storage via the GenHub upload gateway. /// public interface IUploadThingService { /// - /// Uploads a file to UploadThing and returns the public URL. + /// Uploads a file through the gateway and returns the upload result including public URL and deletion token. /// /// The absolute path to the file to upload. /// Optional progress reporter (0.0 to 1.0). /// Cancellation token. - /// The public URL if successful, otherwise null. - Task UploadFileAsync( + /// The operation result containing the upload result if successful. + Task> UploadFileAsync( string filePath, IProgress? progress = null, CancellationToken ct = default); /// - /// Deletes a file from UploadThing. + /// Deletes a file from cloud storage using its cryptographic deletion token. /// /// The key of the file to delete. + /// The cryptographic deletion token. /// Cancellation token. - /// True if the deletion was successful, otherwise false. - Task DeleteFileAsync(string fileKey, CancellationToken ct = default); + /// The operation result indicating whether deletion was successful. + Task> DeleteFileAsync(string fileKey, string deleteToken, CancellationToken ct = default); } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs index 53c23aeb4..536d17a17 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/MapManager/IMapExportService.cs @@ -1,8 +1,10 @@ -using GenHub.Core.Models.Tools.MapManager; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.MapManager; +using GenHub.Core.Models.Tools.UploadThing; namespace GenHub.Core.Interfaces.Tools.MapManager; @@ -12,13 +14,13 @@ namespace GenHub.Core.Interfaces.Tools.MapManager; public interface IMapExportService { /// - /// Uploads maps to UploadThing and returns the share URL. + /// Uploads maps to cloud storage and returns the upload result. /// /// The maps to upload. /// Progress reporter for upload updates. /// Cancellation token. - /// The share URL if successful, otherwise null. - Task UploadToUploadThingAsync( + /// The operation result containing the upload result if successful. + Task> UploadToUploadThingAsync( IEnumerable maps, IProgress? progress = null, CancellationToken ct = default); diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs index 1705d5aed..e33975605 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IReplayExportService.cs @@ -1,8 +1,10 @@ -using GenHub.Core.Models.Tools.ReplayManager; using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Tools.ReplayManager; +using GenHub.Core.Models.Tools.UploadThing; namespace GenHub.Core.Interfaces.Tools.ReplayManager; @@ -12,13 +14,13 @@ namespace GenHub.Core.Interfaces.Tools.ReplayManager; public interface IReplayExportService { /// - /// Uploads replays to UploadThing and returns the share URL. + /// Uploads replays to cloud storage and returns the upload result. /// /// The replays to upload. /// Progress reporter for upload updates. /// Cancellation token. - /// The share URL if successful, otherwise null. - Task UploadToUploadThingAsync( + /// The operation result containing the upload result if successful. + Task> UploadToUploadThingAsync( IEnumerable replays, IProgress? progress = null, CancellationToken ct = default); diff --git a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs index 917db1df0..9686781f8 100644 --- a/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs +++ b/GenHub/GenHub.Core/Interfaces/Tools/ReplayManager/IUrlParserService.cs @@ -1,6 +1,7 @@ -using GenHub.Core.Models.Tools.ReplayManager; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using GenHub.Core.Models.Tools.ReplayManager; namespace GenHub.Core.Interfaces.Tools.ReplayManager; @@ -30,4 +31,12 @@ public interface IUrlParserService /// Cancellation token. /// The direct download URL if successful, otherwise null. Task GetDirectDownloadUrlAsync(string url, CancellationToken ct = default); + + /// + /// Extracts all direct download URLs from a source-specific URL (e.g., all player replays from a match page). + /// + /// The source URL. + /// Cancellation token. + /// A list of direct download URLs extracted from the source. + Task> GetDirectDownloadUrlsAsync(string url, CancellationToken ct = default); } diff --git a/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs b/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs index 988cdfd8d..2aff649d5 100644 --- a/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs +++ b/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs @@ -79,6 +79,13 @@ Task> UpdateProfileUserDataAsync( /// The active profile ID, or null if no profile is active. string? GetActiveProfileId(); + /// + /// Gets the currently active profile ID for the specified game type (if any). + /// + /// The target game type. + /// The active profile ID for the specified game, or null if none is active. + string? GetActiveProfileId(GameType targetGame); + /// /// Checks if a profile has its user data currently active. /// 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/UploadHistoryItem.cs b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs index d2b457c3b..67c0d19c2 100644 --- a/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs +++ b/GenHub/GenHub.Core/Models/Common/UploadHistoryItem.cs @@ -9,8 +9,10 @@ namespace GenHub.Core.Models.Common; /// The size of the uploaded file in bytes. /// The public URL of the upload. /// The name of the uploaded file. +/// Optional tool or file category. public record UploadHistoryItem( DateTime Timestamp, long SizeBytes, string Url, - string FileName); + string FileName, + string? Category = null); diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs index cc1c4d4c3..83cdb2a76 100644 --- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs +++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs @@ -7,7 +7,7 @@ namespace GenHub.Core.Models.Common; /// Represents application-level and user-specific settings for GenHub. -public class UserSettings : ICloneable +public class UserSettings { /// Gets or sets the application theme preference. public string? Theme { get; set; } = GenHub.Core.Constants.AppConstants.DefaultThemeName; @@ -39,6 +39,12 @@ public class UserSettings : ICloneable /// 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; } @@ -72,6 +78,12 @@ public class UserSettings : ICloneable /// Gets or sets the list of GitHub repositories for discovery. public List? GitHubDiscoveryRepositories { get; set; } + /// Gets or sets the configured CSV catalog index.json path or URL. + public string? IndexFilePath { get; set; } + + /// Gets or sets the fallback CSV validation catalogs. + public List? CsvValidationCatalogs { get; set; } + /// Gets or sets the list of installed tool plugin assembly paths. public List? InstalledToolAssemblyPaths { get; set; } @@ -89,6 +101,11 @@ public class UserSettings : ICloneable /// 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 +113,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. @@ -137,7 +177,7 @@ public bool IsExplicitlySet(string propertyName) /// Creates a deep copy of the current UserSettings instance. /// A new UserSettings instance with all properties deeply copied. - public object Clone() + public UserSettings Clone() { return new UserSettings { @@ -151,6 +191,8 @@ public object Clone() MaxConcurrentDownloads = MaxConcurrentDownloads, AllowBackgroundDownloads = AllowBackgroundDownloads, AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup, + AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically, + PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes, LastUpdateCheckTimestamp = LastUpdateCheckTimestamp, EnableDetailedLogging = EnableDetailedLogging, DefaultWorkspaceStrategy = DefaultWorkspaceStrategy, @@ -168,11 +210,14 @@ public object Clone() DismissedUpdateVersion = DismissedUpdateVersion, ContentDirectories = ContentDirectories != null ? [.. ContentDirectories] : null, GitHubDiscoveryRepositories = GitHubDiscoveryRepositories != null ? [.. GitHubDiscoveryRepositories] : null, + IndexFilePath = IndexFilePath, + CsvValidationCatalogs = CsvValidationCatalogs != null ? [.. CsvValidationCatalogs.Select(c => c.Clone())] : null, InstalledToolAssemblyPaths = InstalledToolAssemblyPaths != null ? [.. InstalledToolAssemblyPaths] : null, PreferredStorageInstallationId = PreferredStorageInstallationId, 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 @@ -267,7 +312,7 @@ public PublisherSubscription GetOrCreateSubscription(string publisherId, string? /// True if subscribed; otherwise, false. public bool IsSubscribedTo(string publisherId) { - return GetSubscription(publisherId)?.IsActive ?? false; // Default to not subscribed for safety + return GetSubscription(publisherId)?.IsActive == true; // Default to not subscribed for safety } /// @@ -296,7 +341,7 @@ public bool IsVersionSkipped(string publisherId, string version) { // Check new subscription system var subscription = GetSubscription(publisherId); - if (subscription != null && subscription.ShouldSkipVersion(version)) + if (subscription?.ShouldSkipVersion(version) == true) { return true; } diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs index 9a35655ea..ad0432121 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherContentRegistry.cs @@ -11,6 +11,14 @@ namespace GenHub.Core.Models.CommunityOutpost; /// public static class GenPatcherContentRegistry { + private const string ResolutionVariantType = "resolution"; + private const string LanguageVariantType = "language"; + private const string Pattern720 = "*720*"; + private const string Pattern900 = "*900*"; + private const string Pattern1080 = "*1080*"; + private const string Pattern1440 = "*1440*"; + private const string Pattern2160 = "*2160*"; + /// /// Language code mappings for patch suffixes. /// @@ -33,11 +41,11 @@ public static class GenPatcherContentRegistry /// private static readonly List ResolutionVariants = [ - new ContentVariant { Id = "720p", Name = "720p", VariantType = "resolution", Value = "720", IncludePatterns = ["*720*"], ExcludePatterns = ["*900*", "*1080*", "*1440*", "*2160*"], IsDefault = false }, - new ContentVariant { Id = "900p", Name = "900p", VariantType = "resolution", Value = "900", IncludePatterns = ["*900*"], ExcludePatterns = ["*720*", "*1080*", "*1440*", "*2160*"], IsDefault = false }, - new ContentVariant { Id = "1080p", Name = "1080p (Recommended)", VariantType = "resolution", Value = "1080", IncludePatterns = ["*1080*"], ExcludePatterns = ["*720*", "*900*", "*1440*", "*2160*"], IsDefault = true }, - new ContentVariant { Id = "1440p", Name = "1440p (2K)", VariantType = "resolution", Value = "1440", IncludePatterns = ["*1440*"], ExcludePatterns = ["*720*", "*900*", "*1080*", "*2160*"], IsDefault = false }, - new ContentVariant { Id = "2160p", Name = "2160p (4K)", VariantType = "resolution", Value = "2160", IncludePatterns = ["*2160*"], ExcludePatterns = ["*720*", "*900*", "*1080*", "*1440*"], IsDefault = false }, + new ContentVariant { Id = "720p", Name = "720p", VariantType = ResolutionVariantType, Value = "720", IncludePatterns = [Pattern720], ExcludePatterns = [Pattern900, Pattern1080, Pattern1440, Pattern2160], IsDefault = false }, + new ContentVariant { Id = "900p", Name = "900p", VariantType = ResolutionVariantType, Value = "900", IncludePatterns = [Pattern900], ExcludePatterns = [Pattern720, Pattern1080, Pattern1440, Pattern2160], IsDefault = false }, + new ContentVariant { Id = "1080p", Name = "1080p (Recommended)", VariantType = ResolutionVariantType, Value = "1080", IncludePatterns = [Pattern1080], ExcludePatterns = [Pattern720, Pattern900, Pattern1440, Pattern2160], IsDefault = true }, + new ContentVariant { Id = "1440p", Name = "1440p (2K)", VariantType = ResolutionVariantType, Value = "1440", IncludePatterns = [Pattern1440], ExcludePatterns = [Pattern720, Pattern900, Pattern1080, Pattern2160], IsDefault = false }, + new ContentVariant { Id = "2160p", Name = "2160p (4K)", VariantType = ResolutionVariantType, Value = "2160", IncludePatterns = [Pattern2160], ExcludePatterns = [Pattern720, Pattern900, Pattern1080, Pattern1440], IsDefault = false }, ]; /// @@ -45,9 +53,10 @@ public static class GenPatcherContentRegistry /// private static readonly List HleiVariants = [ - new ContentVariant { Id = "zerohour-en", Name = "Leikeze's Hotkeys (EN)", VariantType = "language", Value = "en", TargetGame = GameType.ZeroHour, IncludePatterns = ["*ENZH.big"], IsDefault = true }, - new ContentVariant { Id = "zerohour-de", Name = "Leikeze's Hotkeys (DE)", VariantType = "language", Value = "de", TargetGame = GameType.ZeroHour, IncludePatterns = ["*DEZH.big"], IsDefault = false }, - new ContentVariant { Id = "generals-en", Name = "Leikeze's Hotkeys [Generals] (EN)", VariantType = "language", Value = "en", TargetGame = GameType.Generals, IncludePatterns = ["!HotkeysLeikezeEN.big"], IsDefault = false }, + new ContentVariant { Id = "zerohour-en", Name = "Leikeze's Hotkeys (EN)", VariantType = LanguageVariantType, Value = "en", TargetGame = GameType.ZeroHour, IncludePatterns = ["*ENZH.big"], OutputFilename = "!HotkeysLeikezeENZH.big", IsDefault = true }, + new ContentVariant { Id = "zerohour-de", Name = "Leikeze's Hotkeys (DE)", VariantType = LanguageVariantType, Value = "de", TargetGame = GameType.ZeroHour, IncludePatterns = ["*DEZH.big"], OutputFilename = "!HotkeysLeikezeDEZH.big", IsDefault = false }, + new ContentVariant { Id = "zerohour-ru", Name = "Leikeze's Hotkeys (RU)", VariantType = LanguageVariantType, Value = "ru", TargetGame = GameType.ZeroHour, IncludePatterns = ["*RUZH.big"], OutputFilename = "!HotkeysLeikezeRUZH.big", IsDefault = false }, + new ContentVariant { Id = "generals-en", Name = "Leikeze's Hotkeys [Generals] (EN)", VariantType = LanguageVariantType, Value = "en", TargetGame = GameType.Generals, IncludePatterns = ["!HotkeysLeikezeEN.big"], OutputFilename = "!HotkeysLeikezeEN.big", IsDefault = false }, ]; /// @@ -193,31 +202,29 @@ public static class GenPatcherContentRegistry InstallTarget = ContentInstallTarget.Workspace, }, - // Hotkeys + // World Builder Tools ["ewba"] = new GenPatcherContentMetadata { ContentCode = "ewba", - DisplayName = "Easy Win Hotkeys (Advanced)", - Description = "Advanced hotkey configuration for competitive play.", + DisplayName = "Enhanced World Builder (Advanced)", + Description = "Advanced World Builder editor with additional tools, molds, and scripts.", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Hotkeys, + Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, - RequiresRepacking = true, - OutputFilename = "!HotkeysEasyWinAdvancedZH.big", }, ["ewbi"] = new GenPatcherContentMetadata { ContentCode = "ewbi", - DisplayName = "Easy Win Hotkeys (International)", - Description = "Standard hotkey layout optimized for non-English keyboards.", + DisplayName = "Enhanced World Builder (International)", + Description = "World Builder editor version 2.2 for international installations.", ContentType = ContentType.Addon, TargetGame = GameType.ZeroHour, - Category = GenPatcherContentCategory.Hotkeys, + Category = GenPatcherContentCategory.Tools, InstallTarget = ContentInstallTarget.Workspace, - RequiresRepacking = true, - OutputFilename = "!HotkeysEasyWinInternationalZH.big", }, + + // Hotkeys ["hlde"] = new GenPatcherContentMetadata { ContentCode = "hlde", @@ -254,7 +261,7 @@ public static class GenPatcherContentRegistry Category = GenPatcherContentCategory.Hotkeys, InstallTarget = ContentInstallTarget.Workspace, RequiresRepacking = true, - OutputFilename = "!HotkeysLeikezeZH.big", + OutputFilename = "!HotkeysLeikeze{variant}.big", SupportsVariants = true, Variants = HleiVariants, }, @@ -471,7 +478,7 @@ public static bool IsKnownCode(string contentCode) } // Try to parse the version (positions 1-2) - var versionPart = code.Substring(1, 2); + var versionPart = code[1..3]; if (!int.TryParse(versionPart, out var versionNumber)) { return null; diff --git a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs index 0c85ee03d..0824f25b1 100644 --- a/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs +++ b/GenHub/GenHub.Core/Models/CommunityOutpost/GenPatcherDependencyBuilder.cs @@ -26,7 +26,7 @@ public static class GenPatcherDependencyBuilder private static readonly List ControlBarCodes = ["cbpr", "cbpx"]; private static readonly List ZeroHourCameraCodes = ["crzh", "dczh"]; private static readonly List GeneralsCameraCodes = ["crgn"]; - private static readonly List HotkeyCodes = ["ewba", "ewbi", "hlde", "hleg", "hlei"]; + private static readonly List HotkeyCodes = ["hlde", "hleg", "hlei"]; /// /// Gets the dependencies for a given content code and metadata. diff --git a/GenHub/GenHub.Core/Models/Content/Checksum.cs b/GenHub/GenHub.Core/Models/Content/Checksum.cs new file mode 100644 index 000000000..a13e17699 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/Checksum.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Content; + +/// +/// Represents checksum information for file integrity verification. +/// +public class Checksum +{ + /// + /// Gets or sets the MD5 hash of the file. + /// + [JsonPropertyName("md5")] + public string Md5 { get; set; } = string.Empty; + + /// + /// Gets or sets the SHA-256 hash of the file. + /// + [JsonPropertyName("sha256")] + public string Sha256 { get; set; } = string.Empty; + + /// + /// Creates a deep copy of the current instance. + /// + /// A new instance with identical values. + public Checksum Clone() => new() + { + Md5 = Md5, + Sha256 = Sha256, + }; +} diff --git a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs index bcd7ca322..d3239277c 100644 --- a/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs +++ b/GenHub/GenHub.Core/Models/Content/ContentSearchQuery.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using GenHub.Core.Constants; using GenHub.Core.Models.Enums; namespace GenHub.Core.Models.Content; @@ -153,28 +154,38 @@ public string? Language private static readonly Dictionary LanguageMap = new(StringComparer.OrdinalIgnoreCase) { - ["EN"] = "EN", - ["DE"] = "DE", - ["FR"] = "FR", - ["PL"] = "PL", - ["ES"] = "ES", - ["IT"] = "IT", - ["KO"] = "KO", - ["BR"] = "BR", - ["CN"] = "CN", - ["ZH"] = "CN", - ["ZH-CN"] = "CN", + [CsvConstants.AllLanguagesFilter] = CsvConstants.AllLanguagesFilter, + [CsvConstants.LanguageEn] = CsvConstants.LanguageEn, + [CsvConstants.LanguageDe] = CsvConstants.LanguageDe, + [CsvConstants.LanguageFr] = CsvConstants.LanguageFr, + [CsvConstants.LanguagePl] = CsvConstants.LanguagePl, + [CsvConstants.LanguageEs] = CsvConstants.LanguageEs, + [CsvConstants.LanguageIt] = CsvConstants.LanguageIt, + [CsvConstants.LanguageKo] = CsvConstants.LanguageKo, + [CsvConstants.LanguagePtBr] = CsvConstants.LanguagePtBr, + ["BR"] = CsvConstants.LanguagePtBr, + ["PT"] = CsvConstants.LanguagePtBr, + [CsvConstants.LanguageZhCn] = CsvConstants.LanguageZhCn, + ["CN"] = CsvConstants.LanguageZhCn, + ["ZH"] = CsvConstants.LanguageZhCn, + [CsvConstants.LanguageZhTw] = CsvConstants.LanguageZhTw, + ["TW"] = CsvConstants.LanguageZhTw, }; - private static string? NormalizeLanguage(string? language) + /// + /// Normalizes a language code to the canonical casing and alias mapping. + /// + /// The raw language string. + /// The normalized language string, defaulting to when null or empty. + public static string NormalizeLanguage(string? language) { - // set default to "ALL" if not specified - // supported languages Brazilian Chinese English French German Italian Korean Polish Spanish if (string.IsNullOrWhiteSpace(language)) - return "ALL"; + { + return CsvConstants.AllLanguagesFilter; + } - var key = language.Trim().ToUpperInvariant(); + var key = language.Trim(); - return LanguageMap.TryGetValue(key, out var normalized) ? normalized : "ALL"; + return LanguageMap.TryGetValue(key, out var normalized) ? normalized : key.ToUpperInvariant(); } } diff --git a/GenHub/GenHub.Core/Models/Content/CsvCatalogConfiguration.cs b/GenHub/GenHub.Core/Models/Content/CsvCatalogConfiguration.cs new file mode 100644 index 000000000..476c73279 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CsvCatalogConfiguration.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace GenHub.Core.Models.Content; + +/// +/// Configuration for CSV catalog discovery. +/// Binds to the "GenHub" configuration section. +/// +public class CsvCatalogConfiguration +{ + /// + /// Gets or sets the configured local path or remote URL for the catalog index.json file. + /// + public string IndexFilePath { get; set; } = string.Empty; + + /// + /// Gets or sets the fallback validation catalogs defined in configuration. + /// + public List CsvValidationCatalogs { get; set; } = []; +} diff --git a/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryEntry.cs b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryEntry.cs new file mode 100644 index 000000000..a02b1f773 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryEntry.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Content; + +/// +/// Represents a single CSV catalog entry in the registry. +/// +public class CsvCatalogRegistryEntry +{ + /// + /// Gets or sets the unique identifier for this registry entry. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// + /// Gets or sets the URL to the CSV file. + /// + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + /// + /// Gets or sets the game type (e.g., "Generals", "ZeroHour"). + /// + [JsonPropertyName("gameType")] + public string GameType { get; set; } = string.Empty; + + /// + /// Gets or sets the game version (e.g., "1.08", "1.04"). + /// + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the list of supported languages in this CSV. + /// + [JsonPropertyName("languages")] + public List SupportedLanguages { get; set; } = []; + + /// + /// Gets or sets the optional file count in the CSV. + /// + [JsonPropertyName("fileCount")] + public int? FileCount { get; set; } + + /// + /// Gets or sets the total size of the CSV file in bytes. + /// + [JsonPropertyName("totalSizeBytes")] + public long TotalSizeBytes { get; set; } + + /// + /// Gets or sets the checksum information for integrity verification. + /// + [JsonPropertyName("checksum")] + public Checksum? Checksum { get; set; } + + /// + /// Gets or sets when this registry entry was generated. + /// + [JsonPropertyName("generatedAt")] + public DateTime? GeneratedAt { get; set; } + + /// + /// Gets or sets the version of the generator that created this entry. + /// + [JsonPropertyName("generatorVersion")] + public string GeneratorVersion { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether this registry entry is active. + /// + [JsonPropertyName("isActive")] + public bool IsActive { get; set; } = true; + + /// + /// Gets or sets an alias for to support configuration binding. + /// + [JsonIgnore] + public List Languages + { + get => SupportedLanguages; + set => SupportedLanguages = value; + } + + /// + /// Creates a deep copy of the current instance. + /// + /// A new instance with identical values. + public CsvCatalogRegistryEntry Clone() => new() + { + Id = Id, + Url = Url, + GameType = GameType, + Version = Version, + SupportedLanguages = SupportedLanguages != null ? [.. SupportedLanguages] : [], + FileCount = FileCount, + TotalSizeBytes = TotalSizeBytes, + Checksum = Checksum?.Clone(), + GeneratedAt = GeneratedAt, + GeneratorVersion = GeneratorVersion, + IsActive = IsActive, + }; +} diff --git a/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryIndex.cs b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryIndex.cs new file mode 100644 index 000000000..b8645614b --- /dev/null +++ b/GenHub/GenHub.Core/Models/Content/CsvCatalogRegistryIndex.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace GenHub.Core.Models.Content; + +/// +/// Index of available CSV catalog registries. +/// Represents the structure of the docs/GameInstallationFilesRegistry/index.json file. +/// +public class CsvCatalogRegistryIndex +{ + /// + /// Gets or sets the schema version. + /// + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets when the index was last updated. + /// + [JsonPropertyName("lastUpdated")] + public DateTime? LastUpdatedAt { get; set; } + + /// + /// Gets or sets the description of this registry index. + /// + [JsonPropertyName("description")] + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the list of available catalog entries. + /// + [JsonPropertyName("registries")] + public List Entries { get; set; } = []; +} 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/GameInstallations/GameInstallation.cs b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs index d697bc3d1..e38beed31 100644 --- a/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs +++ b/GenHub/GenHub.Core/Models/GameInstallations/GameInstallation.cs @@ -1,3 +1,8 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; using GenHub.Core.Constants; using GenHub.Core.Extensions.GameInstallations; using GenHub.Core.Interfaces.GameInstallations; @@ -157,93 +162,21 @@ public void Fetch() bool foundGenerals = false; bool foundZeroHour = false; - // 1. Check strict subdirectories first (standard structure) - var generalsPath = Path.Combine(InstallationPath, "Command and Conquer Generals"); - if (Directory.Exists(generalsPath)) + // Preserve explicitly configured and valid paths (e.g. from platform detectors or manifests) + if (!string.IsNullOrEmpty(GeneralsPath) && Directory.Exists(GeneralsPath) && HasValidExecutable(GeneralsPath)) { - var generalsExe = Path.Combine(generalsPath, GameClientConstants.GeneralsExecutable); - if (generalsExe.FileExistsCaseInsensitive()) - { - HasGenerals = true; - GeneralsPath = generalsPath; - foundGenerals = true; - _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath); - } + HasGenerals = true; + foundGenerals = true; } - var zeroHourPath = Path.Combine(InstallationPath, GameClientConstants.ZeroHourDirectoryName); - if (Directory.Exists(zeroHourPath)) + if (!string.IsNullOrEmpty(ZeroHourPath) && Directory.Exists(ZeroHourPath) && HasValidExecutable(ZeroHourPath)) { - var zeroHourExe = Path.Combine(zeroHourPath, GameClientConstants.ZeroHourExecutable); - if (zeroHourExe.FileExistsCaseInsensitive()) - { - HasZeroHour = true; - ZeroHourPath = zeroHourPath; - foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath); - } + HasZeroHour = true; + foundZeroHour = true; } - // 2. If not found in subdirectories, check the root path (common for manual installs/repacks) - if (!foundGenerals) - { - var rootGeneralsExe = Path.Combine(InstallationPath, GameClientConstants.GeneralsExecutable); - - // Note: Zero Hour also has a generals.exe, so we need to be careful. - // If checking for valid installation, presence of generals.exe usually implies Generals capability. - if (rootGeneralsExe.FileExistsCaseInsensitive()) - { - HasGenerals = true; - GeneralsPath = InstallationPath; - foundGenerals = true; - _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); - } - } - - if (!foundZeroHour && string.IsNullOrEmpty(ZeroHourPath)) - { - // Zero Hour usually has generals.exe AND specific files like "generals.zh.exe" (sometimes) or just "generals.exe" with different hash/version. - // Detection primarily relies on folder name or presence of expansion files. - // Checking for generals.exe in root can map to both if the user selected a merged directory. - var rootGeneralsExe = Path.Combine(InstallationPath, GameClientConstants.GeneralsExecutable); - - if (rootGeneralsExe.FileExistsCaseInsensitive()) - { - // Check if Generals is already set to this path to avoid duplicate detection - // This prevents setting both GeneralsPath and ZeroHourPath to the same directory - // when platform-specific detectors (Steam/EA/etc) have already identified Generals here - bool isGeneralsAlreadySetToRoot = - !string.IsNullOrEmpty(GeneralsPath) && - Path.GetFullPath(GeneralsPath).Equals( - Path.GetFullPath(InstallationPath), - StringComparison.OrdinalIgnoreCase); - - if (!isGeneralsAlreadySetToRoot) - { - // If we are in root and found generals.exe, it could be ZH. - // Check for something specific to ZH if possible, or just assume if user pointed here it might be combined. - // For safety, let's treat root install as potentially containing both if we can't distinguish. - - // Ideally we check for a ZH specific file, but standard detection often just looks for exe. - // Let's assume if the user pointed us here and it has the exe, it's valid. - // Standard Retail ZH has "generals.exe" but also usually lives in its own folder. - // If user pointed to "C:\Games\ZH", it has generals.exe. - HasZeroHour = true; - ZeroHourPath = InstallationPath; - foundZeroHour = true; - _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath); - } - else - { - _logger?.LogDebug( - "Skipping Zero Hour detection at root {InstallationPath} - Generals already detected here", - InstallationPath); - } - } - } - - // Logic improvement: If we found generals.exe in root, we might have set BOTH to true/root. - // This is acceptable for some "All in One" repacks or if the user manually merged them. + FetchSubdirectoryInstallations(ref foundGenerals, ref foundZeroHour); + FetchRootInstallation(ref foundGenerals, ref foundZeroHour); // Log warnings only if absolutely nothing found if (!foundGenerals && !foundZeroHour) @@ -259,7 +192,7 @@ public void Fetch() } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to fetch installations for {InstallationPath}", InstallationPath); + _logger?.LogError(ex, "Failed to fetch installation at {InstallationPath}", InstallationPath); } } @@ -288,4 +221,207 @@ private static bool HasValidExecutable(string path) var possibleExes = new[] { GameClientConstants.SteamGameDatExecutable, GameClientConstants.GeneralsExecutable, GameClientConstants.ZeroHourExecutable }; return possibleExes.Any(exe => Path.Combine(path, exe).FileExistsCaseInsensitive()); } -} \ No newline at end of file + + private static bool HasRootExecutable(string path) + { + var possibleExes = new[] + { + GameClientConstants.GeneralsExecutable, + GameClientConstants.SuperHackersZeroHourExecutable, + GameClientConstants.SuperHackersGeneralsExecutable, + GameClientConstants.GeneralsOnlineDefaultExecutable, + GameClientConstants.GeneralsOnline60HzExecutable, + GameClientConstants.GeneralsOnlineEacLauncherExecutable, + GameClientConstants.ContraExecutable, + GameClientConstants.SteamGameDatExecutable, + GameClientConstants.GameExecutable, + }; + + return possibleExes.Any(exe => Path.Combine(path, exe).FileExistsCaseInsensitive()); + } + + private static bool HasZeroHourArchiveOrExecutableSignature(string path) + { + if (Path.Combine(path, GameClientConstants.ZeroHourIniBig).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.ZeroHourPatchBig).FileExistsCaseInsensitive()) + { + return true; + } + + if (Path.Combine(path, GameClientConstants.SuperHackersZeroHourExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsOnlineDefaultExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsOnline60HzExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsOnlineEacLauncherExecutable).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.ContraExecutable).FileExistsCaseInsensitive()) + { + return true; + } + + // Check for any localized or mod big archive ending with ZH.big (e.g. SpeechEnglishZH.big, RussianZH.big, GermanZH.big, MapsZH.big) + try + { + if (Directory.Exists(path)) + { + var directoryInfo = new DirectoryInfo(path); + if (directoryInfo.EnumerateFiles().Any(f => f.Name.EndsWith("ZH.big", StringComparison.OrdinalIgnoreCase))) + { + return true; + } + } + } + catch (IOException) + { + // Directory probe failure fallback + } + catch (UnauthorizedAccessException) + { + // Directory access denied fallback + } + + return false; + } + + private static bool HasGeneralsArchiveSignature(string path) + { + return Path.Combine(path, "gensec.big").FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsIniBig).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.GeneralsPatchBig).FileExistsCaseInsensitive() || + Path.Combine(path, GameClientConstants.SuperHackersGeneralsExecutable).FileExistsCaseInsensitive(); + } + + private static bool IsZeroHourNamedDirectory(string path) + { + var folderName = Path.GetFileName(path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + return folderName.Contains("Zero Hour", StringComparison.OrdinalIgnoreCase) || + folderName.Contains("ZeroHour", StringComparison.OrdinalIgnoreCase) || + string.Equals(folderName, "ZH", StringComparison.OrdinalIgnoreCase) || + folderName.StartsWith("ZH_", StringComparison.OrdinalIgnoreCase) || + folderName.EndsWith("_ZH", StringComparison.OrdinalIgnoreCase) || + folderName.StartsWith("ZH-", StringComparison.OrdinalIgnoreCase) || + folderName.EndsWith("-ZH", StringComparison.OrdinalIgnoreCase); + } + + private void FetchSubdirectoryInstallations(ref bool foundGenerals, ref bool foundZeroHour) + { + if (!foundGenerals) + { + ReadOnlySpan generalsSubdirs = + [ + GameClientConstants.GeneralsDirectoryName, + GameClientConstants.GeneralsRetailDirectoryName, + ]; + + if (TryFindSubdirectoryInstallation(generalsSubdirs, GameClientConstants.GeneralsExecutable, out var generalsPath)) + { + HasGenerals = true; + GeneralsPath = generalsPath; + foundGenerals = true; + _logger?.LogDebug("Found Generals installation at {GeneralsPath}", GeneralsPath); + } + } + + if (!foundZeroHour) + { + ReadOnlySpan zhSubdirs = + [ + GameClientConstants.ZeroHourDirectoryName, + GameClientConstants.ZeroHourDirectoryNameAmpersandHyphen, + GameClientConstants.ZeroHourRetailDirectoryName, + GameClientConstants.ZeroHourDirectoryNameAbbreviated, + GameClientConstants.ZeroHourDirectoryNameColonVariant, + ]; + + if (TryFindSubdirectoryInstallation(zhSubdirs, GameClientConstants.ZeroHourExecutable, out var zeroHourPath)) + { + HasZeroHour = true; + ZeroHourPath = zeroHourPath; + foundZeroHour = true; + _logger?.LogDebug("Found Zero Hour installation at {ZeroHourPath}", ZeroHourPath); + } + } + } + + private bool TryFindSubdirectoryInstallation( + ReadOnlySpan candidateSubdirectories, + string executableName, + [NotNullWhen(true)] out string? foundPath) + { + foreach (var subDir in candidateSubdirectories) + { + if (InstallationPath.TryGetDirectoryCaseInsensitive(subDir, out var subDirPath)) + { + var exePath = Path.Combine(subDirPath, executableName); + if (exePath.FileExistsCaseInsensitive()) + { + foundPath = subDirPath; + return true; + } + } + } + + foundPath = null; + return false; + } + + private void FetchRootInstallation(ref bool foundGenerals, ref bool foundZeroHour) + { + if ((foundGenerals && foundZeroHour) || !HasRootExecutable(InstallationPath)) + { + return; + } + + var isZhNamed = IsZeroHourNamedDirectory(InstallationPath); + var hasZhSignature = HasZeroHourArchiveOrExecutableSignature(InstallationPath); + var hasGenSignature = HasGeneralsArchiveSignature(InstallationPath); + + if (!foundZeroHour && (isZhNamed || hasZhSignature)) + { + HasZeroHour = true; + ZeroHourPath = InstallationPath; + foundZeroHour = true; + _logger?.LogDebug("Found Zero Hour installation at root {ZeroHourPath}", ZeroHourPath); + } + + if (!foundGenerals && hasGenSignature) + { + var isStrictGeneralsOnlySignature = + Path.Combine(InstallationPath, "gensec.big").FileExistsCaseInsensitive() || + Path.Combine(InstallationPath, GameClientConstants.SuperHackersGeneralsExecutable).FileExistsCaseInsensitive(); + + var isZeroHour = isZhNamed || hasZhSignature; + if (!isZeroHour || isStrictGeneralsOnlySignature) + { + HasGenerals = true; + GeneralsPath = InstallationPath; + foundGenerals = true; + _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); + } + } + + if (foundGenerals || foundZeroHour) + { + return; + } + + AssignRootFallback(ref foundGenerals, ref foundZeroHour); + } + + private void AssignRootFallback(ref bool foundGenerals, ref bool foundZeroHour) + { + if (IsZeroHourNamedDirectory(InstallationPath)) + { + HasZeroHour = true; + ZeroHourPath = InstallationPath; + foundZeroHour = true; + _logger?.LogDebug("Found Zero Hour installation at root based on directory name {ZeroHourPath}", ZeroHourPath); + } + else + { + HasGenerals = true; + GeneralsPath = InstallationPath; + foundGenerals = true; + _logger?.LogDebug("Found Generals installation at root {GeneralsPath}", GeneralsPath); + } + } +} diff --git a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs index 0b1a35291..27c46286d 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/CreateProfileRequest.cs @@ -209,6 +209,9 @@ public class CreateProfileRequest /// Gets or sets the money transaction volume (TSH). public int? TshMoneyTransactionVolume { get; set; } + /// Gets or sets the game window transition speed multiplier (TSH, 1.0 to 4.0). + public float? TshGameWindowTransitionSpeedMultiplier { get; set; } + // ===== GeneralsOnline Settings ===== /// Gets or sets a value indicating whether to show FPS (GO). diff --git a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs index 3885f73bd..d1c664ed5 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/GameProfile.cs @@ -255,6 +255,9 @@ public class GameProfile : IGameProfile /// Gets or sets the font size for system time display (TSH, 0 to disable). public int? TshSystemTimeFontSize { get; set; } + /// Gets or sets the game window transition speed multiplier (TSH, 1.0 to 4.0). + public float? TshGameWindowTransitionSpeedMultiplier { get; set; } + // ===== GeneralsOnline Client Settings ===== /// Gets or sets a value indicating whether to show FPS counter (GO). diff --git a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs index eaceeaf0f..a41ec8731 100644 --- a/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs +++ b/GenHub/GenHub.Core/Models/GameProfile/UpdateProfileRequest.cs @@ -289,6 +289,9 @@ public class UpdateProfileRequest /// Gets or sets the font size for system time display (TSH, 0 to disable). public int? TshSystemTimeFontSize { get; set; } + /// Gets or sets the game window transition speed multiplier (TSH, 1.0 to 4.0). + public float? TshGameWindowTransitionSpeedMultiplier { get; set; } + // ===== GeneralsOnline Client Settings ===== /// Gets or sets a value indicating whether to show FPS counter (GO). 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..2688913f5 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; @@ -44,4 +46,7 @@ public class TheSuperHackersSettings /// Gets or sets the font size for system time display (0 to disable). public int SystemTimeFontSize { get; set; } = 8; + + /// Gets or sets the game window transition speed multiplier (1.0 to 4.0). + public float GameWindowTransitionSpeedMultiplier { get; set; } = GameSettingsTheSuperHackersConstants.DefaultGameWindowTransitionSpeedMultiplier; } 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/Launching/GameLaunchConfiguration.cs b/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs index 656b6d78f..af91d7216 100644 --- a/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Launching/GameLaunchConfiguration.cs @@ -20,4 +20,19 @@ public class GameLaunchConfiguration /// Gets or sets the timeout for waiting. public TimeSpan? Timeout { get; set; } + + /// + /// Gets or sets the process name, without extension, that is + /// expected to spawn and hand the session to — the Easy Anti-Cheat bootstrapper being the + /// case that needs it. Leave when the started executable *is* the game; + /// tracking then follows the started process as before. + /// + public string? ExpectedChildProcessName { get; set; } + + /// + /// Gets or sets how long to wait for to appear before + /// failing the launch. Defaults to + /// . + /// + public TimeSpan? ExpectedChildDiscoveryTimeout { get; set; } } \ No newline at end of file diff --git a/GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs b/GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs new file mode 100644 index 000000000..a9b841e86 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Launching/GameProcessCandidate.cs @@ -0,0 +1,15 @@ +namespace GenHub.Core.Models.Launching; + +/// +/// A running process reduced to the facts needed to decide whether it is the game a launch spawned. +/// Keeps the selection policy free of so it can be tested. +/// +/// The operating system process identifier. +/// The process name, without extension. +/// When the process started in UTC (must have ). +/// The full image path, or when it cannot be read. +public sealed record GameProcessCandidate( + int ProcessId, + string ProcessName, + DateTime StartTime, + string? ExecutablePath); diff --git a/GenHub/GenHub.Core/Models/Launching/GameProcessInfo.cs b/GenHub/GenHub.Core/Models/Launching/GameProcessInfo.cs index ce324b2f9..d56260abd 100644 --- a/GenHub/GenHub.Core/Models/Launching/GameProcessInfo.cs +++ b/GenHub/GenHub.Core/Models/Launching/GameProcessInfo.cs @@ -13,7 +13,7 @@ public class GameProcessInfo public string ProcessName { get; set; } = string.Empty; /// Gets or sets the start time. - public DateTime StartTime { get; set; } = DateTime.Now; + public DateTime StartTime { get; set; } = DateTime.UtcNow; /// Gets or sets a value indicating whether the process is running. public bool IsRunning { get; set; } diff --git a/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs b/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs index 957f3a416..aa7cd9473 100644 --- a/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs +++ b/GenHub/GenHub.Core/Models/Manifest/ContentVariant.cs @@ -43,6 +43,11 @@ public class ContentVariant /// public GameType? TargetGame { get; set; } + /// + /// Gets or sets the specific output filename for this variant when repacking or delivering content. + /// + public string? OutputFilename { get; set; } + /// /// Gets or sets file path patterns to include for this variant. /// Supports wildcards (e.g., "*1920x1080*", "Resolution_1080p/*"). diff --git a/GenHub/GenHub.Core/Models/Manifest/EntryPointResolution.cs b/GenHub/GenHub.Core/Models/Manifest/EntryPointResolution.cs index a373e5fd9..4cc678988 100644 --- a/GenHub/GenHub.Core/Models/Manifest/EntryPointResolution.cs +++ b/GenHub/GenHub.Core/Models/Manifest/EntryPointResolution.cs @@ -57,10 +57,18 @@ public static EntryPointResolution Failed(string reason, IEnumerable /// A diagnostic string. - public override string ToString() => - Success - ? $"{RelativePath} ({Reason})" - : Candidates.Count == 0 - ? Reason - : $"{Reason} Candidates: {string.Join(", ", Candidates)}"; + public override string ToString() + { + if (Success) + { + return $"{RelativePath} ({Reason})"; + } + + if (Candidates.Count == 0) + { + return Reason; + } + + return $"{Reason} Candidates: {string.Join(", ", Candidates)}"; + } } 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/Models/Manifest/VersionConstraint.cs b/GenHub/GenHub.Core/Models/Manifest/VersionConstraint.cs index c46461a11..5638b8c0c 100644 --- a/GenHub/GenHub.Core/Models/Manifest/VersionConstraint.cs +++ b/GenHub/GenHub.Core/Models/Manifest/VersionConstraint.cs @@ -10,8 +10,8 @@ namespace GenHub.Core.Models.Manifest; /// public partial class VersionConstraint { - private static readonly string[] OrSeparators = new[] { "||" }; - private static readonly char[] SpaceSeparators = new[] { ' ' }; + private static readonly string[] OrSeparators = ["||"]; + private static readonly char[] SpaceSeparators = [' ']; /// /// Gets or sets the minimum version required (inclusive by default). diff --git a/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs b/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs index 85fc9d236..2c60d51bc 100644 --- a/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs +++ b/GenHub/GenHub.Core/Models/Notifications/NotificationMessage.cs @@ -46,7 +46,7 @@ public record NotificationMessage /// /// Gets a value indicating whether this notification has any actionable buttons. /// - public bool IsActionable => Actions != null && Actions.Count > 0; + public bool IsActionable => Actions?.Count > 0; /// /// Gets a value indicating whether this notification should persist in the feed @@ -116,7 +116,7 @@ public NotificationMessage( IsDismissed = false; // Support both old single-action and new multi-action patterns - if (actions != null && actions.Count > 0) + if (actions is { Count: > 0 }) { Actions = actions; } diff --git a/GenHub/GenHub.Core/Models/Providers/ProviderEndpoints.cs b/GenHub/GenHub.Core/Models/Providers/ProviderEndpoints.cs index 59a7ccae6..29ea9ec90 100644 --- a/GenHub/GenHub.Core/Models/Providers/ProviderEndpoints.cs +++ b/GenHub/GenHub.Core/Models/Providers/ProviderEndpoints.cs @@ -66,58 +66,46 @@ public class ProviderEndpoints public string? GetEndpoint(string name) { // Check standard endpoints first - if (string.Equals(name, ProviderEndpointConstants.CatalogUrl, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, ProviderEndpointConstants.Catalog, StringComparison.OrdinalIgnoreCase)) + if ((string.Equals(name, ProviderEndpointConstants.CatalogUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, ProviderEndpointConstants.Catalog, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(CatalogUrl)) { - if (!string.IsNullOrEmpty(CatalogUrl)) - { - return CatalogUrl; - } + return CatalogUrl; } - if (string.Equals(name, ProviderEndpointConstants.DownloadBaseUrl, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, ProviderEndpointConstants.DownloadBase, StringComparison.OrdinalIgnoreCase)) + if ((string.Equals(name, ProviderEndpointConstants.DownloadBaseUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, ProviderEndpointConstants.DownloadBase, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(DownloadBaseUrl)) { - if (!string.IsNullOrEmpty(DownloadBaseUrl)) - { - return DownloadBaseUrl; - } + return DownloadBaseUrl; } - if (string.Equals(name, ProviderEndpointConstants.WebsiteUrl, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, ProviderEndpointConstants.Website, StringComparison.OrdinalIgnoreCase)) + if ((string.Equals(name, ProviderEndpointConstants.WebsiteUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, ProviderEndpointConstants.Website, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(WebsiteUrl)) { - if (!string.IsNullOrEmpty(WebsiteUrl)) - { - return WebsiteUrl; - } + return WebsiteUrl; } - if (string.Equals(name, ProviderEndpointConstants.SupportUrl, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, ProviderEndpointConstants.Support, StringComparison.OrdinalIgnoreCase)) + if ((string.Equals(name, ProviderEndpointConstants.SupportUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, ProviderEndpointConstants.Support, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(SupportUrl)) { - if (!string.IsNullOrEmpty(SupportUrl)) - { - return SupportUrl; - } + return SupportUrl; } - if (string.Equals(name, ProviderEndpointConstants.LatestVersionUrl, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, ProviderEndpointConstants.LatestVersion, StringComparison.OrdinalIgnoreCase)) + if ((string.Equals(name, ProviderEndpointConstants.LatestVersionUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, ProviderEndpointConstants.LatestVersion, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(LatestVersionUrl)) { - if (!string.IsNullOrEmpty(LatestVersionUrl)) - { - return LatestVersionUrl; - } + return LatestVersionUrl; } - if (string.Equals(name, ProviderEndpointConstants.ManifestApiUrl, StringComparison.OrdinalIgnoreCase) || - string.Equals(name, ProviderEndpointConstants.ManifestApi, StringComparison.OrdinalIgnoreCase)) + if ((string.Equals(name, ProviderEndpointConstants.ManifestApiUrl, StringComparison.OrdinalIgnoreCase) || + string.Equals(name, ProviderEndpointConstants.ManifestApi, StringComparison.OrdinalIgnoreCase)) && + !string.IsNullOrEmpty(ManifestApiUrl)) { - if (!string.IsNullOrEmpty(ManifestApiUrl)) - { - return ManifestApiUrl; - } + return ManifestApiUrl; } // Check custom endpoints diff --git a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs index f32dded47..c9d50aa7f 100644 --- a/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs +++ b/GenHub/GenHub.Core/Models/Storage/CasConfiguration.cs @@ -130,7 +130,15 @@ public void Validate() if (!string.IsNullOrEmpty(parentDir) && !Directory.Exists(parentDir)) throw new DirectoryNotFoundException($"Parent directory of CasRootPath does not exist: {parentDir}"); } - catch (Exception ex) when (!(ex is ArgumentException || ex is DirectoryNotFoundException)) + catch (ArgumentException) + { + throw; + } + catch (DirectoryNotFoundException) + { + throw; + } + catch (Exception ex) { throw new ArgumentException($"Invalid CasRootPath: {CasRootPath}", ex); } diff --git a/GenHub/GenHub.Core/Models/Theming/ColorTheme.cs b/GenHub/GenHub.Core/Models/Theming/ColorTheme.cs new file mode 100644 index 000000000..cad050e42 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Theming/ColorTheme.cs @@ -0,0 +1,37 @@ +namespace GenHub.Core.Models.Theming; + +/// +/// Represents a selectable color theme for application accents. +/// +public sealed record ColorTheme +{ + /// + /// Gets the unique identifier of the theme. + /// + public required string Id { get; init; } + + /// + /// Gets the human-readable display name. + /// + public required string DisplayName { get; init; } + + /// + /// Gets the primary accent hex color. + /// + public required string PrimaryHex { get; init; } + + /// + /// Gets the lighter accent variant hex color for gradients and highlights. + /// + public required string LightHex { get; init; } + + /// + /// Gets the darker accent variant hex color for depth and gradients. + /// + public required string DarkHex { get; init; } + + /// + /// Gets the translucent glow hex color for ambient lighting effects. + /// + public required string GlowHex { get; init; } +} diff --git a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs index 74dedfe8b..2ffad6d45 100644 --- a/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs +++ b/GenHub/GenHub.Core/Models/Tools/ReplayManager/ReplaySource.cs @@ -29,4 +29,9 @@ public enum ReplaySource /// Direct link to a .rep or .zip file. /// DirectLink, + + /// + /// GameReplays Strata match platform. + /// + Strata, } diff --git a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs index 04e1c356a..6d4ce29d3 100644 --- a/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs +++ b/GenHub/GenHub.Core/Models/Tools/UploadRecord.cs @@ -27,6 +27,30 @@ public sealed class UploadRecord /// public string? FileName { get; set; } + /// + /// Gets or sets the SHA-256 hash of the uploaded file for deduplication. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileHash { get; set; } + + /// + /// Gets or sets the file key assigned by the cloud storage provider. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? FileKey { get; set; } + + /// + /// Gets or sets the cryptographic HMAC deletion token. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DeleteToken { get; set; } + + /// + /// Gets or sets the category or tool identifier of the upload (e.g. "replays", "maps"). + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Category { get; set; } + /// /// Gets or sets a value indicating whether a legacy record was pending deletion. /// diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadRequest.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadRequest.cs new file mode 100644 index 000000000..e133c93ba --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadRequest.cs @@ -0,0 +1,8 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Request to delete a cloud upload using a cryptographic deletion token. +/// +/// Unique file key in cloud storage. +/// Cryptographic HMAC deletion receipt. +public sealed record DeleteUploadRequest(string FileKey, string DeleteToken); diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadResponse.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadResponse.cs new file mode 100644 index 000000000..756f0a367 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/DeleteUploadResponse.cs @@ -0,0 +1,7 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Response from the gateway after requesting deletion. +/// +/// Whether the deletion succeeded upstream. +public sealed record DeleteUploadResponse(bool Success); diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/DirectUploadResponse.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/DirectUploadResponse.cs new file mode 100644 index 000000000..a29138a6e --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/DirectUploadResponse.cs @@ -0,0 +1,12 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Gateway response containing upload result and cryptographic deletion token. +/// +/// Publicly accessible share URL. +/// Unique file key in cloud storage. +/// Cryptographic HMAC deletion receipt. +public sealed record DirectUploadResponse( + string? PublicUrl, + string? FileKey, + string? DeleteToken); diff --git a/GenHub/GenHub.Core/Models/Tools/UploadThing/UploadResult.cs b/GenHub/GenHub.Core/Models/Tools/UploadThing/UploadResult.cs new file mode 100644 index 000000000..735841c05 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Tools/UploadThing/UploadResult.cs @@ -0,0 +1,9 @@ +namespace GenHub.Core.Models.Tools.UploadThing; + +/// +/// Result of a successful cloud upload operation. +/// +/// Public share URL for the uploaded file. +/// Unique file key in cloud storage. +/// Cryptographic HMAC deletion receipt. +public sealed record UploadResult(string PublicUrl, string FileKey, string DeleteToken); diff --git a/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs b/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs new file mode 100644 index 000000000..329f6afd0 --- /dev/null +++ b/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs @@ -0,0 +1,75 @@ +using System.Linq; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; + +namespace GenHub.Core.Models.Workspace; + +/// +/// Classifies content types based on whether they can be safely hot-swapped during an active game session. +/// Hotswappable content is deployed to the user Documents directory and read dynamically by the game engine. +/// Locked content modifies game process executables, BIG archives in the workspace, or memory-sensitive assets. +/// +public static class ContentHotswapClassification +{ + /// + /// Determines whether the specified content type can be hot-swapped while the game is running. + /// + /// The content type to evaluate. + /// true if the content type is hotswappable; otherwise, false. + public static bool IsHotswappable(ContentType contentType) + { + return contentType switch + { + ContentType.Map => true, + ContentType.MapPack => true, + ContentType.Replay => true, + _ => false, + }; + } + + /// + /// Determines whether the specified manifest can be hot-swapped while the game is running. + /// + /// The manifest to evaluate. + /// true if the manifest is hotswappable; otherwise, false. + public static bool IsHotswappable(ContentManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (!IsHotswappable(manifest.ContentType)) + { + return false; + } + + var files = ManifestVariantResolver.ResolveFiles(manifest); + if (files.Count == 0 && (manifest.Variants.Count > 0 || manifest.Files.Count > 0)) + { + return false; + } + + return files.All(f => + f.InstallTarget != ContentInstallTarget.Workspace && + f.InstallTarget != ContentInstallTarget.System); + } + + /// + /// Determines whether the specified content type is locked and cannot be modified during an active game session. + /// + /// The content type to evaluate. + /// true if the content type is locked during active sessions; otherwise, false. + public static bool IsLocked(ContentType contentType) + { + return !IsHotswappable(contentType); + } + + /// + /// Determines whether the specified manifest is locked and cannot be modified during an active game session. + /// + /// The manifest to evaluate. + /// true if the manifest is locked during active sessions; otherwise, false. + public static bool IsLocked(ContentManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + return !IsHotswappable(manifest); + } +} diff --git a/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs b/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs index 88dd778ad..de3a7c762 100644 --- a/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs +++ b/GenHub/GenHub.Core/Models/Workspace/ContentTypePriority.cs @@ -1,3 +1,4 @@ +using System; using GenHub.Core.Models.Enums; namespace GenHub.Core.Models.Workspace; @@ -21,9 +22,19 @@ public static int GetPriority(ContentType contentType) ContentType.Mod => 100, // Highest: User mods override everything ContentType.Patch => 90, // Patches override base content ContentType.GameClient => 50, // Community executables override official + ContentType.ModdingTool => 45, // Modding tools (between Addon and GameClient) + ContentType.Executable => 45, // Executables (between Addon and GameClient) ContentType.Addon => 40, // Addons (maps, etc.) + ContentType.LanguagePack => 35, // Language packs + ContentType.MapPack => 30, // Map packs (between GameInstallation and Addon) + ContentType.Map => 30, // Individual maps + ContentType.Mission => 30, // Missions + ContentType.Skin => 20, // Skins + ContentType.Video => 20, // Videos + ContentType.Replay => 20, // Replays + ContentType.Screensaver => 20, // Screensavers ContentType.GameInstallation => 10, // Lowest: Base game files - _ => 0, // Unknown/undefined types + _ => 0, // Unknown or meta types (bundles, referrals, etc.) }; } diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs index ec943da88..ee5ed6952 100644 --- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs +++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; using GenHub.Core.Models.Enums; @@ -6,13 +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 { /// - public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + [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")] + [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) { @@ -49,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/Services/Providers/ProviderDefinitionLoader.cs b/GenHub/GenHub.Core/Services/Providers/ProviderDefinitionLoader.cs index 4268f84dd..0845ad1d2 100644 --- a/GenHub/GenHub.Core/Services/Providers/ProviderDefinitionLoader.cs +++ b/GenHub/GenHub.Core/Services/Providers/ProviderDefinitionLoader.cs @@ -141,7 +141,18 @@ public async Task>> LoadProvider this.EnsureProvidersLoaded(); } - return this.providers.TryGetValue(providerId, out var provider) ? provider : null; + if (this.providers.TryGetValue(providerId, out var provider)) + { + return provider; + } + + var normalized = providerId.Replace("-", string.Empty); + if (this.providers.TryGetValue(normalized, out provider)) + { + return provider; + } + + return null; } /// @@ -229,6 +240,11 @@ public OperationResult RemoveCustomProvider(string providerId) } var removed = this.providers.TryRemove(providerId, out _); + if (!removed) + { + var normalized = providerId.Replace("-", string.Empty); + removed = this.providers.TryRemove(normalized, out _); + } if (removed) { 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.Core/Utilities/ExecutableFileClassifier.cs b/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs index 10d3a3406..b7de8baed 100644 --- a/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs +++ b/GenHub/GenHub.Core/Utilities/ExecutableFileClassifier.cs @@ -266,8 +266,22 @@ private static bool TryReadHeader(string absolutePath, Span header, out in read = stream.ReadAtLeast(header, MagicHeaderLength, throwOnEndOfStream: false); return true; } - catch (Exception ex) when ( - ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + catch (IOException) + { + read = 0; + return false; + } + catch (UnauthorizedAccessException) + { + read = 0; + return false; + } + catch (ArgumentException) + { + read = 0; + return false; + } + catch (NotSupportedException) { read = 0; return false; diff --git a/GenHub/GenHub.Linux/GameInstallations/CdisoInstallation.cs b/GenHub/GenHub.Linux/GameInstallations/CdisoInstallation.cs index 8cbf2473d..04d3ecd1a 100644 --- a/GenHub/GenHub.Linux/GameInstallations/CdisoInstallation.cs +++ b/GenHub/GenHub.Linux/GameInstallations/CdisoInstallation.cs @@ -357,10 +357,8 @@ private bool TryGetCdisoPathFromWineRegistry(string winePrefix, out string? inst logger?.LogInformation("CD/ISO path found in Wine registry using value '{ValueName}': {InstallPath}", valueName, installPath); return true; } - else - { - logger?.LogDebug("Found registry value '{ValueName}' but path does not exist: {InstallPath}", valueName, installPath); - } + + logger?.LogDebug("Found registry value '{ValueName}' but path does not exist: {InstallPath}", valueName, installPath); } } } 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.Linux/Program.cs b/GenHub/GenHub.Linux/Program.cs index aabae87ea..8b4af8055 100644 --- a/GenHub/GenHub.Linux/Program.cs +++ b/GenHub/GenHub.Linux/Program.cs @@ -38,11 +38,10 @@ public static void Main(string[] args) // Create lockfile to guarantee that only one instance is running on linux var lockFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".genhub", "lock"); Directory.CreateDirectory(Path.GetDirectoryName(lockFilePath)!); + FileStream? lockFile = null; try { - using var lockFile = new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); - - // If we get here, we have the lock + lockFile = new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); } catch (IOException) { @@ -50,34 +49,37 @@ public static void Main(string[] args) return; } - using var bootstrapLoggerFactory = LoggingModule.CreateBootstrapLoggerFactory(); - var bootstrapLogger = bootstrapLoggerFactory.CreateLogger(); - try + using (lockFile) + using (var bootstrapLoggerFactory = LoggingModule.CreateBootstrapLoggerFactory()) { - bootstrapLogger.LogInformation("Starting GenHub Linux application"); - - var services = new ServiceCollection(); - + var bootstrapLogger = bootstrapLoggerFactory.CreateLogger(); try { - // Register shared services and Linux-specific services - services.ConfigureApplicationServices(s => s.AddLinuxServices()); + bootstrapLogger.LogInformation("Starting GenHub Linux application"); + + var services = new ServiceCollection(); + + try + { + // Register shared services and Linux-specific services + services.ConfigureApplicationServices(s => s.AddLinuxServices()); + } + catch (Exception configEx) + { + bootstrapLogger.LogCritical(configEx, "Failed to configure application services"); + throw; + } + + var serviceProvider = services.BuildServiceProvider(); + AppLocator.Services = serviceProvider; + + BuildAvaloniaApp(serviceProvider).StartWithClassicDesktopLifetime(args); } - catch (Exception configEx) + catch (Exception ex) { - bootstrapLogger.LogCritical(configEx, "Failed to configure application services"); + bootstrapLogger.LogCritical(ex, "Application terminated unexpectedly"); throw; } - - var serviceProvider = services.BuildServiceProvider(); - AppLocator.Services = serviceProvider; - - BuildAvaloniaApp(serviceProvider).StartWithClassicDesktopLifetime(args); - } - catch (Exception ex) - { - bootstrapLogger.LogCritical(ex, "Application terminated unexpectedly"); - throw; } } diff --git a/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs b/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs index c9a06a897..676bacf28 100644 --- a/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs +++ b/GenHub/GenHub.MacOS/GameInstallations/MacOSInstallationDetector.cs @@ -222,12 +222,17 @@ private static IEnumerable GetBottleDriveCPaths(string home, string appl foreach (var container in bottleContainers) { - string[] bottles; + string[] bottles = []; try { bottles = Directory.Exists(container) ? Directory.GetDirectories(container) : []; } - catch (Exception) + catch (IOException) + { + // An unreadable bottle container is not a detection failure. + continue; + } + catch (UnauthorizedAccessException) { // An unreadable bottle container is not a detection failure. continue; diff --git a/GenHub/GenHub.ProxyLauncher/Program.cs b/GenHub/GenHub.ProxyLauncher/Program.cs index fac9db11c..bc0c59085 100644 --- a/GenHub/GenHub.ProxyLauncher/Program.cs +++ b/GenHub/GenHub.ProxyLauncher/Program.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; namespace GenHub.ProxyLauncher; @@ -18,259 +20,268 @@ internal class Program /// The exit code of the process. private static async Task Main(string[] args) { - // Prevent multiple instances from running simultaneously - // This can happen when Steam triggers the proxy again while it's already running - const string mutexName = ProxyConstants.SingleInstanceMutexName; + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + var mutexName = GetScopedMutexName(baseDir); + using var mutex = new Mutex(true, mutexName, out bool createdNew); if (!createdNew) { - // Another instance is already running - silently exit - // No logging here since we don't want to spam the log + LogError($"Another instance of proxy launcher is already running for directory: {baseDir}"); return 0; } - int finalExitCode = 0; + GC.KeepAlive(mutex); try { - var baseDir = AppDomain.CurrentDomain.BaseDirectory; var configPath = Path.Combine(baseDir, ConfigFileName); - if (!File.Exists(configPath)) { - // Fallback: If no config, try to launch the original game if it exists as .bak - // This is a safety measure if someone runs the proxy manually without GenHub setup return await TryLaunchBackupAsync(baseDir, args); } - var configJson = await File.ReadAllTextAsync(configPath); - var config = JsonSerializer.Deserialize(configJson); - + var config = await LoadConfigAsync(configPath); if (config == null || string.IsNullOrWhiteSpace(config.TargetExecutable)) { LogError("Invalid configuration: TargetExecutable is missing."); return 1; } - // Log configuration for debugging - LogInfo($"Proxy Launcher started at {DateTime.Now}"); - LogInfo($"Configuration loaded from: {configPath}"); - LogInfo($"Target Executable: {config.TargetExecutable}"); - LogInfo($"Working Directory: {config.WorkingDirectory ?? Path.GetDirectoryName(config.TargetExecutable)}"); - LogInfo($"Arguments: {(config.Arguments != null ? string.Join(" ", config.Arguments) : "(none)")}"); - - // Validate target executable exists - if (!File.Exists(config.TargetExecutable)) - { - var errorMsg = $"Target executable not found: {config.TargetExecutable}"; - LogError(errorMsg); - return 1; - } - - // Validate working directory exists var workingDir = config.WorkingDirectory ?? Path.GetDirectoryName(config.TargetExecutable); - if (!Directory.Exists(workingDir)) + if (!ValidatePaths(config.TargetExecutable, workingDir)) { - var errorMsg = $"Working directory not found: {workingDir}"; - LogError(errorMsg); return 1; } - // Prepare process start info - var startInfo = new ProcessStartInfo - { - FileName = config.TargetExecutable, - WorkingDirectory = workingDir, - UseShellExecute = false, - CreateNoWindow = false, // Allow the game window to appear - }; + LogLaunchDetails(configPath, config, workingDir); - // Detect whether Steam launched us. Some titles do not set the common env flags even when launched by Steam. - var steamContext = IsSteamLaunched(); - if (!steamContext && !string.IsNullOrWhiteSpace(config.SteamAppId)) - { - // Previously we exited after asking Steam to relaunch via steam:// which left the target unstarted and - // resulted in "invalid license". Now we continue and inject Steam env + steam_appid.txt ourselves. - LogInfo("Steam context not detected from environment; continuing with injected Steam env instead of exiting."); - } + var (startInfo, tempExePath) = PrepareProcessStartInfo(config, workingDir!, args); + var (exitCode, _) = await ExecuteAndMonitorProcessAsync(config, startInfo); - // Propagate Steam identifiers so overlay/playtime work even when launched outside Steam. - if (!string.IsNullOrWhiteSpace(config.SteamAppId)) - { - // Ensure steam_appid.txt exists both where the proxy runs (working dir) and where the target exe resides. - EnsureSteamAppId(config.SteamAppId, workingDir); - var targetDir = Path.GetDirectoryName(config.TargetExecutable) ?? workingDir; - if (!string.Equals(targetDir, workingDir, StringComparison.OrdinalIgnoreCase)) - { - EnsureSteamAppId(config.SteamAppId, targetDir); - } + CleanupTempExecutable(tempExePath); + LogInfo($"Process completed. Final Exit Code: {exitCode}"); + return exitCode; + } + catch (Exception ex) + { + LogError($"Critical error in proxy launcher: {ex.Message}"); + return 1; + } + } - // Inject common Steam env vars so SteamAPI sees the app even if Steam didn't set them for the proxy. - startInfo.Environment["SteamAppId"] = config.SteamAppId; - startInfo.Environment["SteamGameId"] = config.SteamAppId; - startInfo.Environment["SteamClientLaunch"] = "1"; - startInfo.Environment["SteamEnv"] = "1"; - startInfo.Environment["SteamOverlayGameId"] = config.SteamAppId; - } + private static string GetScopedMutexName(string baseDir) + { + var normalizedDir = Path.GetFullPath(baseDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).ToUpperInvariant(); + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalizedDir)); + return $"{ProxyConstants.MutexPrefix}{Convert.ToHexString(hashBytes)[..16]}"; + } - // Pass through arguments from the config first, then any command line args passed to this proxy - // Steam might pass args like -quickstart etc. - // BUT: Steam's %command% might pass the original exe path - filter those out - var arguments = new List(); + private static async Task LoadConfigAsync(string configPath) + { + var configJson = await File.ReadAllTextAsync(configPath); + return JsonSerializer.Deserialize(configJson); + } - var dedupe = new HashSet(StringComparer.OrdinalIgnoreCase); + private static bool ValidatePaths(string targetExecutable, string? workingDir) + { + if (!File.Exists(targetExecutable)) + { + LogError($"Target executable not found: {targetExecutable}"); + return false; + } - if (config.Arguments != null) - { - foreach (var arg in config.Arguments) - { - if (string.IsNullOrWhiteSpace(arg)) - { - continue; - } + if (string.IsNullOrWhiteSpace(workingDir) || !Directory.Exists(workingDir)) + { + LogError($"Working directory not found: {workingDir}"); + return false; + } - if (dedupe.Add(arg)) - { - arguments.Add(arg); - } - } - } + return true; + } - if (args.Length > 0) - { - // Filter out exe paths that Steam passes via %command% - // These are typically the original game executable paths - foreach (var arg in args) - { - // Skip arguments that match the current executable (the proxy itself) - // This handles Steam's %command% expansion which passes the full path to this executable - var cleanArg = arg.Trim('"'); - if (string.Equals(cleanArg, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase)) - { - LogInfo($"Filtering out Steam %command% executable arg (matches ProcessPath): {arg}"); - continue; - } + private static void LogLaunchDetails(string configPath, ProxyConfig config, string? workingDir) + { + LogInfo($"Proxy Launcher started at {DateTime.UtcNow:O}"); + LogInfo($"Configuration loaded from: {configPath}"); + LogInfo($"Target Executable: {config.TargetExecutable}"); + LogInfo($"Working Directory: {workingDir}"); + LogInfo($"Arguments: {(config.Arguments != null ? string.Join(" ", config.Arguments) : "(none)")}"); + } - if (string.IsNullOrWhiteSpace(arg)) - { - continue; - } + private static (ProcessStartInfo StartInfo, string? TempExePath) PrepareProcessStartInfo( + ProxyConfig config, + string workingDir, + string[] args) + { + var startInfo = new ProcessStartInfo + { + FileName = config.TargetExecutable!, + WorkingDirectory = workingDir, + UseShellExecute = false, + CreateNoWindow = false, + }; - // Avoid double-applying flags like -win when Steam passes them via %command% - if (dedupe.Add(arg)) - { - arguments.Add(arg); - } - } + ConfigureSteamEnvironment(startInfo, config, workingDir); + startInfo.Arguments = BuildArgumentString(config, args); + + var tempExePath = PrepareTemporaryExecutable(config, workingDir, startInfo); + return (startInfo, tempExePath); + } + + private static void ConfigureSteamEnvironment(ProcessStartInfo startInfo, ProxyConfig config, string workingDir) + { + var steamContext = IsSteamLaunched(); + if (!steamContext && !string.IsNullOrWhiteSpace(config.SteamAppId)) + { + LogInfo("Steam context not detected from environment; continuing with injected Steam env instead of exiting."); + } + + if (!string.IsNullOrWhiteSpace(config.SteamAppId)) + { + EnsureSteamAppId(config.SteamAppId, workingDir); + var targetDir = Path.GetDirectoryName(config.TargetExecutable) ?? workingDir; + if (!string.Equals(targetDir, workingDir, StringComparison.OrdinalIgnoreCase)) + { + EnsureSteamAppId(config.SteamAppId, targetDir); } - startInfo.Arguments = string.Join(" ", arguments); + startInfo.Environment["SteamAppId"] = config.SteamAppId; + startInfo.Environment["SteamGameId"] = config.SteamAppId; + startInfo.Environment["SteamClientLaunch"] = "1"; + startInfo.Environment["SteamEnv"] = "1"; + startInfo.Environment["SteamOverlayGameId"] = config.SteamAppId; + } + } + + private static string BuildArgumentString(ProxyConfig config, string[] args) + { + var arguments = new List(); + var dedupe = new HashSet(StringComparer.OrdinalIgnoreCase); - // Some game launchers (like Community Patch) check their own executable path location. - // If the target exe is NOT in the working directory, we need to copy it there temporarily. - string? tempExePath = null; - var targetExeDir = Path.GetDirectoryName(config.TargetExecutable) ?? string.Empty; - if (!string.Equals(targetExeDir, workingDir, StringComparison.OrdinalIgnoreCase)) + if (config.Arguments != null) + { + foreach (var arg in config.Arguments.Where(arg => !string.IsNullOrWhiteSpace(arg) && dedupe.Add(arg))) { - // Copy the target exe to a temp file in the working directory - var exeName = Path.GetFileNameWithoutExtension(config.TargetExecutable); - var tempExeName = $"{exeName}_genhub_temp_{Guid.NewGuid():N}.exe"; - tempExePath = Path.Combine(workingDir!, tempExeName); + arguments.Add(arg); + } + } - LogInfo($"Target exe not in working directory - creating temp copy at: {tempExePath}"); - try + if (args.Length > 0) + { + foreach (var arg in args.Where(arg => !string.IsNullOrWhiteSpace(arg) && dedupe.Add(arg))) + { + var cleanArg = arg.Trim('"'); + if (string.Equals(cleanArg, Environment.ProcessPath, StringComparison.OrdinalIgnoreCase)) { - File.Copy(config.TargetExecutable, tempExePath, overwrite: true); - startInfo.FileName = tempExePath; - LogInfo($"Temp copy created successfully"); + LogInfo($"Filtering out Steam %command% executable arg (matches ProcessPath): {arg}"); + continue; } - catch (Exception ex) - { - LogError($"Failed to create temp copy: {ex.Message}"); - // Fall back to original path - tempExePath = null; + if (!string.IsNullOrWhiteSpace(arg) && dedupe.Add(arg)) + { + arguments.Add(arg); } } + } - // Log the full command line being executed - LogInfo($"Launching: \"{startInfo.FileName}\" {startInfo.Arguments}"); - LogInfo($"Working Directory: {startInfo.WorkingDirectory}"); + return string.Join(" ", arguments); + } - // Launch the target game - var sw = Stopwatch.StartNew(); - var launchStartUtc = DateTime.UtcNow; - using var process = Process.Start(startInfo); - if (process == null) - { - var errorMsg = $"Failed to start target process: {config.TargetExecutable}"; - LogError(errorMsg); - return 1; - } + private static string? PrepareTemporaryExecutable(ProxyConfig config, string workingDir, ProcessStartInfo startInfo) + { + var targetExeDir = Path.GetDirectoryName(config.TargetExecutable) ?? string.Empty; + if (string.Equals(targetExeDir, workingDir, StringComparison.OrdinalIgnoreCase)) + { + return null; + } - LogInfo($"Process started successfully. PID: {process.Id}"); + var exeName = Path.GetFileNameWithoutExtension(config.TargetExecutable); + var tempExeName = $"{exeName}_genhub_temp_{Guid.NewGuid():N}.exe"; + var tempExePath = Path.Combine(workingDir, tempExeName); - // Important for Steam: We must wait for the game to exit. - // Steam watches THIS process. If we exit, Steam thinks the game stopped. - await process.WaitForExitAsync(); - sw.Stop(); + LogInfo($"Target exe not in working directory - creating temp copy at: {tempExePath}"); + try + { + File.Copy(config.TargetExecutable!, tempExePath, overwrite: true); + startInfo.FileName = tempExePath; + LogInfo("Temp copy created successfully"); + return tempExePath; + } + catch (Exception ex) + { + LogError($"Failed to create temp copy: {ex.Message}"); + return null; + } + } - finalExitCode = process.ExitCode; - LogInfo($"Process exited. Exit Code: {finalExitCode}, Duration: {(int)sw.Elapsed.TotalSeconds}s"); + private static async Task<(int ExitCode, bool SpawnedFound)> ExecuteAndMonitorProcessAsync( + ProxyConfig config, + ProcessStartInfo startInfo) + { + LogInfo($"Launching: \"{startInfo.FileName}\" {startInfo.Arguments}"); + LogInfo($"Working Directory: {startInfo.WorkingDirectory}"); - // Some launchers spawn the real game and then exit quickly. - // If that happens, keep THIS proxy alive by waiting on the spawned child process. - // Steam tracks the process it started (the proxy). If we exit, playtime/overlay stop. - if (sw.Elapsed.TotalSeconds < 30) - { - var baseName = Path.GetFileNameWithoutExtension(startInfo.FileName); - var spawned = TryFindSpawnedProcess(baseName, startInfo.WorkingDirectory, launchStartUtc, process.Id); - if (spawned != null) - { - LogInfo($"Detected spawned process {spawned.Id} for {baseName}; waiting for it to exit to preserve Steam tracking."); - try - { - // Restart stopwatch to track total session time - sw.Restart(); - await spawned.WaitForExitAsync(); - sw.Stop(); - finalExitCode = spawned.ExitCode; - LogInfo($"Spawned process exited. Exit Code: {finalExitCode}, Total Session Duration: {(int)sw.Elapsed.TotalSeconds}s"); - } - catch (Exception ex) - { - LogError($"Error waiting for spawned process: {ex.Message}"); - } - } - } + var sw = Stopwatch.StartNew(); + var launchStartUtc = DateTime.UtcNow; + using var process = Process.Start(startInfo); + if (process == null) + { + LogError($"Failed to start target process: {config.TargetExecutable}"); + return (1, false); + } - // Log information about problematic exits, but do not show a message box. - // Note: C&C games often exit with non-zero codes (e.g. 0xc0000005) on normal shutdown. - // We only log an error if it happened quickly, suggesting it didn't even start. - // Ensure we just log completion and exit - LogInfo($"Process completed. Final Exit Code: {finalExitCode}"); + LogInfo($"Process started successfully. PID: {process.Id}"); + await process.WaitForExitAsync(); + sw.Stop(); - // Cleanup: Delete temp exe copy if we created one - if (tempExePath != null && File.Exists(tempExePath)) + var finalExitCode = process.ExitCode; + LogInfo($"Process exited. Exit Code: {finalExitCode}, Duration: {(int)sw.Elapsed.TotalSeconds}s"); + + var spawnedFound = false; + if (sw.Elapsed.TotalSeconds < 30) + { + var baseName = Path.GetFileNameWithoutExtension(config.TargetExecutable); + var spawned = TryFindSpawnedProcess(baseName, startInfo.WorkingDirectory, launchStartUtc, process.Id); + if (spawned != null) { + spawnedFound = true; + LogInfo($"Detected spawned process {spawned.Id} for {baseName}; waiting for it to exit to preserve Steam tracking."); try { - File.Delete(tempExePath); - LogInfo($"Cleaned up temp exe: {tempExePath}"); + sw.Restart(); + await spawned.WaitForExitAsync(); + sw.Stop(); + finalExitCode = spawned.ExitCode; + LogInfo($"Spawned process exited. Exit Code: {finalExitCode}, Total Session Duration: {(int)sw.Elapsed.TotalSeconds}s"); } catch (Exception ex) { - LogError($"Failed to cleanup temp exe: {ex.Message}"); + LogError($"Error waiting for spawned process: {ex.Message}"); + } + finally + { + spawned.Dispose(); } } } - catch (Exception ex) + + return (finalExitCode, spawnedFound); + } + + private static void CleanupTempExecutable(string? tempExePath) + { + if (tempExePath != null && File.Exists(tempExePath)) { - LogError($"Critical error in proxy launcher: {ex.Message}"); - finalExitCode = 1; + try + { + File.Delete(tempExePath); + LogInfo($"Cleaned up temp exe: {tempExePath}"); + } + catch (Exception ex) + { + LogError($"Failed to cleanup temp exe: {ex.Message}"); + } } - - return finalExitCode; } /// @@ -318,8 +329,6 @@ private static void EnsureSteamAppId(string appId, string directory) /// True if Steam environment variables are detected. private static bool IsSteamLaunched() { - // Steam typically sets one or more of these when launching a game. - // We use a relaxed check to avoid tight coupling to a single flag. return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SteamClientLaunch")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SteamEnv")) || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("SteamTenfoot")) @@ -334,7 +343,7 @@ private static bool IsSteamLaunched() /// The time when the launch started. /// The PID of the launcher itself to exclude. /// The found process, or null if not found. - private static Process? TryFindSpawnedProcess(string baseName, string? workingDir, DateTime launchStartUtc, int excludedPid) + private static Process? TryFindSpawnedProcess(string? baseName, string? workingDir, DateTime launchStartUtc, int excludedPid) { try { @@ -343,7 +352,6 @@ private static bool IsSteamLaunched() return null; } - // Give the launcher a moment to spawn the real game. Thread.Sleep(ProxyConstants.LauncherToGameSpawnDelayMs); var candidates = Process.GetProcessesByName(baseName); @@ -356,7 +364,6 @@ private static bool IsSteamLaunched() continue; } - // StartTime is local time; compare with a small grace window. var startUtc = p.StartTime.ToUniversalTime(); if (startUtc < launchStartUtc.AddSeconds(-2)) { @@ -401,8 +408,6 @@ private static bool IsSteamLaunched() /// The exit code of the launched process, or 1 if not found. private static async Task TryLaunchBackupAsync(string baseDir, string[] args) { - // Try to find generals.exe.ghbak or similar (using standardized extension) - // This is a naive heuristic, mainly for safety var exeName = Path.GetFileName(Environment.ProcessPath); var backupPath = Path.Combine(baseDir, exeName + global::GenHub.Core.Constants.SteamConstants.BackupExtension); @@ -427,21 +432,6 @@ private static async Task TryLaunchBackupAsync(string baseDir, string[] arg return 1; } - // Simple helper to show error since we might not have console - // In a real scenario, we might want to log to a file - - /// - /// Displays a message box with the specified message and title. - /// - /// The message to display. - /// The title of the message box. - private static void MessageBox(string message, string title) - { - // User explicitly requested to remove all message boxes - // Just log the error - LogError($"{title}: {message}"); - } - /// /// Logs an informational message to the proxy log file. /// @@ -451,7 +441,7 @@ private static void LogInfo(string message) try { var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ProxyConstants.LogFileName); - File.AppendAllText(logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] INFO: {message}{Environment.NewLine}"); + File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}Z] INFO: {message}{Environment.NewLine}"); } catch { @@ -468,7 +458,7 @@ private static void LogError(string message) try { var logPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ProxyConstants.LogFileName); - File.AppendAllText(logPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] ERROR: {message}{Environment.NewLine}"); + File.AppendAllText(logPath, $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}Z] ERROR: {message}{Environment.NewLine}"); } catch { @@ -476,7 +466,7 @@ private static void LogError(string message) } } - private class ProxyConfig + private sealed class ProxyConfig { public string? TargetExecutable { get; set; } diff --git a/GenHub/GenHub.ProxyLauncher/ProxyConstants.cs b/GenHub/GenHub.ProxyLauncher/ProxyConstants.cs index ff791eced..8e3448aae 100644 --- a/GenHub/GenHub.ProxyLauncher/ProxyConstants.cs +++ b/GenHub/GenHub.ProxyLauncher/ProxyConstants.cs @@ -16,9 +16,9 @@ internal static class ProxyConstants public const string LogFileName = "genhub_proxy.log"; /// - /// The name of the mutex used to ensure a single instance. + /// Prefix for the per-installation mutex. /// - public const string SingleInstanceMutexName = "GenHubProxyLauncher_SingleInstance"; + public const string MutexPrefix = "GenHubProxyLauncher_"; /// /// Delay in milliseconds to wait for the launcher to spawn the game process. 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..d0fff137c 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs @@ -2,6 +2,7 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Storage; using Microsoft.Extensions.Logging; @@ -549,6 +550,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 +795,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 +862,536 @@ 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); + } + } + + /// + /// Verifies that GetCsvCatalogConfiguration returns app config values when user settings are not set. + /// + [Fact] + public void GetCsvCatalogConfiguration_WithDefaultSettings_ReturnsAppConfig() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/catalog.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var provider = CreateProvider(); + + // Act + var result = provider.GetCsvCatalogConfiguration(); + + // Assert + Assert.Equal("https://example.com/index.json", result.IndexFilePath); + Assert.Single(result.CsvValidationCatalogs); + Assert.Equal("https://example.com/catalog.csv", result.CsvValidationCatalogs[0].Url); + } + + /// + /// Verifies that GetCsvCatalogConfiguration overrides app config when user settings are explicitly set. + /// + [Fact] + public void GetCsvCatalogConfiguration_WithExplicitUserSettings_OverridesAppConfig() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/app-index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/app.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + var userSettings = new UserSettings + { + IndexFilePath = "https://example.com/user-index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/user.csv", GameType = CsvConstants.ZeroHourGameType }, + ], + }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.IndexFilePath)); + userSettings.MarkAsExplicitlySet(nameof(UserSettings.CsvValidationCatalogs)); + + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetCsvCatalogConfiguration(); + + // Assert + Assert.Equal("https://example.com/user-index.json", result.IndexFilePath); + Assert.Single(result.CsvValidationCatalogs); + Assert.Equal("https://example.com/user.csv", result.CsvValidationCatalogs[0].Url); + Assert.Equal(CsvConstants.ZeroHourGameType, result.CsvValidationCatalogs[0].GameType); + } + + /// + /// Verifies that GetEffectiveSettings includes CSV catalog configuration. + /// + [Fact] + public void GetEffectiveSettings_IncludesCsvCatalogConfiguration() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/catalog.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + var testDataPath = Path.Combine(Path.GetTempPath(), $"genhub-test-{Guid.NewGuid():N}"); + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockAppConfig.Setup(x => x.GetConfiguredDataPath()).Returns(testDataPath); + _mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var provider = CreateProvider(); + + // Act + var settings = provider.GetEffectiveSettings(); + + // Assert + Assert.Equal("https://example.com/index.json", settings.IndexFilePath); + Assert.NotNull(settings.CsvValidationCatalogs); + Assert.Single(settings.CsvValidationCatalogs); + Assert.Equal("https://example.com/catalog.csv", settings.CsvValidationCatalogs[0].Url); + } + + /// + /// Verifies that an explicitly set empty catalog list overrides app config. + /// + [Fact] + public void GetCsvCatalogConfiguration_WithExplicitEmptyList_OverridesAppConfig() + { + // Arrange + var appConfig = new CsvCatalogConfiguration + { + IndexFilePath = "https://example.com/index.json", + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry { Url = "https://example.com/catalog.csv", GameType = CsvConstants.GeneralsGameType }, + ], + }; + var userSettings = new UserSettings + { + CsvValidationCatalogs = [], + }; + userSettings.MarkAsExplicitlySet(nameof(UserSettings.CsvValidationCatalogs)); + + _mockAppConfig.Setup(x => x.GetCsvCatalogConfiguration()).Returns(appConfig); + _mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var provider = CreateProvider(); + + // Act + var result = provider.GetCsvCatalogConfiguration(); + + // Assert + Assert.NotNull(result.CsvValidationCatalogs); + Assert.Empty(result.CsvValidationCatalogs); + } + + /// + /// 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/DownloadServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/DownloadServiceTests.cs index c110cbe47..da7c33220 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/DownloadServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/DownloadServiceTests.cs @@ -1,207 +1,207 @@ -using System.Net; -using GenHub.Common.Services; -using GenHub.Core.Interfaces.Common; -using GenHub.Core.Models.Common; -using Microsoft.Extensions.Logging; -using Moq; -using Moq.Protected; - -namespace GenHub.Tests.Core.Common.Services; - -/// -/// Contains unit tests for the class. -/// -public class DownloadServiceTests -{ - /// - /// Creates a instance with a mocked and . - /// - /// The HTTP message handler to use. - /// The mock logger output. - /// The hash provider to use (optional). - /// A new instance. - public static DownloadService CreateService(HttpMessageHandler handler, out Mock> loggerMock, IFileHashProvider? hashProvider = null) - { - loggerMock = new Mock>(); - var httpClient = new HttpClient(handler); - var hashProviderInstance = hashProvider ?? new Sha256HashProvider(); - return new DownloadService(loggerMock.Object, httpClient, hashProviderInstance); - } - - /// - /// Verifies that a successful download writes the file and returns a successful result. - /// - /// A representing the asynchronous operation. - [Fact] - public async Task DownloadFileAsync_SuccessfulDownload_WritesFileAndReturnsSuccess() - { - // Arrange - var fileContent = new byte[] { 1, 2, 3, 4, 5 }; - var handler = new Mock(); - handler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(fileContent), - }); - var service = CreateService(handler.Object, out _); - var tempFile = Path.GetTempFileName(); - try - { - var config = new DownloadConfiguration - { - Url = new Uri("http://test/file.bin"), - DestinationPath = tempFile, - OverwriteExisting = true, - }; - - // Act - var result = await service.DownloadFileAsync(config); - - // Assert - Assert.True(result.Success); - Assert.True(File.Exists(tempFile)); - Assert.Equal(fileContent, File.ReadAllBytes(tempFile)); - } - finally - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - } - - /// - /// Verifies that hash verification fails and deletes the file if the hash does not match. - /// - /// A representing the asynchronous operation. - [Fact] - public async Task DownloadFileAsync_HashVerification_FailsOnWrongHash() - { - // Arrange - var fileContent = new byte[] { 1, 2, 3 }; - var handler = new Mock(); - handler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync((HttpRequestMessage _, CancellationToken __) => - new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new ByteArrayContent(fileContent), - }); - var service = CreateService(handler.Object, out _); - var tempFile = Path.GetTempFileName(); - try - { - var config = new DownloadConfiguration - { - Url = new Uri("http://test/file.bin"), - DestinationPath = tempFile, - ExpectedHash = "deadbeef", - }; - - // Act - var result = await service.DownloadFileAsync(config); - - // Assert - Assert.False(result.Success); - Assert.Contains("Hash verification failed", result.AllErrors); - - // File should be deleted by the service if hash fails - Assert.False(File.Exists(tempFile)); - } - finally - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - } - - /// - /// Verifies that the download service retries on failure and returns a failed result after max attempts. - /// - /// A representing the asynchronous operation. - [Fact] - public async Task DownloadFileAsync_RetriesOnFailure_AndReturnsFailedResult() - { - // Arrange - var handler = new Mock(); - int callCount = 0; - handler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .Callback(() => callCount++) - .ThrowsAsync(new HttpRequestException("Network error")); - var service = CreateService(handler.Object, out _); - var tempFile = Path.GetTempFileName(); - try - { - var config = new DownloadConfiguration - { - Url = new Uri("http://test/file.bin"), - DestinationPath = tempFile, - MaxRetryAttempts = 2, - RetryDelay = TimeSpan.Zero, - }; - - // Act - var result = await service.DownloadFileAsync(config); - - // Assert - Assert.NotNull(result); - Assert.False(result.Success); - Assert.Contains("Download failed after", result.AllErrors); - Assert.Equal(2, callCount); - } - finally - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - } - - /// - /// Verifies that ComputeFileHashAsync returns the correct SHA256 hash for a file. - /// - /// A representing the asynchronous operation. - [Fact] - public async Task ComputeFileHashAsync_ReturnsCorrectHash() - { - // Arrange - var bytes = new byte[] { 1, 2, 3, 4 }; - var tempFile = Path.GetTempFileName(); - try - { - File.WriteAllBytes(tempFile, bytes); - var handler = new Mock(); - var hashProvider = new Sha256HashProvider(); - var service = CreateService(handler.Object, out _, hashProvider); - - // Act - var hash = await service.ComputeFileHashAsync(tempFile); - - // Assert - var expected = BitConverter.ToString(System.Security.Cryptography.SHA256.HashData(bytes)).Replace("-", string.Empty).ToLowerInvariant(); - Assert.Equal(expected, hash); - } - finally - { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } - } - } -} +using System.Net; +using GenHub.Common.Services; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; + +namespace GenHub.Tests.Core.Common.Services; + +/// +/// Contains unit tests for the class. +/// +public class DownloadServiceTests +{ + /// + /// Creates a instance with a mocked and . + /// + /// The HTTP message handler to use. + /// The mock logger output. + /// The hash provider to use (optional). + /// A new instance. + public static DownloadService CreateService(HttpMessageHandler handler, out Mock> loggerMock, IFileHashProvider? hashProvider = null) + { + loggerMock = new Mock>(); + var httpClient = new HttpClient(handler); + var hashProviderInstance = hashProvider ?? new Sha256HashProvider(); + return new DownloadService(loggerMock.Object, httpClient, hashProviderInstance); + } + + /// + /// Verifies that a successful download writes the file and returns a successful result. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFileAsync_SuccessfulDownload_WritesFileAndReturnsSuccessAsync() + { + // Arrange + var fileContent = new byte[] { 1, 2, 3, 4, 5 }; + var handler = new Mock(); + handler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(fileContent), + }); + var service = CreateService(handler.Object, out _); + var tempFile = Path.GetTempFileName(); + try + { + var config = new DownloadConfiguration + { + Url = new Uri("http://test/file.bin"), + DestinationPath = tempFile, + OverwriteExisting = true, + }; + + // Act + var result = await service.DownloadFileAsync(config); + + // Assert + Assert.True(result.Success); + Assert.True(File.Exists(tempFile)); + Assert.Equal(fileContent, File.ReadAllBytes(tempFile)); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Verifies that hash verification fails and deletes the file if the hash does not match. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFileAsync_HashVerification_FailsOnWrongHashAsync() + { + // Arrange + var fileContent = new byte[] { 1, 2, 3 }; + var handler = new Mock(); + handler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync((HttpRequestMessage _, CancellationToken __) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(fileContent), + }); + var service = CreateService(handler.Object, out _); + var tempFile = Path.GetTempFileName(); + try + { + var config = new DownloadConfiguration + { + Url = new Uri("http://test/file.bin"), + DestinationPath = tempFile, + ExpectedHash = "deadbeef", + }; + + // Act + var result = await service.DownloadFileAsync(config); + + // Assert + Assert.False(result.Success); + Assert.Contains("Hash verification failed", result.AllErrors); + + // File should be deleted by the service if hash fails + Assert.False(File.Exists(tempFile)); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Verifies that the download service retries on failure and returns a failed result after max attempts. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DownloadFileAsync_RetriesOnFailure_AndReturnsFailedResultAsync() + { + // Arrange + var handler = new Mock(); + int callCount = 0; + handler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback(() => callCount++) + .ThrowsAsync(new HttpRequestException("Network error")); + var service = CreateService(handler.Object, out _); + var tempFile = Path.GetTempFileName(); + try + { + var config = new DownloadConfiguration + { + Url = new Uri("http://test/file.bin"), + DestinationPath = tempFile, + MaxRetryAttempts = 2, + RetryDelay = TimeSpan.Zero, + }; + + // Act + var result = await service.DownloadFileAsync(config); + + // Assert + Assert.NotNull(result); + Assert.False(result.Success); + Assert.Contains("Download failed after", result.AllErrors); + Assert.Equal(2, callCount); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } + + /// + /// Verifies that ComputeFileHashAsync returns the correct SHA256 hash for a file. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task ComputeFileHashAsync_ReturnsCorrectHashAsync() + { + // Arrange + var bytes = new byte[] { 1, 2, 3, 4 }; + var tempFile = Path.GetTempFileName(); + try + { + File.WriteAllBytes(tempFile, bytes); + var handler = new Mock(); + var hashProvider = new Sha256HashProvider(); + var service = CreateService(handler.Object, out _, hashProvider); + + // Act + var hash = await service.ComputeFileHashAsync(tempFile); + + // Assert + var expected = BitConverter.ToString(System.Security.Cryptography.SHA256.HashData(bytes)).Replace("-", string.Empty).ToLowerInvariant(); + Assert.Equal(expected, hash); + } + finally + { + if (File.Exists(tempFile)) + { + File.Delete(tempFile); + } + } + } +} 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/ThemeServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ThemeServiceTests.cs new file mode 100644 index 000000000..c62f62396 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ThemeServiceTests.cs @@ -0,0 +1,111 @@ +using System.Linq; +using GenHub.Common.Services; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Theming; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Common.Services; + +/// +/// Unit tests for and application theming constants. +/// +public class ThemeServiceTests +{ + private readonly Mock _mockConfigProvider; + private readonly ThemeService _service; + + /// + /// Initializes a new instance of the class. + /// + public ThemeServiceTests() + { + _mockConfigProvider = new Mock(); + _mockConfigProvider.Setup(s => s.GetTheme()).Returns("Purple"); + + _service = new ThemeService(_mockConfigProvider.Object, NullLogger.Instance); + } + + /// + /// Verifies that all expected built-in themes are present. + /// + [Fact] + public void AvailableThemes_ContainsAllExpectedPalettes() + { + // Assert + Assert.NotNull(_service.AvailableThemes); + Assert.True(_service.AvailableThemes.Count >= 12); + + var themeIds = _service.AvailableThemes.Select(t => t.Id).ToList(); + Assert.Contains("Purple", themeIds); + Assert.Contains("Generals", themeIds); + Assert.Contains("ZeroHour", themeIds); + Assert.Contains("Emerald", themeIds); + Assert.Contains("Crimson", themeIds); + Assert.Contains("Amber", themeIds); + Assert.Contains("Cobalt", themeIds); + Assert.Contains("Rose", themeIds); + Assert.Contains("Tiberium", themeIds); + Assert.Contains("Teal", themeIds); + Assert.Contains("Indigo", themeIds); + Assert.Contains("Ruby", themeIds); + } + + /// + /// Verifies that default theme is Void Purple. + /// + [Fact] + public void CurrentTheme_Initially_ReturnsDefaultTheme() + { + // Assert + Assert.Equal(ThemeConstants.DefaultTheme.Id, _service.CurrentTheme.Id); + Assert.Equal("#A855F7", _service.CurrentTheme.PrimaryHex); + } + + /// + /// Verifies that applying a theme by ID updates CurrentTheme. + /// + [Fact] + public void ApplyTheme_ById_UpdatesCurrentTheme() + { + // Act + _service.ApplyTheme("Generals"); + + // Assert + Assert.Equal("Generals", _service.CurrentTheme.Id); + Assert.Equal("Generals Orange", _service.CurrentTheme.DisplayName); + } + + /// + /// Verifies that applying an invalid theme falls back to default. + /// + [Fact] + public void ApplyTheme_InvalidTheme_FallsBackToDefault() + { + // Act + _service.ApplyTheme("NonExistentTheme"); + + // Assert + Assert.Equal(ThemeConstants.DefaultTheme.Id, _service.CurrentTheme.Id); + } + + /// + /// Verifies that InitializeTheme restores saved theme from settings. + /// + [Fact] + public void InitializeTheme_RestoresSavedTheme() + { + // Arrange + _mockConfigProvider.Setup(s => s.GetTheme()).Returns("Emerald"); + + // Act + _service.InitializeTheme(); + + // Assert + Assert.Equal("Emerald", _service.CurrentTheme.Id); + Assert.Equal("Emerald Green", _service.CurrentTheme.DisplayName); + } +} 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 8c666a17a..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); } @@ -74,7 +76,7 @@ public void Get_WhenNoFileExists_ReturnsDefaultUserSettings() /// /// A representing the asynchronous test operation. [Fact] - public async Task SaveAsync_CreatesFileWithCorrectData() + public async Task SaveAsync_CreatesFileWithCorrectDataAsync() { var service = CreateService(); var settingsPath = Path.Combine(_tempDirectory, FileTypes.JsonFileExtension); @@ -99,7 +101,7 @@ public async Task SaveAsync_CreatesFileWithCorrectData() /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadSettings_AfterSave_LoadsCorrectData() + public async Task LoadSettings_AfterSave_LoadsCorrectDataAsync() { // Use a unique temp directory for this test var testDir = Path.Combine(_tempDirectory, Guid.NewGuid().ToString()); @@ -137,7 +139,7 @@ public async Task LoadSettings_AfterSave_LoadsCorrectData() /// /// A task representing the asynchronous test operation. [Fact] - public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMarker() + public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMarkerAsync() { var settingsPath = Path.Combine(_tempDirectory, "provenance", FileTypes.SettingsFileName); var historicalPoolPath = "/historical/installation/.genhub-cas"; @@ -170,7 +172,7 @@ public async Task LoadSettings_AfterSave_PreservesInstallationPoolProvenanceMark /// /// A representing the asynchronous test operation. [Fact] - public async Task GetSettings_WithCorruptedJson_ReturnsDefaults() + public async Task GetSettings_WithCorruptedJson_ReturnsDefaultsAsync() { var testDir = Path.Combine(_tempDirectory, Guid.NewGuid().ToString()); Directory.CreateDirectory(testDir); @@ -226,15 +228,12 @@ public void GetSettings_ReturnsIndependentCopy() /// /// A representing the asynchronous test operation. [Fact] - public async Task SaveAsync_CreatesDirectoryIfNotExists() + 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)); @@ -255,17 +254,14 @@ public void UpdateSettings_WithNullAction_ThrowsArgumentNullException() /// /// A representing the asynchronous test operation. [Fact] - public async Task SaveAsync_WithLongPath_CreatesNestedDirectories() + public async Task SaveAsync_WithLongPath_CreatesNestedDirectoriesAsync() { // Arrange var deepPath = Path.Combine(_tempDirectory, "very", "deep", "nested", "path"); 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..9e9d2c729 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs @@ -0,0 +1,134 @@ +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.PrMergedUpdateAvailableNotificationTitle)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle)); + 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.Equal("development", AppUpdateConstants.DevelopmentBranch); + Assert.Equal("main", AppUpdateConstants.MainBranch); + 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.PrMergedUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrMergedUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrMergedReleaseNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.PrMergedReleaseNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchStaleUpdateNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchStaleUpdateNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchStaleReleaseNotificationFormat); + Assert.Contains("{1}", AppUpdateConstants.BranchStaleReleaseNotificationFormat); + Assert.Contains("{0}", AppUpdateConstants.PrMergedStatusMessageFormat); + Assert.Contains("{0}", AppUpdateConstants.BranchStaleStatusMessageFormat); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PatRequiredForArtifactsMessage)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrFallbackDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchFallbackDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ReleaseDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.GitHubFallbackDedupePrefix)); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.NotificationAlreadyShownLogFormat)); + Assert.Contains("{Identity}", AppUpdateConstants.NotificationAlreadyShownLogFormat); + Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.LoadingMessage)); + Assert.Equal("Loading...", AppUpdateConstants.LoadingMessage); + 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/Constants/GameClientHashRegistryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs index 2654792ee..9218f9e01 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/GameClientHashRegistryTests.cs @@ -82,6 +82,18 @@ public void PossibleExecutableNames_AreConfigured() Assert.Contains("generalsonlinezh_60.exe", names); } + /// + /// Since 060526_QFE1 the GeneralsOnline portable launches through the Easy Anti-Cheat + /// bootstrapper, so directory scans have to recognise it as a client executable. + /// + [Fact] + public void PossibleExecutableNames_IncludeTheGeneralsOnlineAntiCheatBootstrapper() + { + Assert.Contains( + _registry.PossibleExecutableNames, + name => name.Equals(GameClientConstants.GeneralsOnlineEacLauncherExecutable, StringComparison.OrdinalIgnoreCase)); + } + /// /// Verifies that GameClientInfo.Validate() works correctly. /// 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/BackgroundUpdateCoordinatorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/BackgroundUpdateCoordinatorTests.cs new file mode 100644 index 000000000..9b24db6e5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/BackgroundUpdateCoordinatorTests.cs @@ -0,0 +1,647 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Messages; +using GenHub.Core.Models.AppUpdate; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Notifications; +using GenHub.Features.AppUpdate.Interfaces; +using GenHub.Features.AppUpdate.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace GenHub.Tests.Core.Features.AppUpdate.Services; + +/// +/// Contains unit tests for the class. +/// +public class BackgroundUpdateCoordinatorTests +{ + /// + /// Verifies that when startup update check is disabled, no update checks are performed on initialize. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task InitializeAsync_WhenStartupCheckDisabled_DoesNotCheckUpdatesAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { AutoCheckForUpdatesOnStartup = false, AutoCheckForUpdatesPeriodically = false }); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.InitializeAsync(); + + mockVelopack.Verify(x => x.CheckForUpdatesAsync(It.IsAny()), Times.Never); + mockVelopack.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.Never); + } + + /// + /// Verifies that when a subscribed PR is merged or closed, update checking falls back to the development branch artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenPrIsMerged_FallsBackToDevelopmentArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedPrNumber = 265, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 265); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedPrNumber == 265) + { + mockVelopack.SetupGet(x => x.IsPrMergedOrClosed).Returns(true); + return Task.FromResult(null); + } + + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.True(updateNotification.IsPersistent); + Assert.True(updateNotification.ShowInBadge); + Assert.Contains("265", updateNotification.Message); + Assert.Single(updateNotification.Actions); + } + + /// + /// Verifies that when a subscribed custom branch has no artifacts, update checking falls back to the development branch artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCustomBranchIsStale_FallsBackToDevelopmentArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedBranch = "feat/deleted-branch", + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, null); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/deleted-branch"); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.True(updateNotification.IsPersistent); + Assert.True(updateNotification.ShowInBadge); + Assert.Contains("feat/deleted-branch", updateNotification.Message); + Assert.Single(updateNotification.Actions); + } + + /// + /// Verifies that when a custom branch has no artifacts and releases are checked via GitHub API, fallback notification is displayed. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCustomBranchIsStaleAndNoArtifact_FallsBackToGitHubApiReleaseAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + SubscribedBranch = "feat/deleted-branch", + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, null); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/deleted-branch"); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync((ArtifactUpdateInfo?)null); + mockVelopack.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) + .ReturnsAsync((Velopack.UpdateInfo?)null); + mockVelopack.SetupGet(x => x.HasUpdateAvailableFromGitHub).Returns(true); + mockVelopack.SetupGet(x => x.LatestVersionFromGitHub).Returns("1.5.0"); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Equal(AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle, updateNotification.Title); + Assert.Contains("1.5.0", updateNotification.Message); + } + + /// + /// Verifies that receiving an update settings changed message restarts the periodic timer without exception. + /// + [Fact] + public void Receive_UpdateSettingsChangedMessage_RestartsPeriodicTimerWithoutException() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + var message = new UpdateSettingsChangedMessage( + AutoCheckForUpdatesOnStartup: true, + AutoCheckForUpdatesPeriodically: true, + PeriodicUpdateCheckIntervalMinutes: 15); + + var exception = Record.Exception(() => coordinator.Receive(message)); + Assert.Null(exception); + } + + /// + /// Verifies that when an artifact update is available, the notification action installs the artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenArtifactUpdateAvailable_NotificationActionInstallsArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedPrNumber = 100, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-pr100", + GitHash: "abc1234", + PullRequestNumber: 100, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 100); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.PrUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Single(updateNotification.Actions); + + // Execute the action to verify install + updateNotification.Actions[0].Callback?.Invoke(); + + mockVelopack.Verify( + x => x.InstallArtifactAsync(artifactInfo, It.IsAny>(), It.IsAny()), + Times.Once); + } + + /// + /// Verifies that checking updates repeatedly with the same version deduplicates notifications. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenSameUpdateCheckedRepeatedly_DeduplicatesNotificationAsync() + { + var showCount = 0; + + var userSettings = new UserSettings + { + SubscribedPrNumber = 100, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + + var artifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-pr100", + GitHash: "abc1234", + PullRequestNumber: 100, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 100); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync(artifactInfo); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(_ => Interlocked.Increment(ref showCount)); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + await coordinator.CheckForUpdatesAsync(); + + Assert.Equal(1, showCount); + } + + /// + /// Verifies that cancellation token propagation cancels update checks. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await Assert.ThrowsAnyAsync(() => coordinator.CheckForUpdatesAsync(cts.Token)); + } + + /// + /// Verifies that executing the PR merged fallback notification action clears the subscribed PR setting and installs the dev artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenPrMerged_NotificationActionClearsSubscriptionAndInstallsDevArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedPrNumber = 265, + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + mockUserSettings.Setup(x => x.Update(It.IsAny>())) + .Callback>(action => action(userSettings)); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 265); + mockVelopack.SetupProperty(x => x.SubscribedBranch, null); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedPrNumber == 265) + { + mockVelopack.SetupGet(x => x.IsPrMergedOrClosed).Returns(true); + return Task.FromResult(null); + } + + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.PrMergedUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Single(updateNotification.Actions); + + // Execute action + updateNotification.Actions[0].Callback?.Invoke(); + + // Wait brief delay for async install and settings update + await Task.Delay(100); + + mockVelopack.Verify( + x => x.InstallArtifactAsync(devArtifactInfo, It.IsAny>(), It.IsAny()), + Times.Once); + Assert.Null(userSettings.SubscribedPrNumber); + mockUserSettings.Verify(x => x.SaveAsync(It.IsAny()), Times.Once); + } + + /// + /// Verifies that executing the stale branch fallback notification action clears the subscribed branch setting and installs the dev artifact. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task CheckForUpdatesAsync_WhenCustomBranchStale_NotificationActionClearsSubscriptionAndInstallsDevArtifactAsync() + { + var notificationShownTcs = new TaskCompletionSource(); + + var userSettings = new UserSettings + { + AutoCheckForUpdatesOnStartup = true, + SubscribedBranch = "feat/old-branch", + DismissedUpdateVersion = null, + }; + + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(userSettings); + mockUserSettings.Setup(x => x.Update(It.IsAny>())) + .Callback>(action => action(userSettings)); + + var devArtifactInfo = new ArtifactUpdateInfo( + Version: "0.0.99999-development", + GitHash: "abc1234", + PullRequestNumber: null, + WorkflowRunId: 12345, + WorkflowRunUrl: "https://example.com/runs/1", + ArtifactId: 67890, + ArtifactName: "genhub-velopack-linux-0.0.99999", + CreatedAt: DateTime.UtcNow, + DownloadUrl: "https://example.com/artifact.zip", + Size: 1024); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, null); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/old-branch"); + + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .Returns(_ => + { + if (mockVelopack.Object.SubscribedBranch == AppUpdateConstants.DevelopmentBranch) + { + return Task.FromResult(devArtifactInfo); + } + + return Task.FromResult(null); + }); + + var mockNotificationService = CreateNotificationServiceMock(); + mockNotificationService.Setup(x => x.Show(It.IsAny())) + .Callback(msg => + { + if (msg.Title == AppUpdateConstants.BranchStaleUpdateAvailableNotificationTitle) + { + notificationShownTcs.TrySetResult(msg); + } + }); + + var mockLogger = new Mock>(); + + using var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + await coordinator.CheckForUpdatesAsync(); + var updateNotification = await notificationShownTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotNull(updateNotification); + Assert.Single(updateNotification.Actions); + + // Execute action + updateNotification.Actions[0].Callback?.Invoke(); + + await Task.Delay(100); + + mockVelopack.Verify( + x => x.InstallArtifactAsync(devArtifactInfo, It.IsAny>(), It.IsAny()), + Times.Once); + Assert.Null(userSettings.SubscribedBranch); + mockUserSettings.Verify(x => x.SaveAsync(It.IsAny()), Times.Once); + } + + /// + /// Verifies that Dispose can be safely called multiple times. + /// + [Fact] + public void Dispose_CanBeCalledMultipleTimesWithoutThrowing() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + var mockNotificationService = CreateNotificationServiceMock(); + var mockLogger = new Mock>(); + + var coordinator = new BackgroundUpdateCoordinator( + mockVelopack.Object, + mockUserSettings.Object, + mockNotificationService.Object, + mockLogger.Object); + + var exception = Record.Exception(() => + { + coordinator.Dispose(); + coordinator.Dispose(); + }); + + Assert.Null(exception); + } + + private static Mock CreateNotificationServiceMock() + { + var mock = new Mock(); + mock.Setup(x => x.Notifications).Returns(Observable.Empty()); + mock.Setup(x => x.NotificationHistory).Returns(Observable.Empty()); + mock.Setup(x => x.DismissRequests).Returns(Observable.Empty()); + mock.Setup(x => x.DismissAllRequests).Returns(Observable.Empty()); + mock.Setup(x => x.UpdateRequests).Returns(Observable.Empty<(Guid Id, string? Title, string Message)>()); + return mock; + } +} 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/OctokitGitHubApiClientTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs index e3242662c..7f6f802e7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs @@ -18,7 +18,7 @@ public class OctokitGitHubApiClientTests /// /// A task representing the asynchronous test operation. [Fact] - public async Task GetLatestReleaseAsync_ReturnsNullWhenNotFound() + public async Task GetLatestReleaseAsync_ReturnsNullWhenNotFoundAsync() { // Arrange var releasesClientMock = new Mock(); @@ -52,7 +52,7 @@ public async Task GetLatestReleaseAsync_ReturnsNullWhenNotFound() /// /// A task representing the asynchronous test operation. [Fact] - public async Task GetReleasesAsync_ReturnsEmptyCollectionWhenNoReleases() + public async Task GetReleasesAsync_ReturnsEmptyCollectionWhenNoReleasesAsync() { // Arrange var releasesClientMock = new Mock(); @@ -132,4 +132,118 @@ public void SetAuthenticationToken_ThrowsWithMockClient() // Act & Assert Assert.Throws(() => api.SetAuthenticationToken(secureToken)); } + + /// + /// Verifies that credentials are automatically loaded from IGitHubTokenStorage. + /// + [Fact] + public void EnsureCredentialsLoaded_LoadsFromTokenStorage() + { + // Arrange + var concreteClient = new GitHubClient(new ProductHeaderValue("test")); + var secureToken = new SecureString(); + foreach (char c in "stored-secret-pat") + { + secureToken.AppendChar(c); + } + + var tokenStorageMock = new Mock(); + tokenStorageMock.Setup(x => x.HasToken()).Returns(true); + tokenStorageMock.Setup(x => x.LoadTokenAsync()).ReturnsAsync(secureToken); + + var api = new OctokitGitHubApiClient( + concreteClient, + Mock.Of(), + Mock.Of>(), + Mock.Of(), + tokenStorageMock.Object); + + // Act & Assert + api.IsAuthenticated.Should().BeTrue(); + concreteClient.Credentials.Should().NotBeNull(); + concreteClient.Credentials.Password.Should().Be("stored-secret-pat"); + } + + /// + /// Verifies that ClearAuthenticationToken resets credentials to Anonymous. + /// + [Fact] + public void ClearAuthenticationToken_ResetsCredentialsToAnonymous() + { + // Arrange + var concreteClient = new GitHubClient(new ProductHeaderValue("test")); + var api = new OctokitGitHubApiClient( + concreteClient, + Mock.Of(), + Mock.Of>(), + Mock.Of()); + + var secureToken = new SecureString(); + foreach (char c in "test-token") + { + secureToken.AppendChar(c); + } + + api.SetAuthenticationToken(secureToken); + api.IsAuthenticated.Should().BeTrue(); + + // Act + api.ClearAuthenticationToken(); + + // Assert + api.IsAuthenticated.Should().BeFalse(); + concreteClient.Credentials.Should().Be(Credentials.Anonymous); + } + + /// + /// Verifies that rate limit tracker is updated when RateLimitExceededException occurs. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetLatestReleaseAsync_WhenRateLimitExceeded_UpdatesTrackerAsync() + { + // Arrange + var resetEpoch = ((DateTimeOffset)DateTime.UtcNow.AddMinutes(30)).ToUnixTimeSeconds(); + var headers = new Dictionary + { + ["X-RateLimit-Reset"] = resetEpoch.ToString(), + }; + var responseMock = new Mock(); + responseMock.SetupGet(x => x.Headers).Returns(headers); + var rateLimit = new Octokit.RateLimit(60, 0, resetEpoch); + var apiInfo = new Octokit.ApiInfo(new Dictionary(), new List(), new List(), "etag", rateLimit); + responseMock.SetupGet(x => x.ApiInfo).Returns(apiInfo); + + var rateLimitException = new RateLimitExceededException(responseMock.Object); + + var releasesClientMock = new Mock(); + releasesClientMock + .Setup(x => x.GetLatest(It.IsAny(), It.IsAny())) + .ThrowsAsync(rateLimitException); + + var repositoriesClientMock = new Mock(); + repositoriesClientMock + .SetupGet(x => x.Release) + .Returns(releasesClientMock.Object); + + var gitHubClientMock = new Mock(); + gitHubClientMock.SetupGet(x => x.Repository).Returns(repositoriesClientMock.Object); + + var tracker = new GitHubRateLimitTracker(Mock.Of>()); + + var api = new OctokitGitHubApiClient( + gitHubClientMock.Object, + Mock.Of(), + Mock.Of>(), + Mock.Of(), + rateLimitTracker: tracker); + + // Act + var result = await api.GetLatestReleaseAsync("owner", "repo"); + + // Assert + result.Should().BeNull(); + api.IsRateLimited.Should().BeTrue(); + tracker.IsAtLimit.Should().BeTrue(); + } } \ No newline at end of file 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 8a12c2a4e..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; @@ -55,7 +56,7 @@ public void Constructor_ShouldInitializeSuccessfully() /// /// A representing the asynchronous operation. [Fact] - public async Task CheckForUpdatesAsync_InDevEnvironment_ShouldReturnNull() + public async Task CheckForUpdatesAsync_InDevEnvironment_ShouldReturnNullAsync() { // Arrange var manager = CreateManager(); @@ -72,7 +73,7 @@ public async Task CheckForUpdatesAsync_InDevEnvironment_ShouldReturnNull() /// /// A representing the asynchronous operation. [Fact] - public async Task CheckForUpdatesAsync_WithCancellation_ShouldHandleGracefully() + public async Task CheckForUpdatesAsync_WithCancellation_ShouldHandleGracefullyAsync() { // Arrange var manager = CreateManager(); @@ -91,7 +92,7 @@ public async Task CheckForUpdatesAsync_WithCancellation_ShouldHandleGracefully() /// /// A representing the asynchronous operation. [Fact] - public async Task DownloadUpdatesAsync_WhenNotInitialized_ShouldThrowInvalidOperationException() + public async Task DownloadUpdatesAsync_WhenNotInitialized_ShouldThrowInvalidOperationExceptionAsync() { // Arrange var manager = CreateManager(); @@ -174,7 +175,7 @@ public void VelopackUpdateManager_ShouldUseCorrectRepositoryUrl() /// /// A representing the asynchronous operation. [Fact] - public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNull() + public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNullAsync() { // Arrange _mockGitHubTokenStorage.Setup(x => x.HasToken()).Returns(false); @@ -188,6 +189,28 @@ public async Task CheckForArtifactUpdatesAsync_WithoutPAT_ShouldReturnNull() 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 8b3043ff2..a195175b0 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,17 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GitHub; +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; @@ -16,14 +25,14 @@ public class UpdateNotificationViewModelTests /// /// A representing the asynchronous operation. [Fact] - public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatus() + public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsync() { var mockVelopack = new Mock(); mockVelopack.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) .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 +52,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatus() 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 +72,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 +81,679 @@ 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); + } + + /// + /// Verifies that when a subscribed PR is merged or closed, CheckForUpdates sets ShowPrMergedWarning and formats the status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenSubscribedPrIsMerged_ShowsMergedWarningAndStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 265 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 265); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync((ArtifactUpdateInfo?)null); + mockVelopack.SetupGet(x => x.IsPrMergedOrClosed).Returns(true); + mockVelopack.Setup(x => x.GetBranchesAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockVelopack.Setup(x => x.GetOpenPullRequestsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var mockTokenStorage = new Mock(); + mockTokenStorage.Setup(x => x.HasToken()).Returns(true); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + mockTokenStorage.Object); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.True(vm.ShowPrMergedWarning); + Assert.Contains("merged", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when a subscribed custom branch has no artifacts and PAT is configured, CheckForUpdates sets stale branch status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenCustomBranchHasNoArtifactsAndPatPresent_SetsStaleStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "feat/deleted-branch" }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/deleted-branch"); + mockVelopack.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny())) + .ReturnsAsync((ArtifactUpdateInfo?)null); + mockVelopack.Setup(x => x.GetBranchesAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockVelopack.Setup(x => x.GetOpenPullRequestsAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + + var mockTokenStorage = new Mock(); + mockTokenStorage.Setup(x => x.HasToken()).Returns(true); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + mockTokenStorage.Object); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.Contains("no available builds", vm.StatusMessage, StringComparison.OrdinalIgnoreCase); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when subscribed to a branch but no PAT is configured, CheckForUpdates sets PAT required status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenSubscribedBranchAndNoPat_SetsPatRequiredStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "feat/some-branch" }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedBranch, "feat/some-branch"); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + gitHubTokenStorage: null); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.Equal(AppUpdateConstants.PatRequiredForArtifactsMessage, vm.StatusMessage); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when subscribed to a PR but no PAT is configured, CheckForUpdates sets PAT required status message. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CheckForUpdatesCommand_WhenSubscribedPrAndNoPat_SetsPatRequiredStatusAsync() + { + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 42 }); + + var mockVelopack = new Mock(); + mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 42); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object, + gitHubTokenStorage: null); + + await ((CommunityToolkit.Mvvm.Input.IAsyncRelayCommand)vm.CheckForUpdatesCommand).ExecuteAsync(null); + + Assert.Equal(AppUpdateConstants.PatRequiredForArtifactsMessage, vm.StatusMessage); + Assert.False(vm.IsUpdateAvailable); + } + + /// + /// Verifies that when subscribing to a branch, the install button text changes to loading and download is disabled until artifacts finish loading. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SubscribeToBranch_WhileLoading_DisablesDownloadAndShowsLoadingTextAsync() + { + var mockVelopack = new Mock(); + var mockUserSettings = new Mock(); + mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings()); + + var tcs = new TaskCompletionSource>(); + mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("feat/test", It.IsAny())) + .Returns(tcs.Task); + + using var vm = new UpdateNotificationViewModel( + mockVelopack.Object, + Mock.Of>(), + mockUserSettings.Object); + + vm.SubscribeToBranchCommand.Execute("feat/test"); + + Assert.True(vm.IsLoadingVersions); + Assert.True(vm.IsLoadingOrInstalling); + Assert.Equal("Loading...", vm.InstallButtonText); + Assert.False(vm.CanDownloadUpdate); + + tcs.SetResult([ + new ArtifactUpdateInfo("0.0.100-feat-test", "abcdef1", null, 100, "https://github.com/run/1", 10, "genhub.zip", DateTime.UtcNow, "https://github.com/art/1", 1024), + ]); + + var timeout = DateTime.UtcNow.AddSeconds(2); + while (vm.IsLoadingVersions && DateTime.UtcNow < timeout) + { + await Task.Delay(10); + } + + Assert.False(vm.IsLoadingVersions); + Assert.False(vm.IsLoadingOrInstalling); + Assert.NotNull(vm.SelectedVersion); + Assert.True(vm.CanDownloadUpdate); + Assert.Equal("Install Update", vm.InstallButtonText); + } } \ 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 a21b91fed..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_ValidatesManifestBeforePreparation() + 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_ValidatesManifestBeforePreparation() }) .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,18 +71,111 @@ public async Task PrepareContentAsync_ValidatesManifestBeforePreparation() // 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. /// /// A task representing the asynchronous operation. [Fact] - public async Task PrepareContentAsync_FailsWhenManifestValidationHasErrors() + 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_FailsWhenManifestValidationHasErrors() 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/CNCLabsMapDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs index 6843eef2f..9434147bd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CNCLabsMapDiscovererTests.cs @@ -35,7 +35,7 @@ public CNCLabsMapDiscovererTests() /// /// A task representing the asynchronous test operation. [Fact] - public async Task DiscoverAsync_NullQuery_ReturnsFailure() + public async Task DiscoverAsync_NullQuery_ReturnsFailureAsync() { // Arrange using var http = CreateHttpClient(_ => new HttpResponseMessage(HttpStatusCode.OK)); @@ -56,7 +56,7 @@ public async Task DiscoverAsync_NullQuery_ReturnsFailure() /// /// A task representing the asynchronous test operation. [Fact] - public async Task DiscoverAsync_MissingSearchTermAndFilters_ReturnsFailure() + public async Task DiscoverAsync_MissingSearchTermAndFilters_ReturnsFailureAsync() { // Arrange: neither SearchTerm nor both TargetGame & ContentType var query = new ContentSearchQuery @@ -82,7 +82,7 @@ public async Task DiscoverAsync_MissingSearchTermAndFilters_ReturnsFailure() /// /// A task representing the asynchronous test operation. [Fact] - public async Task DiscoverAsync_CancellationRequested_ReturnsFailure() + public async Task DiscoverAsync_CancellationRequested_ReturnsFailureAsync() { // Arrange var query = new ContentSearchQuery @@ -110,7 +110,7 @@ public async Task DiscoverAsync_CancellationRequested_ReturnsFailure() /// /// A task representing the asynchronous test operation. [Fact] - public async Task DiscoverAsync_HttpThrows_ReturnsFailure_AndLogs() + public async Task DiscoverAsync_HttpThrows_ReturnsFailure_AndLogsAsync() { // Arrange - any request throws using var http = new HttpClient(new ThrowingHandler(new HttpRequestException("boom"))); @@ -138,7 +138,7 @@ public async Task DiscoverAsync_HttpThrows_ReturnsFailure_AndLogs() /// /// A task representing the asynchronous test operation. [Fact] - public async Task DiscoverAsync_WithFilters_ParsesListAndProjectsResults() + public async Task DiscoverAsync_WithFilters_ParsesListAndProjectsResultsAsync() { // Arrange var query = new ContentSearchQuery diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs index 79cc0a612..8a9fa81b4 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostDiscovererTests.cs @@ -86,7 +86,7 @@ public void CommunityPatchIdFormat_SpecificationDocumentation() /// /// A task representing the asynchronous test operation. [Fact] - public async Task DiscoverAsync_GeneratesCorrectIdForCommunityPatch() + public async Task DiscoverAsync_GeneratesCorrectIdForCommunityPatchAsync() { // Arrange var mockHttp = new Mock(); @@ -100,9 +100,9 @@ public async Task DiscoverAsync_GeneratesCorrectIdForCommunityPatch() PublisherType = "communityoutpost", DisplayName = "Community Outpost", }; - provider.Endpoints.CatalogUrl = "http://example.com/dl.dat"; + provider.Endpoints.CatalogUrl = "https://example.com/dl.dat"; provider.Endpoints.Mirrors.Add(new MirrorEndpoint { Name = "Main", Priority = 1 }); - provider.Endpoints.Custom["patchPageUrl"] = "http://example.com/patch"; + provider.Endpoints.Custom["patchPageUrl"] = "https://example.com/patch"; var htmlContent = @"Download Latest"; var handler = new Mock(); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs index 8126cb280..b976ece3e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostManifestFactoryTests.cs @@ -59,20 +59,24 @@ public void Dispose() /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateManifestsFromExtractedContentAsync_WithHleiPackage_ShouldSplitIntoMultipleManifests() + public async Task CreateManifestsFromExtractedContentAsync_WithHleiPackage_ShouldSplitIntoMultipleManifestsAsync() { // Arrange var zhEnDir = Path.Combine(_tempDir, "ZH", "BIG EN"); var zhDeDir = Path.Combine(_tempDir, "ZH", "BIG DE"); + var zhRuDir = Path.Combine(_tempDir, "ZH", "BIG RU"); var ccgEnDir = Path.Combine(_tempDir, "CCG", "BIG EN"); Directory.CreateDirectory(zhEnDir); Directory.CreateDirectory(zhDeDir); + Directory.CreateDirectory(zhRuDir); Directory.CreateDirectory(ccgEnDir); File.WriteAllText(Path.Combine(zhEnDir, "!HotkeysLeikezeENZH.big"), "mock content"); File.WriteAllText(Path.Combine(zhDeDir, "!HotkeysLeikezeDEZH.big"), "mock content"); + File.WriteAllText(Path.Combine(zhRuDir, "!HotkeysLeikezeRUZH.big"), "mock content"); File.WriteAllText(Path.Combine(ccgEnDir, "!HotkeysLeikezeEN.big"), "mock content"); + File.WriteAllText(Path.Combine(_tempDir, "!HotkeysLeikezeIndicatorsZH.big"), "mock indicator"); var originalManifest = new ContentManifest { @@ -90,23 +94,31 @@ public async Task CreateManifestsFromExtractedContentAsync_WithHleiPackage_Shoul var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir); // Assert - Assert.Equal(3, manifests.Count); + Assert.Equal(4, manifests.Count); var zhEnManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-zerohour-en")); Assert.NotNull(zhEnManifest); Assert.Equal(GameType.ZeroHour, zhEnManifest.TargetGame); Assert.Contains("(EN)", zhEnManifest.Name); - Assert.Single(zhEnManifest.Files); + Assert.Equal(2, zhEnManifest.Files.Count); var zhDeManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-zerohour-de")); Assert.NotNull(zhDeManifest); Assert.Equal(GameType.ZeroHour, zhDeManifest.TargetGame); Assert.Contains("(DE)", zhDeManifest.Name); + Assert.Equal(2, zhDeManifest.Files.Count); + + var zhRuManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-zerohour-ru")); + Assert.NotNull(zhRuManifest); + Assert.Equal(GameType.ZeroHour, zhRuManifest.TargetGame); + Assert.Contains("(RU)", zhRuManifest.Name); + Assert.Equal(2, zhRuManifest.Files.Count); var ccgEnManifest = manifests.FirstOrDefault(m => m.Id.Value.Contains("-generals-en")); Assert.NotNull(ccgEnManifest); Assert.Equal(GameType.Generals, ccgEnManifest.TargetGame); Assert.Contains("[Generals]", ccgEnManifest.Name); + Assert.Single(ccgEnManifest.Files); } /// @@ -114,7 +126,7 @@ public async Task CreateManifestsFromExtractedContentAsync_WithHleiPackage_Shoul /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateManifestsFromExtractedContentAsync_WithNoVariants_ShouldReturnSingleManifest() + public async Task CreateManifestsFromExtractedContentAsync_WithNoVariants_ShouldReturnSingleManifestAsync() { // Arrange File.WriteAllText(Path.Combine(_tempDir, "mod.big"), "mock content"); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs new file mode 100644 index 000000000..5b223f092 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CommunityOutpostResolverTests.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.CommunityOutpost; + +/// +/// Tests for CommunityOutpostResolver to verify manifest generation and ingestion gate compatibility. +/// +public class CommunityOutpostResolverTests +{ + private readonly Mock _providerLoaderMock; + + /// + /// Initializes a new instance of the class. + /// + public CommunityOutpostResolverTests() + { + _providerLoaderMock = new Mock(); + + var providerDefinition = new ProviderDefinition + { + ProviderId = CommunityOutpostConstants.PublisherId, + PublisherType = CommunityOutpostConstants.PublisherType, + DisplayName = CommunityOutpostConstants.PublisherName, + Endpoints = new ProviderEndpoints + { + WebsiteUrl = "https://legi.cc", + }, + }; + + _providerLoaderMock + .Setup(l => l.GetProvider(CommunityOutpostConstants.PublisherId)) + .Returns(providerDefinition); + } + + /// + /// Verifies that Community Patch resolution creates a manifest with format version 1 that passes ManifestIngestionGate. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ResolveAsync_CommunityPatch_GeneratesManifestAcceptedByIngestionGateAsync() + { + // Arrange + var builderMock = CreateBuilderMock( + ManifestId.Create("1.20260827.communityoutpost.gameclient.communitypatch"), + "Community Patch (TheSuperHackers Build)", + "27-08-2026", + ContentType.GameClient, + GameType.ZeroHour); + + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + var searchResult = new ContentSearchResult + { + Id = "1.20260827.communityoutpost.gameclient.community-patch", + Name = "Community Patch (TheSuperHackers Build)", + Version = "27-08-2026", + SourceUrl = "https://legi.cc/patch/generalszh_27-08-2026_NonRet.zip", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + }; + searchResult.ResolverMetadata["contentCode"] = "community-patch"; + + // Act + var result = await resolver.ResolveAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + builderMock.Verify( + m => m.WithBasicInfo( + CommunityOutpostConstants.PublisherType, + "community-patch", + "20260827"), + Times.Once); + + var manifest = result.Data; + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal("27-08-2026", manifest.Version); + Assert.Equal(ContentType.GameClient, manifest.ContentType); + Assert.Equal(GameType.ZeroHour, manifest.TargetGame); + + // Ingestion gate must accept the manifest + var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); + Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); + Assert.Null(rejectionReason); + } + + /// + /// Verifies that resolving base game clients produces manifests with DefaultManifestVersion format. + /// + /// The content code under test. + /// The expected display name. + /// The patch version string. + /// The expected numeric version for manifest ID. + /// The expected content name for manifest ID. + /// The expected game type. + /// A task representing the asynchronous unit test. + [Theory] + [InlineData("104e", "Zero Hour 1.04 (English)", "1.04", "104", "patch104english", GameType.ZeroHour)] + [InlineData("108e", "Generals 1.08 (English)", "1.08", "108", "patch108english", GameType.Generals)] + public async Task ResolveAsync_BaseGamePatch_GeneratesManifestAcceptedByIngestionGateAsync( + string contentCode, + string expectedName, + string version, + string expectedNumericVersion, + string expectedContentName, + GameType expectedGame) + { + // Arrange + var builderMock = CreateBuilderMock( + ManifestId.Create($"1.{expectedNumericVersion}.communityoutpost.gameclient.{expectedContentName}"), + expectedName, + version, + ContentType.GameClient, + expectedGame); + + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + var searchResult = new ContentSearchResult + { + Id = $"1.{expectedNumericVersion}.communityoutpost.gameclient.{expectedContentName}", + Name = expectedName, + Version = version, + SourceUrl = $"https://legi.cc/gp2/files/{contentCode}.dat", + ContentType = ContentType.GameClient, + TargetGame = expectedGame, + }; + searchResult.ResolverMetadata["contentCode"] = contentCode; + + // Act + var result = await resolver.ResolveAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + builderMock.Verify( + m => m.WithBasicInfo( + CommunityOutpostConstants.PublisherType, + expectedContentName, + expectedNumericVersion), + Times.Once); + + var manifest = result.Data; + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + Assert.Equal(version, manifest.Version); + + var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); + Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); + Assert.Null(rejectionReason); + } + + /// + /// Verifies that resolving addons like GenTool produces valid manifests that pass ManifestIngestionGate. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ResolveAsync_AddonContent_GeneratesManifestAcceptedByIngestionGateAsync() + { + // Arrange + var builderMock = CreateBuilderMock( + ManifestId.Create("1.1.communityoutpost.addon.gent"), + "GenTool", + "8.8", + ContentType.Addon, + GameType.ZeroHour); + + var resolver = new CommunityOutpostResolver( + () => builderMock.Object, + _providerLoaderMock.Object, + NullLogger.Instance); + + var searchResult = new ContentSearchResult + { + Id = "1.1.communityoutpost.addon.gent", + Name = "GenTool", + Version = "8.8", + SourceUrl = "https://legi.cc/gp2/files/gent.dat", + ContentType = ContentType.Addon, + TargetGame = GameType.ZeroHour, + }; + searchResult.ResolverMetadata["contentCode"] = "gent"; + + // Act + var result = await resolver.ResolveAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + var manifest = result.Data; + Assert.Equal(ManifestConstants.DefaultManifestVersion, manifest.ManifestVersion); + + var accepted = ManifestIngestionGate.TryAccept(manifest, out var rejectionReason); + Assert.True(accepted, $"Manifest should be accepted by ManifestIngestionGate, but was rejected with: {rejectionReason}"); + Assert.Null(rejectionReason); + } + + private static Mock CreateBuilderMock( + ManifestId manifestId, + string name, + string version, + ContentType contentType, + GameType targetGame) + { + var manifest = new ContentManifest + { + Id = manifestId, + Name = name, + Version = version, + ContentType = contentType, + TargetGame = targetGame, + ManifestVersion = ManifestConstants.DefaultManifestVersion, + }; + + var builderMock = new Mock(); + builderMock.Setup(m => m.WithBasicInfo(It.IsAny(), It.IsAny(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithContentType(It.IsAny(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithPublisher(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithMetadata(It.IsAny(), It.IsAny?>(), It.IsAny(), It.IsAny?>(), It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.WithInstallationInstructions(It.IsAny())).Returns(builderMock.Object); + builderMock.Setup(m => m.AddDependency( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny(), + It.IsAny?>())).Returns(builderMock.Object); + builderMock.Setup(m => m.AddRemoteFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())).ReturnsAsync(builderMock.Object); + builderMock.Setup(m => m.Build()).Returns(manifest); + + return builderMock; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CompressedImageToTgaConverterTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CompressedImageToTgaConverterTests.cs index 9a272d401..57317a831 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CompressedImageToTgaConverterTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/CompressedImageToTgaConverterTests.cs @@ -58,7 +58,7 @@ public CompressedImageToTgaConverterTests() /// /// A task representing the asynchronous test. [Fact] - public async Task ConvertFileAsync_AvifOnUnsupportedRuntime_ThrowsPlatformNotSupported() + public async Task ConvertFileAsync_AvifOnUnsupportedRuntime_ThrowsPlatformNotSupportedAsync() { var source = Path.Combine(_tempDir, "texture.avif"); await File.WriteAllBytesAsync(source, Convert.FromBase64String(TinyAvifBase64)); @@ -91,7 +91,7 @@ public async Task ConvertFileAsync_AvifOnUnsupportedRuntime_ThrowsPlatformNotSup /// /// A task representing the asynchronous test. [Fact] - public async Task ConvertDirectoryAsync_UnconvertibleAvif_IsLeftOnDisk() + public async Task ConvertDirectoryAsync_UnconvertibleAvif_IsLeftOnDiskAsync() { var source = Path.Combine(_tempDir, "texture.avif"); await File.WriteAllBytesAsync(source, Convert.FromBase64String(TinyAvifBase64)); @@ -112,7 +112,7 @@ public async Task ConvertDirectoryAsync_UnconvertibleAvif_IsLeftOnDisk() /// /// A task representing the asynchronous test. [Fact] - public async Task ConvertFileAsync_ConcurrentAvifProbes_DoNotExposeNativeLoaderFailure() + public async Task ConvertFileAsync_ConcurrentAvifProbes_DoNotExposeNativeLoaderFailureAsync() { var tasks = Enumerable.Range(0, 8) .Select( diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs index 27bb0ead2..673492aaf 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherContentRegistryTests.cs @@ -21,6 +21,8 @@ public class GenPatcherContentRegistryTests [Theory] [InlineData("gent", "GenTool", ContentType.Addon, GameType.ZeroHour)] [InlineData("gena", "GenAssist", ContentType.Addon, GameType.ZeroHour)] + [InlineData("ewba", "Enhanced World Builder (Advanced)", ContentType.Addon, GameType.ZeroHour)] + [InlineData("ewbi", "Enhanced World Builder (International)", ContentType.Addon, GameType.ZeroHour)] [InlineData("10gn", "Generals 1.08", ContentType.GameClient, GameType.Generals)] [InlineData("10zh", "Zero Hour 1.04", ContentType.GameClient, GameType.ZeroHour)] [InlineData("cbbs", "Control Bar HD (Base)", ContentType.Addon, GameType.ZeroHour)] @@ -185,6 +187,7 @@ public void GetKnownContentCodes_ReturnsNonEmptyCollection() [InlineData("crzh", GenPatcherContentCategory.Camera)] [InlineData("hlen", GenPatcherContentCategory.Hotkeys)] [InlineData("gent", GenPatcherContentCategory.Tools)] + [InlineData("ewba", GenPatcherContentCategory.Tools)] [InlineData("maod", GenPatcherContentCategory.Maps)] [InlineData("icon", GenPatcherContentCategory.Visuals)] [InlineData("vc05", GenPatcherContentCategory.Prerequisites)] @@ -226,4 +229,39 @@ public void GetMetadata_RecognizesAllLanguageSuffixes(char suffix, string expect Assert.Equal(expectedLanguageCode, metadata.LanguageCode); Assert.Equal(ContentType.Patch, metadata.ContentType); } + + /// + /// Verifies that Leikeze's Hotkeys metadata defines valid variants with output filenames. + /// + [Fact] + public void GetMetadata_HleiVariants_DefineOutputFilenames() + { + // Act + var metadata = GenPatcherContentRegistry.GetMetadata("hlei"); + + // Assert + Assert.True(metadata.SupportsVariants); + Assert.NotNull(metadata.Variants); + Assert.Equal(4, metadata.Variants.Count); + + var zhEn = metadata.Variants.FirstOrDefault(v => v.Id == "zerohour-en"); + Assert.NotNull(zhEn); + Assert.Equal("!HotkeysLeikezeENZH.big", zhEn.OutputFilename); + Assert.Equal(GameType.ZeroHour, zhEn.TargetGame); + + var zhDe = metadata.Variants.FirstOrDefault(v => v.Id == "zerohour-de"); + Assert.NotNull(zhDe); + Assert.Equal("!HotkeysLeikezeDEZH.big", zhDe.OutputFilename); + Assert.Equal(GameType.ZeroHour, zhDe.TargetGame); + + var zhRu = metadata.Variants.FirstOrDefault(v => v.Id == "zerohour-ru"); + Assert.NotNull(zhRu); + Assert.Equal("!HotkeysLeikezeRUZH.big", zhRu.OutputFilename); + Assert.Equal(GameType.ZeroHour, zhRu.TargetGame); + + var ccgEn = metadata.Variants.FirstOrDefault(v => v.Id == "generals-en"); + Assert.NotNull(ccgEn); + Assert.Equal("!HotkeysLeikezeEN.big", ccgEn.OutputFilename); + Assert.Equal(GameType.Generals, ccgEn.TargetGame); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs index f90c1d1d5..165e2c1f1 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CommunityOutpost/GenPatcherDependencyBuilderTests.cs @@ -158,8 +158,8 @@ public void GetDependencies_Prerequisites_HasNoDependencies(string contentCode) [Theory] [InlineData("hlen")] [InlineData("hlde")] - [InlineData("ewba")] - [InlineData("ewbi")] + [InlineData("hleg")] + [InlineData("hlei")] public void GetDependencies_Hotkeys_RequiresZeroHour104(string contentCode) { // Arrange @@ -275,7 +275,7 @@ public void GetConflictingCodes_Hotkeys_ReturnsOtherHotkeys() Assert.DoesNotContain("hleg", conflicts); // Should not conflict with itself Assert.Contains("hlde", conflicts); Assert.Contains("hlei", conflicts); - Assert.Contains("ewba", conflicts); + Assert.DoesNotContain("ewba", conflicts); } /// 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 615bd5884..9bc16d292 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs @@ -22,12 +22,12 @@ namespace GenHub.Tests.Core.Features.Content; /// public class ContentOrchestratorTests { - private readonly Mock _cacheMock = default!; - private readonly Mock _contentValidatorMock = default!; - private readonly Mock _manifestPoolMock = default!; - private readonly Mock _installationServiceMock = default!; - private readonly Mock _installationCasPoolServiceMock = default!; - private readonly Mock> _loggerMock = default!; + private readonly Mock _cacheMock; + private readonly Mock _contentValidatorMock; + private readonly Mock _manifestPoolMock; + private readonly Mock _installationServiceMock; + private readonly Mock _installationCasPoolServiceMock; + private readonly Mock> _loggerMock; /// /// Initializes a new instance of the class. @@ -47,7 +47,7 @@ public ContentOrchestratorTests() /// /// A task representing the asynchronous operation. [Fact] - public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfully() + public async Task SearchAsync_AggregatesResultsFromMultipleProviders_SuccessfullyAsync() { // Arrange var provider1Mock = new Mock(); @@ -92,7 +92,7 @@ public async Task SearchAsync_AggregatesResultsFromMultipleProviders_Successfull /// /// A task representing the asynchronous operation. [Fact] - public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() + public async Task AcquireContentAsync_ValidatesAndStoresContent_SuccessfullyAsync() { // Arrange var searchResult = new ContentSearchResult @@ -153,7 +153,7 @@ public async Task AcquireContentAsync_ValidatesAndStoresContent_Successfully() /// /// A task representing the asynchronous operation. [Fact] - public async Task AcquireContentAsync_WhenGameClientPoolCannotBeEnsured_ReturnsFailure() + public async Task AcquireContentAsync_WhenGameClientPoolCannotBeEnsured_ReturnsFailureAsync() { var searchResult = new ContentSearchResult { @@ -233,7 +233,7 @@ public async Task AcquireContentAsync_WhenGameClientPoolCannotBeEnsured_ReturnsF /// /// A task representing the asynchronous operation. [Fact] - public async Task SearchAsync_WhenProviderCancels_PropagatesCancellation() + public async Task SearchAsync_WhenProviderCancels_PropagatesCancellationAsync() { var providerMock = new Mock(); providerMock.Setup(provider => provider.IsEnabled).Returns(true); @@ -263,7 +263,7 @@ await Assert.ThrowsAnyAsync( /// /// A task representing the asynchronous operation. [Fact] - public async Task SearchAsync_WhenCancelledBeforeCacheHit_PropagatesCancellation() + public async Task SearchAsync_WhenCancelledBeforeCacheHit_PropagatesCancellationAsync() { _cacheMock .Setup(cache => cache.GetAsync>(It.IsAny(), It.IsAny())) @@ -296,7 +296,7 @@ await Assert.ThrowsAnyAsync( /// /// A task representing the asynchronous operation. [Fact] - public async Task SearchAsync_WhenProviderTimesOut_KeepsResultsFromOtherProviders() + public async Task SearchAsync_WhenProviderTimesOut_KeepsResultsFromOtherProvidersAsync() { var timingOutProviderMock = new Mock(); timingOutProviderMock.Setup(provider => provider.IsEnabled).Returns(true); @@ -337,7 +337,7 @@ public async Task SearchAsync_WhenProviderTimesOut_KeepsResultsFromOtherProvider /// /// A task representing the asynchronous operation. [Fact] - public async Task AcquireContentAsync_WhenProviderCancels_PropagatesCancellation() + public async Task AcquireContentAsync_WhenProviderCancels_PropagatesCancellationAsync() { var searchResult = new ContentSearchResult { @@ -376,7 +376,7 @@ await Assert.ThrowsAnyAsync( /// /// A task representing the asynchronous operation. [Fact] - public async Task AcquireContentAsync_WhenProviderTimesOut_ReturnsFailure() + public async Task AcquireContentAsync_WhenProviderTimesOut_ReturnsFailureAsync() { var searchResult = new ContentSearchResult { @@ -414,7 +414,7 @@ public async Task AcquireContentAsync_WhenProviderTimesOut_ReturnsFailure() /// /// A task representing the asynchronous operation. [Fact] - public async Task AcquireContentAsync_WhenInstallationDetectionCancels_PropagatesCancellation() + public async Task AcquireContentAsync_WhenInstallationDetectionCancels_PropagatesCancellationAsync() { var searchResult = new ContentSearchResult { @@ -475,4 +475,122 @@ 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); + } + + /// + /// Verifies that ResolveManifestAsync successfully matches resolvers across hyphen and case variations. + /// + /// The resolver ID variant to test. + /// A representing the asynchronous operation. + [Theory] + [InlineData("community-outpost")] + [InlineData("communityoutpost")] + [InlineData("community_outpost")] + [InlineData("COMMUNITY-OUTPOST")] + [InlineData("COMMUNITYOUTPOST")] + [InlineData("COMMUNITY_OUTPOST")] + public async Task ResolveManifestAsync_MatchesResolverWithHyphenAndCaseVariationsAsync(string searchResolverId) + { + // Arrange + var resolverMock = new Mock(); + resolverMock.Setup(r => r.ResolverId).Returns("community-outpost"); + + var searchResult = new ContentSearchResult + { + Id = "1.0.communityoutpost.addon.gent", + Name = "GenTool", + ResolverId = searchResolverId, + }; + + var manifest = new ContentManifest + { + Id = searchResult.Id, + Name = searchResult.Name, + ContentType = ContentType.Addon, + }; + + resolverMock + .Setup(r => r.ResolveAsync(searchResult, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + _contentValidatorMock + .Setup(v => v.ValidateManifestAsync(manifest, It.IsAny())) + .ReturnsAsync(new ValidationResult(manifest.Id, [])); + + var orchestrator = new ContentOrchestrator( + _loggerMock.Object, + [], + [], + [resolverMock.Object], + _cacheMock.Object, + _contentValidatorMock.Object, + _manifestPoolMock.Object, + _installationServiceMock.Object, + _installationCasPoolServiceMock.Object); + + // Act + var result = await orchestrator.ResolveManifestAsync(searchResult); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("GenTool", result.Data.Name); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs new file mode 100644 index 000000000..80ba8cec5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvContentProviderTests.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.Results.Content; +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; + +/// +/// Unit tests for . +/// +public class CsvContentProviderTests +{ + /// + /// Verifies that the constructor throws when no matching discoverer is registered. + /// + [Fact] + public void Constructor_WhenDiscovererMissing_ThrowsInvalidOperationException() + { + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var act = () => new CsvContentProvider( + [], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + Mock.Of(), + Mock.Of()); + + act.Should().Throw() + .WithMessage("*CSV discoverer not found*"); + } + + /// + /// Verifies that the constructor throws when no matching resolver is registered. + /// + [Fact] + public void Constructor_WhenResolverMissing_ThrowsInvalidOperationException() + { + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var act = () => new CsvContentProvider( + [mockDiscoverer.Object], + [], + [mockDeliverer.Object], + Mock.Of>(), + Mock.Of(), + Mock.Of()); + + act.Should().Throw() + .WithMessage("*CSV resolver not found*"); + } + + /// + /// Verifies that the constructor throws when no deliverer is registered. + /// + [Fact] + public void Constructor_WhenDelivererMissing_ThrowsInvalidOperationException() + { + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + + var act = () => new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [], + Mock.Of>(), + Mock.Of(), + Mock.Of()); + + act.Should().Throw() + .WithMessage("*deliverer not found*"); + } + + /// + /// Verifies that returns the expected publisher type constant. + /// + [Fact] + public void SourceName_ReturnsExpectedPublisherType() + { + var provider = CreateProvider(); + + provider.SourceName.Should().Be(PublisherTypeConstants.CsvRegistry); + } + + /// + /// Verifies that returns the expected description constant. + /// + [Fact] + public void Description_ReturnsExpectedDescription() + { + var provider = CreateProvider(); + + provider.Description.Should().Be(CsvConstants.Description); + } + + /// + /// Verifies that coordinates discovery, resolution, and validation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task SearchAsync_ExecutesDiscoveryAndResolutionSuccessfullyAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var discoveredItem = new ContentSearchResult + { + Id = manifestId, + Name = "Generals 1.08 (EN)", + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + }; + + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + mockDiscoverer.Setup(d => d.DiscoverAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = [discoveredItem], + TotalItems = 1, + })); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + mockResolver.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + mockValidator.Object, + CreateMockInstallationService()); + + var result = await provider.SearchAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Should().ContainSingle(); + result.Data!.First().Name.Should().Be("Generals 1.08 (EN)"); + } + + /// + /// Verifies that returns failure when content ID is null or whitespace. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetValidatedContentAsync_WithNullOrWhitespaceContentId_ReturnsFailureAsync() + { + var provider = CreateProvider(); + + var result = await provider.GetValidatedContentAsync(string.Empty); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that retrieves the manifest when matching content is found. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetValidatedContentAsync_WithMatchingContentId_ReturnsManifestAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var discoveredItem = new ContentSearchResult + { + Id = manifestId, + Name = "Generals 1.08 (EN)", + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + }; + + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + mockDiscoverer.Setup(d => d.DiscoverAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = [discoveredItem], + TotalItems = 1, + })); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + mockResolver.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + mockValidator.Object, + CreateMockInstallationService()); + + var result = await provider.GetValidatedContentAsync(manifestId); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Name.Should().Be("Generals 1.08 (EN)"); + } + + /// + /// Verifies that returns failure when no items match exact content ID. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task GetValidatedContentAsync_WithNonMatchingContentId_ReturnsFailureAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + Files = [new ManifestFile { RelativePath = "game.dat", Size = 12345 }], + }; + + var discoveredItem = new ContentSearchResult + { + Id = manifestId, + Name = "Generals 1.08 (EN)", + RequiresResolution = true, + ResolverId = CsvConstants.ResolverId, + }; + + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + mockDiscoverer.Setup(d => d.DiscoverAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentDiscoveryResult + { + Items = [discoveredItem], + TotalItems = 1, + })); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + mockResolver.Setup(r => r.ResolveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + mockValidator.Object, + CreateMockInstallationService()); + + var result = await provider.GetValidatedContentAsync("non-matching-id"); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that completes preparation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task PrepareContentAsync_WithValidManifest_ReturnsSuccessAsync() + { + var manifestId = ManifestIdGenerator.GeneratePublisherContentId(PublisherTypeConstants.CsvRegistry, ContentType.GameInstallation, "generals-1.08-en"); + var manifest = new ContentManifest + { + Id = new ManifestId(manifestId), + Name = "Generals 1.08 (EN)", + Version = "1.08", + ContentType = ContentType.GameInstallation, + TargetGame = GameType.Generals, + }; + + var mockValidator = new Mock(); + mockValidator.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + mockValidator.Setup(v => v.ValidateAllAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new ValidationResult(manifestId, [])); + + var provider = CreateProvider(validator: mockValidator.Object); + + var result = await provider.PrepareContentAsync(manifest, "C:\\test\\dir"); + + result.Success.Should().BeTrue(); + } + + private static IInstallationInstructionsService CreateMockInstallationService() + { + var mockInstallService = new Mock(); + mockInstallService + .Setup(s => s.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + return mockInstallService.Object; + } + + private static CsvContentProvider CreateProvider(IContentValidator? validator = null) + { + var mockDiscoverer = new Mock(); + mockDiscoverer.Setup(d => d.SourceName).Returns(CsvConstants.SourceName); + + var mockResolver = new Mock(); + mockResolver.Setup(r => r.ResolverId).Returns(CsvConstants.ResolverId); + + var mockDeliverer = new Mock(); + mockDeliverer.Setup(d => d.SourceName).Returns(ContentSourceNames.HttpDeliverer); + + return new CsvContentProvider( + [mockDiscoverer.Object], + [mockResolver.Object], + [mockDeliverer.Object], + Mock.Of>(), + validator ?? Mock.Of(), + CreateMockInstallationService()); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvDiscovererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvDiscovererTests.cs new file mode 100644 index 000000000..efcd62c77 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvDiscovererTests.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Features.Content.Services.ContentDiscoverers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public class CsvDiscovererTests +{ + private sealed class TempIndexFile : IDisposable + { + public TempIndexFile(IEnumerable entries) + { + FilePath = Path.GetTempFileName(); + var index = new CsvCatalogRegistryIndex + { + Entries = entries.ToList(), + }; + + File.WriteAllText(FilePath, JsonSerializer.Serialize(index)); + } + + public string FilePath { get; } + + public void Dispose() + { + if (File.Exists(FilePath)) + { + File.Delete(FilePath); + } + } + } + + private sealed class StubHttpMessageHandler( + string? expectedUrl = null, + string content = "", + HttpStatusCode statusCode = HttpStatusCode.NotFound) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var code = expectedUrl == null || request.RequestUri?.AbsoluteUri == expectedUrl + ? statusCode + : HttpStatusCode.NotFound; + var responseContent = code == HttpStatusCode.OK ? content : string.Empty; + var response = new HttpResponseMessage(code) + { + RequestMessage = request, + Content = new StringContent(responseContent), + }; + + return Task.FromResult(response); + } + } + + private const string GeneralsVersion = "1.08"; + private const string ZeroHourVersion = "1.04"; + private const string TestGeneralsCsvUrl = "https://example.com/generals.csv"; + private const string TestZeroHourCsvUrl = "https://example.com/zerohour.csv"; + private const string TestFallbackCsvUrl = "https://example.com/fallback.csv"; + + /// + /// Verifies that returns an empty result when the query specifies a non-game-installation content type. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenContentTypeIsNotGameInstallation_ReturnsEmptyResultAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + var query = new ContentSearchQuery { ContentType = ContentType.Map }; + + var result = await discoverer.DiscoverAsync(query); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that loads and returns entries from index.json when available. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenIndexJsonAvailable_ReturnsEntriesAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn), + CreateEntry(TestZeroHourCsvUrl, CsvConstants.ZeroHourGameType, ZeroHourVersion, CsvConstants.LanguageEn)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().HaveCount(2); + } + + /// + /// Verifies that falls back to configured catalogs when index.json is unavailable. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenIndexJsonFails_FallsBackToConfiguredCatalogsAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration + { + IndexFilePath = "https://nonexistent.invalid/index.json", + CsvValidationCatalogs = + [ + CreateEntry(TestFallbackCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn), + ], + }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.CsvUrlMetadataKey].Should().Be(TestFallbackCsvUrl); + } + + /// + /// Verifies that returns an empty result when no sources contain valid entries. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenConfigEmpty_ReturnsEmptyAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration + { + IndexFilePath = string.Empty, + CsvValidationCatalogs = [], + }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that filters entries by language when a specific language is requested. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithSpecificLanguageQuery_ReturnsFilteredResultAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn, CsvConstants.LanguageDe, CsvConstants.LanguageFr)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { Language = "de" }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.LanguageMetadataKey].Should().Be(CsvConstants.LanguageDe); + } + + /// + /// Verifies that returns results for all supported languages when "All" is queried. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithAllLanguageQuery_ReturnsAllSupportedLanguagesAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn, CsvConstants.LanguageDe, CsvConstants.LanguageFr)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { Language = CsvConstants.AllLanguagesFilter }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().HaveCount(3); + } + + /// + /// Verifies that matches an entry whose language is "All" when a specific language is queried. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenEntryHasAllLanguage_MatchesAnyQueryAsync() + { + using var indexFile = CreateIndexFile( + CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.AllLanguagesFilter)); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { Language = "fr" }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.LanguageMetadataKey].Should().Be(CsvConstants.LanguageFr); + } + + /// + /// Verifies that caches catalog entries across multiple calls. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_CachesEntriesBetweenCallsAsync() + { + var tempIndex = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + try + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath }); + + var firstResult = await discoverer.DiscoverAsync(new ContentSearchQuery()); + firstResult.Data!.Items.Should().HaveCount(1); + + // Delete file - second call should still succeed from cache + tempIndex.Dispose(); + + var secondResult = await discoverer.DiscoverAsync(new ContentSearchQuery()); + secondResult.Success.Should().BeTrue(); + secondResult.Data!.Items.Should().HaveCount(1); + } + finally + { + tempIndex.Dispose(); + } + } + + /// + /// Verifies that propagates cancellation tokens. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + await Assert.ThrowsAnyAsync(() => + discoverer.DiscoverAsync(new ContentSearchQuery(), cts.Token)); + } + + /// + /// Verifies that returns a failure result when query is null. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenQueryIsNull_ReturnsFailureAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + var result = await discoverer.DiscoverAsync(null!); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that returns an empty result when network requests fail and no fallback is available. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenNetworkFails_ReturnsEmptyResultAsync() + { + var httpHandler = new StubHttpMessageHandler(statusCode: HttpStatusCode.InternalServerError); + var discoverer = CreateDiscoverer( + new CsvCatalogConfiguration { IndexFilePath = "https://example.com/index.json" }, + httpHandler); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that retries loading on next query after a transient failure without permanently caching empty results. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WhenFirstLoadFails_RetriesOnNextCallAsync() + { + var tempIndex = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + try + { + // First point to non-existent file + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath + ".nonexistent" }); + var firstResult = await discoverer.DiscoverAsync(new ContentSearchQuery()); + firstResult.Success.Should().BeTrue(); + firstResult.Data!.Items.Should().BeEmpty(); + + // Next point to actual file with a new discoverer or reconfigured discoverer + var secondDiscoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath }); + var secondResult = await secondDiscoverer.DiscoverAsync(new ContentSearchQuery()); + secondResult.Success.Should().BeTrue(); + secondResult.Data!.Items.Should().HaveCount(1); + } + finally + { + tempIndex.Dispose(); + } + } + + /// + /// Verifies that gives precedence to the configured index over default. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_ConfiguredIndexTakesPrecedenceOverDefaultAsync() + { + var tempIndex = CreateIndexFile(CreateEntry("https://custom.com/custom.csv", CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + try + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = tempIndex.FilePath }); + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Success.Should().BeTrue(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().SourceUrl.Should().Be("https://custom.com/custom.csv"); + } + finally + { + tempIndex.Dispose(); + } + } + + /// + /// Verifies that uses configured fallback catalogs. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithConfiguredFallbackCatalogs_UsesFallbackAsync() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration + { + CsvValidationCatalogs = + [ + new CsvCatalogRegistryEntry + { + Url = TestFallbackCsvUrl, + GameType = CsvConstants.GeneralsGameType, + Version = GeneralsVersion, + SupportedLanguages = [CsvConstants.LanguageEn], + }, + ], + }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery()); + + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().ResolverMetadata[CsvConstants.CsvUrlMetadataKey].Should().Be(TestFallbackCsvUrl); + } + + /// + /// Verifies that returns no items for unsupported target games. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithUnsupportedTargetGame_ReturnsEmptyAsync() + { + using var indexFile = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { TargetGame = (GameType)999 }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that handles Zero Hour game type correctly. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithZeroHourQuery_ReturnsValidResultAsync() + { + using var indexFile = CreateIndexFile(CreateEntry(TestZeroHourCsvUrl, CsvConstants.ZeroHourGameType, ZeroHourVersion, CsvConstants.LanguageEn)); + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { TargetGame = GameType.ZeroHour }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().ContainSingle(); + result.Data.Items.First().TargetGame.Should().Be(GameType.ZeroHour); + } + + /// + /// Verifies that returns empty when no matching game type is found. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task DiscoverAsync_WithNonMatchingGameType_ReturnsEmptyAsync() + { + using var indexFile = CreateIndexFile(CreateEntry(TestGeneralsCsvUrl, CsvConstants.GeneralsGameType, GeneralsVersion, CsvConstants.LanguageEn)); + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration { IndexFilePath = indexFile.FilePath }); + + var result = await discoverer.DiscoverAsync(new ContentSearchQuery { TargetGame = GameType.ZeroHour }); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Items.Should().BeEmpty(); + } + + /// + /// Verifies that returns the correct source name. + /// + [Fact] + public void SourceName_ReturnsExpectedValue() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.SourceName.Should().Be(CsvConstants.SourceName); + } + + /// + /// Verifies that returns the correct description. + /// + [Fact] + public void Description_ReturnsExpectedValue() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.Description.Should().Be(CsvConstants.Description); + } + + /// + /// Verifies that returns true. + /// + [Fact] + public void IsEnabled_ReturnsTrue() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.IsEnabled.Should().BeTrue(); + } + + /// + /// Verifies that returns DirectSearch. + /// + [Fact] + public void Capabilities_ReturnsDirectSearch() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + discoverer.Capabilities.Should().Be(ContentSourceCapabilities.DirectSearch); + } + + /// + /// Verifies that can be called multiple times without throwing. + /// + [Fact] + public void Dispose_CanBeCalledMultipleTimes() + { + var discoverer = CreateDiscoverer(new CsvCatalogConfiguration()); + + var act = () => + { + discoverer.Dispose(); + discoverer.Dispose(); + }; + + act.Should().NotThrow(); + } + + private static CsvDiscoverer CreateDiscoverer(CsvCatalogConfiguration? config, HttpMessageHandler? httpMessageHandler = null) + { + var mockConfig = new Mock(); + mockConfig.Setup(o => o.GetCsvCatalogConfiguration()).Returns(config!); + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory + .Setup(o => o.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(httpMessageHandler ?? new StubHttpMessageHandler())); + + return new CsvDiscoverer(Mock.Of>(), mockConfig.Object, mockHttpClientFactory.Object); + } + + private static TempIndexFile CreateIndexFile(params CsvCatalogRegistryEntry[] entries) + { + return new TempIndexFile(entries); + } + + private static CsvCatalogRegistryEntry CreateEntry(string url, string gameType, string version, params string[] languages) + { + return new CsvCatalogRegistryEntry + { + Url = url, + GameType = gameType, + Version = version, + SupportedLanguages = languages.ToList(), + IsActive = true, + }; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvResolverTests.cs new file mode 100644 index 000000000..1f9238f6e --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/CsvResolverTests.cs @@ -0,0 +1,399 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GenHub.Core.Constants; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results.Content; +using GenHub.Features.Content.Services.ContentResolvers; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content; + +/// +/// Unit tests for . +/// +public class CsvResolverTests +{ + private sealed class TempCsvFile : IDisposable + { + public TempCsvFile(string csvContent) + { + FilePath = Path.GetTempFileName(); + File.WriteAllText(FilePath, csvContent); + } + + public string FilePath { get; } + + public void Dispose() + { + if (File.Exists(FilePath)) + { + File.Delete(FilePath); + } + } + } + + private sealed class StubHttpMessageHandler( + string? expectedUrl = null, + string content = "", + HttpStatusCode statusCode = HttpStatusCode.NotFound) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var code = expectedUrl == null || request.RequestUri?.AbsoluteUri == expectedUrl + ? statusCode + : HttpStatusCode.NotFound; + var responseContent = code == HttpStatusCode.OK ? content : string.Empty; + var response = new HttpResponseMessage(code) + { + RequestMessage = request, + Content = new StringContent(responseContent), + }; + + return Task.FromResult(response); + } + } + + private const string SampleCsvHeader = "relativePath,size,md5,sha256,gameType,language,isRequired,metadata,downloadUrl"; + private const string SampleCsvRowAll = "game.dat,123456,md5all,sha256all,Generals,All,True,\"{}\",https://example.com/game.dat"; + private const string SampleCsvRowEn = "English.big,234567,md5en,sha256en,Generals,EN,False,\"{}\",https://example.com/English.big"; + private const string SampleCsvRowDe = "German.big,345678,md5de,sha256de,Generals,DE,False,\"{}\",https://example.com/German.big"; + private const string SampleCsvRowZh = "ZeroHour.exe,456789,md5zh,sha256zh,ZeroHour,All,True,\"{}\",https://example.com/ZeroHour.exe"; + + private static readonly string FullSampleCsv = string.Join( + Environment.NewLine, + SampleCsvHeader, + SampleCsvRowAll, + SampleCsvRowEn, + SampleCsvRowDe, + SampleCsvRowZh); + + /// + /// Verifies that returns a failure when the item is null. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithNullDiscoveredItem_ReturnsFailureAsync() + { + var resolver = CreateResolver(); + + var result = await resolver.ResolveAsync(null!); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that returns a failure when SourceUrl is empty. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithEmptySourceUrl_ReturnsFailureAsync() + { + var resolver = CreateResolver(); + var item = new ContentSearchResult { SourceUrl = string.Empty }; + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that successfully resolves a manifest from HTTP URL. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenRemoteCsvFetchedSuccessfully_ResolvesManifestAsync() + { + var remoteUrl = "https://example.com/catalog.csv"; + var httpHandler = new StubHttpMessageHandler(expectedUrl: remoteUrl, content: FullSampleCsv, statusCode: HttpStatusCode.OK); + var resolver = CreateResolver(httpHandler); + + var item = CreateDiscoveredItem(remoteUrl, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Files.Should().HaveCount(2); // game.dat (All) + English.big (EN) + result.Data.Files.Should().Contain(f => f.RelativePath == "game.dat" && f.SourceType == ContentSourceType.RemoteDownload); + result.Data.Files.Should().Contain(f => f.RelativePath == "English.big" && f.SourceType == ContentSourceType.RemoteDownload); + } + + /// + /// Verifies that successfully resolves a manifest from a local file. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenLocalCsvFileExists_ResolvesManifestAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + result.Data!.Files.Should().HaveCount(2); + result.Data.Files.Should().AllSatisfy(f => f.SourceType.Should().Be(ContentSourceType.LocalFile)); + } + + /// + /// Verifies that returns a failure when local file is missing. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenLocalCsvFileNotFound_ReturnsFailureAsync() + { + var resolver = CreateResolver(); + var item = CreateDiscoveredItem("C:\\nonexistent\\missing_catalog.csv", GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that returns a failure when network request fails. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenNetworkFails_ReturnsFailureAsync() + { + var remoteUrl = "https://example.com/catalog.csv"; + var httpHandler = new StubHttpMessageHandler(expectedUrl: remoteUrl, statusCode: HttpStatusCode.InternalServerError); + var resolver = CreateResolver(httpHandler); + + var item = CreateDiscoveredItem(remoteUrl, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that filters files by specific language. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithSpecificLanguageQuery_FiltersFilesByLanguageAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageDe); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(2); // game.dat (All) + German.big (DE) + result.Data.Files.Should().Contain(f => f.RelativePath == "German.big"); + result.Data.Files.Should().NotContain(f => f.RelativePath == "English.big"); + } + + /// + /// Verifies that includes all language files when language is "All". + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithAllLanguageQuery_IncludesAllFilesAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(3); // game.dat, English.big, German.big + } + + /// + /// Verifies that filters files by game type correctly. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithTargetGame_FiltersFilesByGameTypeAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.ZeroHour, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(1); // ZeroHour.exe + result.Data.Files.First().RelativePath.Should().Be("ZeroHour.exe"); + result.Data.Files.First().IsExecutable.Should().BeTrue(); + } + + /// + /// Verifies that returns failure when no files match. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenNoFilesMatch_ReturnsFailureAsync() + { + using var tempCsv = new TempCsvFile(SampleCsvHeader); + var resolver = CreateResolver(); + + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + } + + /// + /// Verifies that propagates cancellation. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenCancelled_ThrowsOperationCanceledExceptionAsync() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var resolver = CreateResolver(); + var item = CreateDiscoveredItem("https://example.com/test.csv", GameType.Generals, CsvConstants.LanguageEn); + + await Assert.ThrowsAnyAsync(() => + resolver.ResolveAsync(item, cts.Token)); + } + + /// + /// Verifies that returns the expected constant. + /// + [Fact] + public void ResolverId_ReturnsExpectedConstant() + { + var resolver = CreateResolver(); + + resolver.ResolverId.Should().Be(CsvConstants.ResolverId); + } + + /// + /// Verifies that filters out traversal and rooted paths. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithTraversingRelativePaths_RejectsUnsafePathsAsync() + { + var maliciousCsv = string.Join( + Environment.NewLine, + SampleCsvHeader, + "../../escape.dll,100,md5,sha256,Generals,All,True,\"{}\",https://example.com/escape.dll", + "C:\\root.dll,100,md5,sha256,Generals,All,True,\"{}\",https://example.com/root.dll", + "valid.dll,100,md5,sha256,Generals,All,True,\"{}\",https://example.com/valid.dll"); + + using var tempCsv = new TempCsvFile(maliciousCsv); + var resolver = CreateResolver(); + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Should().HaveCount(1); + result.Data.Files.Single().RelativePath.Should().Be("valid.dll"); + } + + /// + /// Verifies that sets SourceType to GameInstallation when remote entry has no valid URL. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WhenRemoteFileHasNoDownloadUrl_SetsSourceTypeToGameInstallationAsync() + { + var csvNoUrl = string.Join( + Environment.NewLine, + SampleCsvHeader, + "local_only.dat,100,md5,sha256,Generals,All,True,\"{}\","); + + var remoteUrl = "https://example.com/nourl.csv"; + var httpHandler = new StubHttpMessageHandler(expectedUrl: remoteUrl, content: csvNoUrl, statusCode: HttpStatusCode.OK); + var resolver = CreateResolver(httpHandler); + var item = CreateDiscoveredItem(remoteUrl, GameType.Generals, CsvConstants.AllLanguagesFilter); + + var result = await resolver.ResolveAsync(item); + + result.Success.Should().BeTrue(); + result.Data!.Files.Single().SourceType.Should().Be(ContentSourceType.GameInstallation); + result.Data.Files.Single().DownloadUrl.Should().BeNull(); + } + + /// + /// Verifies that the ProviderDefinition overload delegates to the main ResolveAsync method. + /// + /// A task representing the asynchronous test operation. + [Fact] + public async Task ResolveAsync_WithProviderDefinitionOverload_DelegatesToResolveAsync() + { + using var tempCsv = new TempCsvFile(FullSampleCsv); + var resolver = CreateResolver(); + var item = CreateDiscoveredItem(tempCsv.FilePath, GameType.Generals, CsvConstants.LanguageEn); + + var result = await resolver.ResolveAsync(null, item); + + result.Success.Should().BeTrue(); + result.Data.Should().NotBeNull(); + } + + private static CsvResolver CreateResolver(HttpMessageHandler? handler = null) + { + var mockHttpClientFactory = new Mock(); + mockHttpClientFactory + .Setup(o => o.CreateClient(It.IsAny())) + .Returns(() => new HttpClient(handler ?? new StubHttpMessageHandler())); + + return new CsvResolver(mockHttpClientFactory.Object, Mock.Of>()); + } + + private static ContentSearchResult CreateDiscoveredItem(string sourceUrl, GameType gameType, string language) + { + var gameTypeStr = gameType == GameType.ZeroHour ? CsvConstants.ZeroHourGameType : CsvConstants.GeneralsGameType; + var id = ManifestIdGenerator.GeneratePublisherContentId( + PublisherTypeConstants.CsvRegistry, + ContentType.GameInstallation, + $"{gameTypeStr}-1.0-{language}"); + + var item = new ContentSearchResult + { + Id = id, + Name = $"{gameTypeStr} 1.0 ({language})", + Description = $"Base game installation files for {gameTypeStr} 1.0", + Version = "1.0", + ContentType = ContentType.GameInstallation, + TargetGame = gameType, + ProviderName = CsvConstants.SourceName, + ResolverId = CsvConstants.ResolverId, + SourceUrl = sourceUrl, + RequiresResolution = true, + }; + + item.ResolverMetadata[CsvConstants.CsvUrlMetadataKey] = sourceUrl; + item.ResolverMetadata[CsvConstants.GameTypeMetadataKey] = gameTypeStr; + item.ResolverMetadata[CsvConstants.LanguageMetadataKey] = language; + item.ResolverMetadata[CsvConstants.VersionMetadataKey] = "1.0"; + + return item; + } +} 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 e418f85cf..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); } /// @@ -61,7 +72,7 @@ public GitHubContentProviderTests() /// A task representing the asynchronous operation. /// [Fact] - public async Task SearchAsync_OrchestratesDiscoveryAndResolution_Successfully() + public async Task SearchAsync_OrchestratesDiscoveryAndResolution_SuccessfullyAsync() { // Arrange var query = new ContentSearchQuery { SearchTerm = "Test" }; @@ -105,7 +116,7 @@ public async Task SearchAsync_OrchestratesDiscoveryAndResolution_Successfully() /// A task representing the asynchronous operation. /// [Fact] - public async Task PrepareContentAsync_CallsDelivererAndValidator_Successfully() + public async Task PrepareContentAsync_CallsDelivererAndValidator_SuccessfullyAsync() { // Arrange var manifest = new ContentManifest { Id = "1.0.genhub.mod.ghtestmod", Files = [] }; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs index a6e824d64..a3bc76ae7 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/GitHubResolverTests.cs @@ -46,7 +46,7 @@ public GitHubResolverTests() /// /// A representing the asynchronous test. [Fact] - public async Task ResolveAsync_WithValidDiscoveredItem_ReturnsSuccessfulManifest() + public async Task ResolveAsync_WithValidDiscoveredItem_ReturnsSuccessfulManifestAsync() { var discoveredItem = CreateItem("v1.0"); var release = CreateRelease("v1.0"); @@ -67,7 +67,7 @@ public async Task ResolveAsync_WithValidDiscoveredItem_ReturnsSuccessfulManifest /// /// A representing the asynchronous test. [Fact] - public async Task ResolveAsync_WithLatestTag_CallsGetLatestRelease() + public async Task ResolveAsync_WithLatestTag_CallsGetLatestReleaseAsync() { var discoveredItem = CreateItem("latest"); var release = CreateRelease("v1.1"); @@ -88,7 +88,7 @@ public async Task ResolveAsync_WithLatestTag_CallsGetLatestRelease() /// /// A representing the asynchronous test. [Fact] - public async Task ResolveAsync_WhenLatestReleaseNotFound_FallsBackToAnyRelease() + public async Task ResolveAsync_WhenLatestReleaseNotFound_FallsBackToAnyReleaseAsync() { var discoveredItem = CreateItem("latest"); var preRelease = CreateRelease("v0.5-beta"); @@ -113,7 +113,7 @@ public async Task ResolveAsync_WhenLatestReleaseNotFound_FallsBackToAnyRelease() /// /// A representing the asynchronous unit test. [Fact] - public async Task ResolveAsync_MissingMetadata_ReturnsFailure() + public async Task ResolveAsync_MissingMetadata_ReturnsFailureAsync() { var discoveredItem = new ContentSearchResult { ResolverId = "GitHubRelease" }; var result = await _resolver.ResolveAsync(discoveredItem); @@ -144,7 +144,7 @@ private static GitHubRelease CreateRelease(string tag) { TagName = tag, PublishedAt = DateTimeOffset.Now, - Assets = [new GitHubReleaseAsset { Name = "test.zip", BrowserDownloadUrl = "http://test.com" },], + Assets = [new GitHubReleaseAsset { Name = "test.zip", BrowserDownloadUrl = "https://test.com" },], }; } 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); + } + + /// + /// Verifies that a successful RunOnce step persists its key immediately even if a subsequent step fails. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceStep_PersistsKeyImmediatelyEvenIfLaterStepFails() + { + var successFile = "success.tmp"; + var fullPath = Path.Combine(_tempDirectory, successFile); + await File.WriteAllTextAsync(fullPath, "temporary"); + + const string step1Key = "step:runonce:first"; + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Step 1 Remove", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = successFile, + StepKey = step1Key, + RunOnce = true, + }, + new InstallationStep + { + Name = "Step 2 Unknown Kind", + Kind = InstallationStepKind.Unknown, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: PublisherTypeConstants.GeneralsOnline); + + Assert.False(result.Success); + Assert.False(File.Exists(fullPath)); + Assert.True(_userSettings.IsInstallationStepExecuted(step1Key)); + _userSettingsServiceMock.Verify(u => u.SaveAsync(It.IsAny()), Times.AtLeastOnce); + } + + /// + /// Verifies that an already-executed RunOnce step is skipped without failing provider authorization. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task ExecutePostInstallStepsAsync_RunOnceAlreadyExecuted_DoesNotFailAuthorizationForUntrustedProvider() + { + const string stepKey = "step:untrusted:runonce"; + _userSettings.RecordInstallationStepExecuted(stepKey); + + var manifest = CreateBaseManifest(); + manifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = "Already Executed Step", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "installer.exe", + StepKey = stepKey, + RunOnce = true, + }, + ], + }; + + var result = await _service.ExecutePostInstallStepsAsync( + manifest, + _tempDirectory, + providerSource: "untrusted_source"); + + Assert.True(result.Success); + } + + private static ContentManifest CreateBaseManifest() => new() + { + Id = "1.0.test.gameclient.variant", + Name = "Test Manifest", + Version = "1.0.0", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Providers/ProviderDefinitionLoaderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Providers/ProviderDefinitionLoaderTests.cs index 9195ab082..92c21c611 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Providers/ProviderDefinitionLoaderTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Providers/ProviderDefinitionLoaderTests.cs @@ -36,7 +36,7 @@ public void Dispose() /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadProvidersAsync_LoadsValidProviders_Successfully() + public async Task LoadProvidersAsync_LoadsValidProviders_SuccessfullyAsync() { // Arrange var provider1Json = @"{ @@ -79,7 +79,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task GetProvider_ReturnsCorrectProvider_AfterLoading() + public async Task GetProvider_ReturnsCorrectProvider_AfterLoadingAsync() { // Arrange var providerJson = @"{ @@ -118,7 +118,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task GetProvider_AutoLoadsProviders_WhenNotInitialized() + public async Task GetProvider_AutoLoadsProviders_WhenNotInitializedAsync() { // Arrange var providerJson = @"{ @@ -147,7 +147,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task GetProvider_ReturnsNull_ForNonExistentProvider() + public async Task GetProvider_ReturnsNull_ForNonExistentProviderAsync() { // Arrange var loader = new ProviderDefinitionLoader(_loggerMock.Object, _testProvidersDirectory); @@ -165,7 +165,7 @@ public async Task GetProvider_ReturnsNull_ForNonExistentProvider() /// /// A representing the asynchronous test operation. [Fact] - public async Task GetProvider_IsCaseInsensitive() + public async Task GetProvider_IsCaseInsensitiveAsync() { // Arrange var providerJson = @"{ @@ -193,7 +193,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadProvidersAsync_HandlesInvalidJson_Gracefully() + public async Task LoadProvidersAsync_HandlesInvalidJson_GracefullyAsync() { // Arrange var validJson = @"{ @@ -230,7 +230,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadProvidersAsync_HandlesMissingProviderId_Gracefully() + public async Task LoadProvidersAsync_HandlesMissingProviderId_GracefullyAsync() { // Arrange var validJson = @"{ @@ -271,7 +271,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task ReloadProvidersAsync_ClearsAndReloads_Successfully() + public async Task ReloadProvidersAsync_ClearsAndReloads_SuccessfullyAsync() { // Arrange var initialJson = @"{ @@ -316,7 +316,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task AddCustomProvider_AddsProvider_Successfully() + public async Task AddCustomProvider_AddsProvider_SuccessfullyAsync() { // Arrange var loader = new ProviderDefinitionLoader(_loggerMock.Object, _testProvidersDirectory); @@ -345,7 +345,7 @@ public async Task AddCustomProvider_AddsProvider_Successfully() /// /// A representing the asynchronous test operation. [Fact] - public async Task RemoveCustomProvider_RemovesProvider_Successfully() + public async Task RemoveCustomProvider_RemovesProvider_SuccessfullyAsync() { // Arrange var loader = new ProviderDefinitionLoader(_loggerMock.Object, _testProvidersDirectory); @@ -375,7 +375,7 @@ public async Task RemoveCustomProvider_RemovesProvider_Successfully() /// /// A representing the asynchronous test operation. [Fact] - public async Task GetAllProviders_ReturnsOnlyEnabledProviders() + public async Task GetAllProviders_ReturnsOnlyEnabledProvidersAsync() { // Arrange var enabledJson = @"{ @@ -416,7 +416,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task GetProvidersByType_ReturnsCorrectlyFilteredProviders() + public async Task GetProvidersByType_ReturnsCorrectlyFilteredProvidersAsync() { // Arrange var staticJson = @"{ @@ -463,7 +463,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadProvidersAsync_ParsesCustomEndpoints_Correctly() + public async Task LoadProvidersAsync_ParsesCustomEndpoints_CorrectlyAsync() { // Arrange var providerJson = @"{ @@ -504,7 +504,7 @@ await File.WriteAllTextAsync( /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadProvidersAsync_HandlesEmptyDirectory_Gracefully() + public async Task LoadProvidersAsync_HandlesEmptyDirectory_GracefullyAsync() { // Arrange - directory is already empty var loader = new ProviderDefinitionLoader(_loggerMock.Object, _testProvidersDirectory); @@ -523,7 +523,7 @@ public async Task LoadProvidersAsync_HandlesEmptyDirectory_Gracefully() /// /// A representing the asynchronous test operation. [Fact] - public async Task LoadProvidersAsync_HandlesNonExistentDirectory_Gracefully() + public async Task LoadProvidersAsync_HandlesNonExistentDirectory_GracefullyAsync() { // Arrange var nonExistentPath = Path.Combine(Path.GetTempPath(), "GenHub.Tests", "NonExistent", Guid.NewGuid().ToString()); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs new file mode 100644 index 000000000..ee1ba5497 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostDelivererTests.cs @@ -0,0 +1,392 @@ +using System.IO.Compression; +using System.Reflection; +using System.Text; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Storage; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.CommunityOutpost; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace GenHub.Tests.Core.Features.Content.Services.CommunityOutpost; + +/// +/// Tests the containment and expansion bounds applied to Community Outpost archives, which arrive +/// from a third-party catalog and are therefore untrusted input. +/// +public sealed class CommunityOutpostDelivererTests : IDisposable +{ + private readonly string _workingDirectory = Path.Combine( + Path.GetTempPath(), + "GenHubCommunityOutpost", + Guid.NewGuid().ToString("N")); + + private readonly string _extractDirectory; + + /// + /// Initializes a new instance of the class. + /// + public CommunityOutpostDelivererTests() + { + _extractDirectory = Path.Combine(_workingDirectory, "extracted"); + Directory.CreateDirectory(_extractDirectory); + } + + /// + public void Dispose() + { + if (Directory.Exists(_workingDirectory)) + { + Directory.Delete(_workingDirectory, recursive: true); + } + } + + /// + /// Extracts entries that stay inside the target directory. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_ExtractsEntriesWithinBudgetAsync() + { + var archivePath = Path.Combine(_workingDirectory, "content.zip"); + CreateArchive(archivePath, "patch/readme.txt", "generals.big"); + + await InvokeExtractArchiveAsync(archivePath, _extractDirectory); + + Assert.True(File.Exists(Path.Combine(_extractDirectory, "patch", "readme.txt"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "generals.big"))); + } + + /// + /// Refuses an entry whose key climbs out of the extract directory, rather than depending on the + /// archive library to block it. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_RejectsEntryEscapingTheExtractDirectoryAsync() + { + var archivePath = Path.Combine(_workingDirectory, "traversal.zip"); + CreateArchive(archivePath, "../escaped.big"); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("outside target directory", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(File.Exists(Path.Combine(_workingDirectory, "escaped.big"))); + } + + /// + /// Refuses an archive that declares more entries than the extraction budget allows. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ExtractArchiveAsync_RejectsArchiveOverTheEntryBudgetAsync() + { + var archivePath = Path.Combine(_workingDirectory, "swarm.zip"); + var entryNames = Enumerable + .Range(0, CommunityOutpostConstants.MaxArchiveEntries + 1) + .Select(index => $"entry{index}.dat") + .ToArray(); + CreateArchive(archivePath, entryNames); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("too many entries", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFileSystemEntries(_extractDirectory)); + } + + /// + /// Refuses an entry whose name cannot name a file before that name is turned into a path. A + /// name that resolves to the extract directory itself would otherwise stage the write beside + /// that directory rather than inside it, and a colon names an NTFS alternate data stream. + /// + /// The entry name the archive declares. + /// A task representing the asynchronous test. + [Theory] + [InlineData(".")] + [InlineData("patch/..")] + [InlineData(" ")] + [InlineData("payload.big:stream")] + public async Task ExtractArchiveAsync_RejectsEntryWithAnUnusableNameAsync(string entryName) + { + var archivePath = Path.Combine(_workingDirectory, "unusable.zip"); + CreateArchive(archivePath, entryName); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(archivePath, _extractDirectory)); + + Assert.Contains("cannot be extracted to a file", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Empty(Directory.GetFileSystemEntries(_workingDirectory, "*.genhub-staging*")); + } + + /// + /// Surfaces a cancellation that lands part-way through extraction as a cancellation rather than + /// as an ordinary extraction failure, so callers can tell a user who changed their mind from a + /// hostile or broken archive. The cancellation is triggered once an early entry has landed on + /// disk and while a much larger one is still being written, which is what puts it inside the + /// entry loop rather than in front of it. The downloaded archive is the only complete copy of + /// the content, so it must survive, and the truncated file set must never reach the manifest + /// pool. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task DeliverContentAsync_CancelledMidExtraction_KeepsArchiveAndRegistersNothingAsync() + { + const int largeEntryBytes = 32 * 1024 * 1024; + var targetDirectory = Path.Combine(_workingDirectory, "target"); + Directory.CreateDirectory(targetDirectory); + + var downloadService = new Mock(); + downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + CreateArchive(destination, ("first.dat", 16), ("marker.dat", 16), ("large.dat", largeEntryBytes)); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var manifestPool = new Mock(); + var deliverer = CreateDeliverer(downloadService.Object, manifestPool.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "content.zip", + DownloadUrl = "https://legi.cc/gp2/f/cbpr.zip", + }, + ], + }; + + var extractDirectory = Path.Combine(targetDirectory, "extracted"); + using var cancellation = new CancellationTokenSource(); + var cancelWhenMarkerLands = CancelWhenFileAppearsAsync( + Path.Combine(extractDirectory, "marker.dat"), + cancellation); + + await Assert.ThrowsAnyAsync(() => + deliverer.DeliverContentAsync(manifest, targetDirectory, null, cancellation.Token)); + + await cancelWhenMarkerLands; + + Assert.True( + File.Exists(Path.Combine(targetDirectory, "content.zip")), + "the archive is the only recoverable copy of the content"); + Assert.True( + File.Exists(Path.Combine(extractDirectory, "first.dat")), + "the cancellation has to land after extraction started, not in front of it"); + Assert.False( + File.Exists(Path.Combine(extractDirectory, "large.dat")), + "the entry being written when the cancellation landed must not be left behind"); + Assert.Empty(Directory.GetFileSystemEntries(extractDirectory, "*.genhub-staging*")); + + manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that multi-variant hotkeys packages like hlei repack all language/game subdirectories. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RepackContentIfNeededAsync_WithHleiPackage_RepacksAllVariantBigFilesAsync() + { + var hleiManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.hlei"), + Name = "Leikeze's Hotkeys", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:hlei"], + }, + }; + + var zhEnDir = Path.Combine(_extractDirectory, "ZH", "BIG EN", "Data", "English"); + var zhDeDir = Path.Combine(_extractDirectory, "ZH", "BIG DE", "Data", "English"); + var zhRuDir = Path.Combine(_extractDirectory, "ZH", "BIG RU", "Data", "English"); + var ccgEnDir = Path.Combine(_extractDirectory, "CCG", "BIG EN", "Data", "English"); + + Directory.CreateDirectory(zhEnDir); + Directory.CreateDirectory(zhDeDir); + Directory.CreateDirectory(zhRuDir); + Directory.CreateDirectory(ccgEnDir); + + File.WriteAllText(Path.Combine(zhEnDir, "generals.csf"), "EN CSF"); + File.WriteAllText(Path.Combine(zhDeDir, "generals.csf"), "DE CSF"); + File.WriteAllText(Path.Combine(zhRuDir, "generals.csf"), "RU CSF"); + File.WriteAllText(Path.Combine(ccgEnDir, "generals.csf"), "CCG CSF"); + + var deliverer = CreateDeliverer(new Mock().Object, new Mock().Object); + var repackMethod = typeof(CommunityOutpostDeliverer).GetMethod( + "RepackContentIfNeededAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("RepackContentIfNeededAsync method not found."); + + await (Task)repackMethod.Invoke(deliverer, [hleiManifest, _extractDirectory, CancellationToken.None])!; + + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeENZH.big"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeDEZH.big"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeRUZH.big"))); + Assert.True(File.Exists(Path.Combine(_extractDirectory, "!HotkeysLeikezeEN.big"))); + Assert.False(Directory.Exists(Path.Combine(_extractDirectory, "ZH"))); + Assert.False(Directory.Exists(Path.Combine(_extractDirectory, "CCG"))); + } + + /// + /// Verifies that pre-existing BIG files inside a variant directory are copied to the resolved variant destination filename. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RepackContentIfNeededAsync_WithPreExistingBigInVariant_CopiesToResolvedVariantFileNameAsync() + { + var hleiManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.hlei"), + Name = "Leikeze's Hotkeys", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:hlei"], + }, + }; + + var zhEnDir = Path.Combine(_extractDirectory, "ZH", "BIG EN"); + Directory.CreateDirectory(zhEnDir); + File.WriteAllText(Path.Combine(zhEnDir, "arbitrary_name.big"), "PRE-PACKED BIG CONTENT"); + + var deliverer = CreateDeliverer(new Mock().Object, new Mock().Object); + var repackMethod = typeof(CommunityOutpostDeliverer).GetMethod( + "RepackContentIfNeededAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("RepackContentIfNeededAsync method not found."); + + await (Task)repackMethod.Invoke(deliverer, [hleiManifest, _extractDirectory, CancellationToken.None])!; + + var destFile = Path.Combine(_extractDirectory, "!HotkeysLeikezeENZH.big"); + Assert.True(File.Exists(destFile)); + Assert.Equal("PRE-PACKED BIG CONTENT", File.ReadAllText(destFile)); + } + + /// + /// Verifies that repacking is skipped for content metadata that does not require repacking. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task RepackContentIfNeededAsync_WithNonRepackingContent_SkipsRepackAsync() + { + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.communityoutpost.addon.gent"), + Name = "GenTool", + ContentType = GenHub.Core.Models.Enums.ContentType.Addon, + Publisher = new PublisherInfo { PublisherType = "communityoutpost" }, + Metadata = new ContentMetadata + { + Tags = ["contentCode:gent"], + }, + }; + + var dummyFile = Path.Combine(_extractDirectory, "d3d8.dll"); + File.WriteAllText(dummyFile, "DLL CONTENT"); + + var deliverer = CreateDeliverer(new Mock().Object, new Mock().Object); + var repackMethod = typeof(CommunityOutpostDeliverer).GetMethod( + "RepackContentIfNeededAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("RepackContentIfNeededAsync method not found."); + + await (Task)repackMethod.Invoke(deliverer, [manifest, _extractDirectory, CancellationToken.None])!; + + Assert.True(File.Exists(dummyFile)); + } + + private static CommunityOutpostDeliverer CreateDeliverer( + IDownloadService downloadService, + IContentManifestPool manifestPool) + { + var converter = new CompressedImageToTgaConverter(NullLogger.Instance); + var manifestFactory = new CommunityOutpostManifestFactory( + NullLogger.Instance, + new Mock().Object, + converter); + + return new CommunityOutpostDeliverer( + downloadService, + manifestPool, + manifestFactory, + new Mock().Object, + new Mock().Object, + converter, + NullLogger.Instance); + } + + private static void CreateArchive(string archivePath, params string[] entryNames) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes(entryName)); + } + } + + private static void CreateArchive(string archivePath, params (string EntryName, int ByteCount)[] entries) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (var (entryName, byteCount) in entries) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(new byte[byteCount]); + } + } + + private static Task CancelWhenFileAppearsAsync(string path, CancellationTokenSource cancellation) + { + return Task.Factory.StartNew( + () => + { + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!File.Exists(path) && DateTime.UtcNow < deadline) + { + } + + cancellation.Cancel(); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + private static async Task InvokeExtractArchiveAsync(string archivePath, string extractPath) + { + var extract = typeof(CommunityOutpostDeliverer).GetMethod( + "ExtractArchiveAsync", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new InvalidOperationException("CommunityOutpostDeliverer.ExtractArchiveAsync was not found."); + + await (Task)extract.Invoke(null, [archivePath, extractPath, CancellationToken.None])!; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostProfileReconcilerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostProfileReconcilerTests.cs index dc85d2dcd..0e09052ed 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostProfileReconcilerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/CommunityOutpost/CommunityOutpostProfileReconcilerTests.cs @@ -76,7 +76,7 @@ public CommunityOutpostProfileReconcilerTests() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_NoUpdateAvailable_ReturnsFalse() + public async Task CheckAndReconcileIfNeededAsync_NoUpdateAvailable_ReturnsFalseAsync() { _updateServiceMock .Setup(x => x.CheckForUpdatesAsync(It.IsAny())) @@ -93,7 +93,7 @@ public async Task CheckAndReconcileIfNeededAsync_NoUpdateAvailable_ReturnsFalse( /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_UpdateCheckFails_ReturnsFailure() + public async Task CheckAndReconcileIfNeededAsync_UpdateCheckFails_ReturnsFailureAsync() { _updateServiceMock .Setup(x => x.CheckForUpdatesAsync(It.IsAny())) @@ -109,7 +109,7 @@ public async Task CheckAndReconcileIfNeededAsync_UpdateCheckFails_ReturnsFailure /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_VersionSkipped_ReturnsFalse() + public async Task CheckAndReconcileIfNeededAsync_VersionSkipped_ReturnsFalseAsync() { const string latestVersion = "2.0.0"; @@ -135,7 +135,7 @@ public async Task CheckAndReconcileIfNeededAsync_VersionSkipped_ReturnsFalse() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_UserSkipsDialog_ReturnsFalse() + public async Task CheckAndReconcileIfNeededAsync_UserSkipsDialog_ReturnsFalseAsync() { _updateServiceMock .Setup(x => x.CheckForUpdatesAsync(It.IsAny())) @@ -166,7 +166,7 @@ public async Task CheckAndReconcileIfNeededAsync_UserSkipsDialog_ReturnsFalse() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireFails_ReturnsFailure() + public async Task CheckAndReconcileIfNeededAsync_AcquireFails_ReturnsFailureAsync() { const string latestVersion = "2.0.0"; @@ -204,7 +204,7 @@ public async Task CheckAndReconcileIfNeededAsync_AcquireFails_ReturnsFailure() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellation() + public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellationAsync() { const string latestVersion = "2.0.0"; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentStorageServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentStorageServiceTests.cs index 4b0943376..99310f1dd 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentStorageServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/ContentStorageServiceTests.cs @@ -80,7 +80,7 @@ public void Dispose() /// /// A representing the asynchronous unit test. [Fact] - public async Task StoreContentAsync_WithTraversingSourcePath_ShouldFail() + public async Task StoreContentAsync_WithTraversingSourcePath_ShouldFailAsync() { // Arrange // Source Dir: /Temp/Source @@ -121,7 +121,7 @@ public async Task StoreContentAsync_WithTraversingSourcePath_ShouldFail() /// /// A representing the asynchronous unit test. [Fact] - public async Task StoreContentAsync_WithValidExternalSourcePath_ShouldSucceed() + public async Task StoreContentAsync_WithValidExternalSourcePath_ShouldSucceedAsync() { // Arrange // Source Dir: /Temp/ExternalGame diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs new file mode 100644 index 000000000..7f1913983 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/EasyAntiCheatPreconditionTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using GenHub.Core.Constants; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for . +/// +public sealed class EasyAntiCheatPreconditionTests +{ + private readonly EasyAntiCheatPrecondition _precondition = new(NullLogger.Instance); + + /// + /// Verifies that CanHandle returns false when step or manifest is null. + /// + [Fact] + public void CanHandle_NullStepOrManifest_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + Assert.False(_precondition.CanHandle(null!, manifest)); + Assert.False(_precondition.CanHandle(step, null!)); + } + + /// + /// Verifies that CanHandle returns false when step kind is not RunVerifiedInstaller. + /// + [Fact] + public void CanHandle_NonInstallerKind_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = new InstallationStep + { + Name = "Remove File Step", + Kind = InstallationStepKind.RemoveFile, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }; + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that CanHandle returns false when publisher type is not GeneralsOnline. + /// + [Fact] + public void CanHandle_NonGeneralsOnlinePublisher_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + manifest.Publisher = new PublisherInfo + { + PublisherType = "OtherPublisher", + }; + + var step = CreateEacStep(); + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that CanHandle returns false when executable name does not match EAC setup executable. + /// + [Fact] + public void CanHandle_NonEacExecutable_ReturnsFalse() + { + var manifest = CreateBaseManifest(); + var step = new InstallationStep + { + Name = "Other Executable", + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = "other_installer.exe", + }; + + Assert.False(_precondition.CanHandle(step, manifest)); + } + + /// + /// Verifies that IsAlreadyFulfilled returns false on non-Windows platforms. + /// + [Fact] + public void IsAlreadyFulfilled_NonWindows_ReturnsFalse() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + Assert.False(_precondition.IsAlreadyFulfilled(step, manifest)); + } + + /// + /// Verifies that CanHandle behavior matches operating system requirements. + /// + [Fact] + public void CanHandle_ValidStep_MatchesOperatingSystem() + { + var manifest = CreateBaseManifest(); + var step = CreateEacStep(); + + var result = _precondition.CanHandle(step, manifest); + Assert.Equal(OperatingSystem.IsWindows(), result); + } + + private static ContentManifest CreateBaseManifest() => new() + { + Id = "1.0.test.gameclient.variant", + Name = "Generals Online", + Version = "1.0.0", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + + private static InstallationStep CreateEacStep() => new() + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + Arguments = ["install", GeneralsOnlineConstants.EacProductId], + StepKey = GeneralsOnlineConstants.EacStepKey, + RunOnce = true, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs new file mode 100644 index 000000000..1fd756ad7 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineClientIdentifierTests.cs @@ -0,0 +1,55 @@ +using GenHub.Core.Constants; +using GenHub.Features.Content.Services.GeneralsOnline; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Tests for across the pre- and post-EAC layouts. +/// +public class GeneralsOnlineClientIdentifierTests +{ + /// + /// The Easy Anti-Cheat bootstrapper is the supported entry point, so publisher discovery + /// must recognise it. + /// + [Fact] + public void Identify_EacLauncher_ReturnsSixtyHertzClient() + { + var identifier = new GeneralsOnlineClientIdentifier(); + var path = Path.Combine("C:", "GO", GameClientConstants.GeneralsOnlineEacLauncherExecutable); + + Assert.True(identifier.CanIdentify(path)); + + var identification = identifier.Identify(path); + + Assert.NotNull(identification); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, identification!.DisplayName); + } + + /// + /// Pre-EAC packages ship the 60Hz binary as the entry point and must still be recognised. + /// + [Fact] + public void Identify_SixtyHertzExecutable_ReturnsSixtyHertzClient() + { + var identifier = new GeneralsOnlineClientIdentifier(); + var path = Path.Combine("C:", "GO", GameClientConstants.GeneralsOnline60HzExecutable); + + Assert.True(identifier.CanIdentify(path)); + Assert.NotNull(identifier.Identify(path)); + } + + /// + /// Easy Anti-Cheat wraps only the 60Hz binary. The ordinary client binary ships alongside it + /// as workspace content and is not a supported entry point. + /// + [Fact] + public void Identify_DefaultExecutable_IsNotRecognised() + { + var identifier = new GeneralsOnlineClientIdentifier(); + var path = Path.Combine("C:", "GO", GameClientConstants.GeneralsOnlineDefaultExecutable); + + Assert.False(identifier.CanIdentify(path)); + Assert.Null(identifier.Identify(path)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs new file mode 100644 index 000000000..ec5ff6a19 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineDelivererTests.cs @@ -0,0 +1,645 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for . +/// +public class GeneralsOnlineDelivererTests : IDisposable +{ + private readonly Mock _downloadServiceMock; + private readonly Mock _manifestPoolMock; + private readonly Mock _providerLoaderMock; + private readonly GeneralsOnlineManifestFactory _manifestFactory; + private readonly GeneralsOnlineDeliverer _deliverer; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public GeneralsOnlineDelivererTests() + { + _downloadServiceMock = new Mock(); + _manifestPoolMock = new Mock(); + _providerLoaderMock = new Mock(); + + _providerLoaderMock + .Setup(l => l.GetProvider(PublisherTypeConstants.GeneralsOnline)) + .Returns(new ProviderDefinition + { + ProviderId = PublisherTypeConstants.GeneralsOnline, + PublisherType = PublisherTypeConstants.GeneralsOnline, + Endpoints = new ProviderEndpoints + { + WebsiteUrl = "https://example.com/go", + }, + }); + + _manifestPoolMock + .Setup(p => p.IsManifestAcquiredAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(false)); + + _manifestFactory = new GeneralsOnlineManifestFactory( + NullLogger.Instance, + _providerLoaderMock.Object); + + _deliverer = new GeneralsOnlineDeliverer( + _downloadServiceMock.Object, + _manifestPoolMock.Object, + _manifestFactory, + NullLogger.Instance); + + _tempDir = Path.Combine(Path.GetTempPath(), "GenHub_GODelivererTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up test artifacts. + /// + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies CanDeliver returns true for GeneralsOnline manifests with zip downloads. + /// + [Fact] + public void CanDeliver_ValidGeneralsOnlineManifest_ReturnsTrue() + { + var manifest = new ContentManifest + { + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + Assert.True(_deliverer.CanDeliver(manifest)); + } + + /// + /// Verifies CanDeliver returns false for other publishers. + /// + [Fact] + public void CanDeliver_OtherPublisher_ReturnsFalse() + { + var manifest = new ContentManifest + { + Publisher = new PublisherInfo { PublisherType = "other-publisher" }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/other.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + Assert.False(_deliverer.CanDeliver(manifest)); + } + + /// + /// Verifies CanDeliver returns false for GeneralsOnline manifests without a ZIP download URL. + /// + [Fact] + public void CanDeliver_ManifestWithoutZipFile_ReturnsFalse() + { + var manifest = new ContentManifest + { + Publisher = new PublisherInfo + { + Name = GeneralsOnlineConstants.PublisherName, + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline.exe", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + Assert.False(_deliverer.CanDeliver(manifest)); + } + + /// + /// Verifies DeliverContentAsync fails when any manifest registration in pool fails. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_WhenManifestRegistrationFails_ReturnsFailureAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "test.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + // First manifest registration succeeds, second fails + var callCount = 0; + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + return callCount == 1 + ? OperationResult.CreateSuccess(true) + : OperationResult.CreateFailure("Simulated pool registration failure"); + }); + + _manifestPoolMock + .Setup(p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.Contains("Simulated pool registration failure", result.FirstError); + + // Verifies that earlier successfully registered manifest was rolled back + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.Is(id => id.Value == manifest.Id.Value), + false, + It.IsAny()), + Times.Once); + + // Temp artifacts should be cleaned up on failure + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies the happy path of DeliverContentAsync: all manifests register, + /// files are moved to target directory, and temporary extraction directory is cleaned up. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_HappyPath_RegistersAllManifestsAndCleansUpAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "happy_test.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "happy_delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + + // Exactly 2 manifests were registered in pool (GameClient and GameData Patch; empty MapPack is skipped) + _manifestPoolMock.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + + // Rollback was never invoked on the happy path + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + // Files were moved to target directory + Assert.True(File.Exists(Path.Combine(targetDir, "generalsonlinezh_60.exe"))); + Assert.True(File.Exists(Path.Combine(targetDir, "GeneralsOnlineGameData", "500_900_CommunityPatch_CoreINI.big"))); + + // Downloaded ZIP and temporary extracted directory were cleaned up + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies that if manifest acquisition check fails, rollback is triggered and failure is returned. + /// + /// A task representing the asynchronous unit test. + [Fact] + public async Task DeliverContentAsync_CheckAcquisitionFails_RollsBackAndReturnsFailureAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "check_fail.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + // First check succeeds, second check fails + _manifestPoolMock + .SetupSequence(p => p.IsManifestAcquiredAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(false)) + .ReturnsAsync(OperationResult.CreateFailure("CAS index corrupted")); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _manifestPoolMock + .Setup(p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "check_fail_delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.Contains("Failed to check manifest acquisition status", result.FirstError); + + // First manifest was rolled back + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.Is(id => id.Value == manifest.Id.Value), + false, + It.IsAny()), + Times.Once); + + // Temp artifacts should be cleaned up on failure + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies that already-acquired manifests are skipped during registration. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_ManifestAlreadyAcquired_SkipsRegistrationAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "already_acquired.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + // First manifest is already acquired, second is not + _manifestPoolMock + .SetupSequence(p => p.IsManifestAcquiredAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)) + .ReturnsAsync(OperationResult.CreateSuccess(false)); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var targetDir = Path.Combine(_tempDir, "already_acquired_delivery"); + Directory.CreateDirectory(targetDir); + var result = await _deliverer.DeliverContentAsync(manifest, targetDir, null, CancellationToken.None); + + // Assert + Assert.True(result.Success); + + // AddManifestAsync called only once (for the unacquired Patch manifest, skipping GameClient) + _manifestPoolMock.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that cancellation during manifest registration triggers rollback, cleans temp artifacts, and rethrows OperationCanceledException. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_CancellationDuringRegistration_RollsBackAndRethrowsAsync() + { + // Arrange + var zipPath = Path.Combine(_tempDir, "cancel_test.zip"); + CreateTestZip(zipPath); + + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => File.Copy(zipPath, path, true)) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + }, + ], + }; + + using var cts = new CancellationTokenSource(); + + var callCount = 0; + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return OperationResult.CreateSuccess(true); + } + + cts.Cancel(); + throw new OperationCanceledException(cts.Token); + }); + + _manifestPoolMock + .Setup(p => p.RemoveManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act & Assert + var targetDir = Path.Combine(_tempDir, "cancel_delivery"); + Directory.CreateDirectory(targetDir); + + await Assert.ThrowsAsync( + () => _deliverer.DeliverContentAsync(manifest, targetDir, null, cts.Token)); + + // Rollback was invoked for the earlier registered manifest + _manifestPoolMock.Verify( + p => p.RemoveManifestAsync( + It.Is(id => id.Value == manifest.Id.Value), + false, + It.IsAny()), + Times.Once); + + // Temp artifacts were cleaned up + Assert.False(File.Exists(Path.Combine(targetDir, "GeneralsOnline.zip"))); + Assert.False(Directory.Exists(Path.Combine(targetDir, "extracted"))); + } + + /// + /// Verifies that DeliverContentAsync passes the declared expected hash to IDownloadService.DownloadFileAsync. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task DeliverContentAsync_WithDeclaredHash_PassesExpectedHashToDownloadServiceAsync() + { + // Arrange + const string expectedHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + var zipPath = Path.Combine(_tempDir, "test_hash.zip"); + CreateTestZip(zipPath); + + string? capturedExpectedHash = null; + _downloadServiceMock + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Callback?, CancellationToken>((url, path, hash, prog, token) => + { + capturedExpectedHash = hash; + File.Copy(zipPath, path, true); + }) + .ReturnsAsync(DownloadResult.CreateSuccess(zipPath, 100, TimeSpan.FromSeconds(1))); + + _manifestPoolMock + .Setup(p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Files = + [ + new ManifestFile + { + DownloadUrl = "https://example.com/GeneralsOnline_101525_QFE5.zip", + SourceType = ContentSourceType.RemoteDownload, + Hash = expectedHash, + }, + ], + InstallationInstructions = new InstallationInstructions + { + DownloadHash = expectedHash, + }, + }; + + var targetDir = Path.Combine(_tempDir, "hash_delivery"); + Directory.CreateDirectory(targetDir); + + // Act + var result = await _deliverer.DeliverContentAsync(manifest, targetDir); + + // Assert + Assert.True(result.Success); + Assert.Equal(expectedHash, capturedExpectedHash); + } + + private static void CreateTestZip(string zipPath) + { + using var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create); + CreateEntryWithText(archive, "generalsonlinezh_60.exe", "fake content"); + CreateEntryWithText(archive, "GeneralsOnlineGameData/500_900_CommunityPatch_CoreINI.big", "fake big content"); + } + + private static void CreateEntryWithText(ZipArchive archive, string entryName, string content) + { + var entry = archive.CreateEntry(entryName); + using var writer = new StreamWriter(entry.Open()); + writer.Write(content); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs index e2e10254f..db6aaaff6 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.GeneralsOnline; using GenHub.Core.Models.Providers; using GenHub.Features.Content.Services.GeneralsOnline; using Microsoft.Extensions.Logging.Abstractions; @@ -44,7 +45,7 @@ public GeneralsOnlineJsonCatalogParserTests() /// /// A representing the asynchronous test operation. [Fact] - public async Task ParseAsync_WithPascalCaseJson_ParsesCorrectly() + public async Task ParseAsync_WithPascalCaseJson_ParsesCorrectlyAsync() { // Arrange var json = @"{ @@ -70,7 +71,7 @@ public async Task ParseAsync_WithPascalCaseJson_ParsesCorrectly() /// /// A representing the asynchronous test operation. [Fact] - public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectly() + public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectlyAsync() { // Arrange // Standard lowercase/camelCase that matches exact property names if attributes weren't there @@ -91,4 +92,34 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectly() var item = result.Data.First(); Assert.Equal("111825_QFE2", item.Version); } + + /// + /// Tests that ParseAsync correctly populates the SHA256 hash when present in the API response. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_WithSha256_PopulatesSha256OnReleaseAsync() + { + // Arrange + const string expectedSha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + var json = $@"{{ + ""version"": ""111825_QFE2"", + ""download_url"": ""https://example.com/download.zip"", + ""size"": 123456, + ""sha256"": ""{expectedSha256}"", + ""release_notes"": ""Fixes stuff"" + }}"; + + var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data.First(); + var release = item.GetData(); + Assert.NotNull(release); + Assert.Equal(expectedSha256, release.Sha256); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs new file mode 100644 index 000000000..443824a51 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryEacTests.cs @@ -0,0 +1,295 @@ +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Tests covering the Easy Anti-Cheat era layout of the Generals Online portable, +/// where EAC_LaunchGeneralsOnline.exe wraps the game binary named by +/// EasyAntiCheat/Settings.json. +/// +public class GeneralsOnlineManifestFactoryEacTests : IDisposable +{ + private readonly string _extractedDirectory; + + /// + /// Initializes a new instance of the class. + /// + public GeneralsOnlineManifestFactoryEacTests() + { + _extractedDirectory = Path.Combine(Path.GetTempPath(), $"genhub-eac-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_extractedDirectory); + } + + /// + /// The EAC bootstrapper is the launch target, so it must be the file carrying + /// in the game client manifest. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_MarksWrapperAsExecutableAsync() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + var executables = gameClient.Files.Where(file => file.IsExecutable).ToList(); + var executable = Assert.Single(executables); + Assert.Equal( + GameClientConstants.GeneralsOnlineEacLauncherExecutable, + Path.GetFileName(executable.RelativePath), + ignoreCase: true); + } + + /// + /// Easy Anti-Cheat launches the binary named by its settings file, so the wrapped + /// game binary must remain in the workspace as a non-launch file. Dropping it + /// leaves the bootstrapper with nothing to start. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_RetainsWrappedBinaryAsWorkspaceFileAsync() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + var wrapped = gameClient.Files.SingleOrDefault(file => + Path.GetFileName(file.RelativePath) + .Equals(GameClientConstants.GeneralsOnline60HzExecutable, StringComparison.OrdinalIgnoreCase)); + + Assert.NotNull(wrapped); + Assert.False(wrapped!.IsExecutable); + } + + /// + /// The portable also ships a non-60Hz binary. Easy Anti-Cheat wraps only the binary named + /// by its settings file, so the other one stays as plain workspace content. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_RetainsDefaultBinaryAsWorkspaceFileAsync() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + var defaultBinary = gameClient.Files.SingleOrDefault(file => + Path.GetFileName(file.RelativePath) + .Equals(GameClientConstants.GeneralsOnlineDefaultExecutable, StringComparison.OrdinalIgnoreCase)); + + Assert.NotNull(defaultBinary); + Assert.False(defaultBinary!.IsExecutable); + } + + /// + /// Only the bootstrapper at the archive root is the supported entry point. A nested file + /// that merely shares its name must not divert the launch target away from the real client. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_NestedWrapperName_DoesNotBecomeLaunchTargetAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile(Path.Combine("tools", GameClientConstants.GeneralsOnlineEacLauncherExecutable)); + + var gameClient = await CreateGameClientManifestAsync(); + + var executables = gameClient.Files.Where(file => file.IsExecutable).ToList(); + var executable = Assert.Single(executables); + Assert.Equal( + GameClientConstants.GeneralsOnline60HzExecutable, + executable.RelativePath, + ignoreCase: true); + } + + /// + /// Pre-EAC portables ship no bootstrapper, so the 60Hz binary stays the launch target. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_MarksSixtyHertzBinaryAsExecutableAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var gameClient = await CreateGameClientManifestAsync(); + + var executables = gameClient.Files.Where(file => file.IsExecutable).ToList(); + var executable = Assert.Single(executables); + Assert.Equal( + GameClientConstants.GeneralsOnline60HzExecutable, + Path.GetFileName(executable.RelativePath), + ignoreCase: true); + } + + /// + /// Verifies that EAC portable layout configures a post-install step to run the verified EAC setup executable. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EacLayout_ConfiguresEacPostInstallStepAsync() + { + WriteEacPortableLayout(); + + var gameClient = await CreateGameClientManifestAsync(); + + Assert.NotNull(gameClient.InstallationInstructions); + var postSteps = gameClient.InstallationInstructions.PostInstallSteps; + var eacStep = Assert.Single(postSteps); + + Assert.Equal(GeneralsOnlineConstants.EacStepName, eacStep.Name); + Assert.Equal(InstallationStepKind.RunVerifiedInstaller, eacStep.Kind); + Assert.Equal(GameClientConstants.GeneralsOnlineEacSetupExecutable, eacStep.TargetRelativePath); + Assert.True(eacStep.RequiresElevation); + Assert.True(eacStep.RunOnce); + Assert.Equal(GeneralsOnlineConstants.EacStepKey, eacStep.StepKey); + Assert.Equal(GeneralsOnlineConstants.EacStatusMessage, eacStep.StatusMessage); + Assert.NotNull(eacStep.Arguments); + Assert.Equal( + [GeneralsOnlineConstants.EacInstallCommand, GeneralsOnlineConstants.EacProductId], + eacStep.Arguments); + } + + /// + /// Verifies that Pre-EAC portable layout does not configure an EAC post-install step when setup executable is absent. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreEacLayout_DoesNotConfigureEacPostInstallStepAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var gameClient = await CreateGameClientManifestAsync(); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)); + Assert.Null(eacStep); + } + + /// + /// Verifies that an inherited EAC step is not duplicated when EAC portable layout already contains the setup executable. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutablePresent_DoesNotDuplicateEacStepAsync() + { + WriteEacPortableLayout(); + + var originalManifest = CreateOriginalManifest(); + originalManifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }, + ], + }; + + var gameClient = await CreateGameClientManifestAsync(originalManifest); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacSteps = gameClient.InstallationInstructions.PostInstallSteps.Where(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)).ToList(); + Assert.Single(eacSteps); + } + + /// + /// Verifies that an inherited EAC step is dropped when the setup executable is absent in extracted content. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_InheritedEacStep_SetupExecutableAbsent_DropsEacStepAsync() + { + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile("libcurl.dll"); + + var originalManifest = CreateOriginalManifest(); + originalManifest.InstallationInstructions = new InstallationInstructions + { + PostInstallSteps = + [ + new InstallationStep + { + Name = GeneralsOnlineConstants.EacStepName, + Kind = InstallationStepKind.RunVerifiedInstaller, + TargetRelativePath = GameClientConstants.GeneralsOnlineEacSetupExecutable, + }, + ], + }; + + var gameClient = await CreateGameClientManifestAsync(originalManifest); + + Assert.NotNull(gameClient.InstallationInstructions); + var eacStep = gameClient.InstallationInstructions.PostInstallSteps.FirstOrDefault(s => + string.Equals(s.TargetRelativePath, GameClientConstants.GeneralsOnlineEacSetupExecutable, StringComparison.OrdinalIgnoreCase)); + Assert.Null(eacStep); + } + + /// + public void Dispose() + { + GC.SuppressFinalize(this); + if (Directory.Exists(_extractedDirectory)) + { + Directory.Delete(_extractedDirectory, recursive: true); + } + } + + private static ContentManifest CreateOriginalManifest() => new() + { + Id = "1.605261.generalsonline.gameclient.60hz", + Name = "GeneralsOnline", + Version = "060526_QFE1", + ContentType = ContentType.GameClient, + TargetGame = GameType.ZeroHour, + Publisher = new PublisherInfo + { + Name = "GeneralsOnline", + PublisherType = PublisherTypeConstants.GeneralsOnline, + }, + }; + + private void WriteEacPortableLayout() + { + WriteFile(GameClientConstants.GeneralsOnlineEacLauncherExecutable); + WriteFile(GameClientConstants.GeneralsOnlineEacSetupExecutable); + WriteFile(GameClientConstants.GeneralsOnline60HzExecutable); + WriteFile(GameClientConstants.GeneralsOnlineDefaultExecutable); + WriteFile(Path.Combine("EasyAntiCheat", "Settings.json")); + WriteFile("EOSSDK-Win32-Shipping.dll"); + } + + private void WriteFile(string relativePath) + { + var fullPath = Path.Combine(_extractedDirectory, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + File.WriteAllText(fullPath, relativePath); + } + + private async Task CreateGameClientManifestAsync(ContentManifest? originalManifest = null) + { + var providerLoader = new Mock(); + var factory = new GeneralsOnlineManifestFactory( + NullLogger.Instance, + providerLoader.Object); + + var manifests = await factory.CreateManifestsFromExtractedContentAsync( + originalManifest ?? CreateOriginalManifest(), + _extractedDirectory); + + return manifests.Single(manifest => manifest.ContentType == ContentType.GameClient); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs new file mode 100644 index 000000000..200f82338 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactoryTests.cs @@ -0,0 +1,433 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GeneralsOnline; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Providers; +using GenHub.Features.Content.Services.GeneralsOnline; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; + +/// +/// Unit tests for and related dependency creation. +/// +public class GeneralsOnlineManifestFactoryTests : IDisposable +{ + private readonly Mock _providerLoaderMock; + private readonly GeneralsOnlineManifestFactory _factory; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public GeneralsOnlineManifestFactoryTests() + { + _providerLoaderMock = new Mock(); + _providerLoaderMock + .Setup(l => l.GetProvider(PublisherTypeConstants.GeneralsOnline)) + .Returns(new ProviderDefinition + { + ProviderId = PublisherTypeConstants.GeneralsOnline, + PublisherType = PublisherTypeConstants.GeneralsOnline, + Description = "Community multiplayer for Generals Zero Hour", + DefaultTags = ["multiplayer", "online"], + Endpoints = new ProviderEndpoints + { + WebsiteUrl = "https://example.com/go", + }, + }); + + _factory = new GeneralsOnlineManifestFactory( + NullLogger.Instance, + _providerLoaderMock.Object); + + _tempDir = Path.Combine(Path.GetTempPath(), "GenHub_GOTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up temporary test directory. + /// + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, true); + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that generates 3 manifests: + /// 60Hz GameClient, QuickMatch MapPack, and GeneralsOnlineGameData data patch. + /// + [Fact] + public void CreateManifests_GeneratesThreeManifests_IncludingGameDataPatch() + { + // Arrange + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/GeneralsOnline_portable_101525_QFE5.zip", + PortableSize = 1048576, + Changelog = "https://example.com/changelog", + }; + + // Act + var manifests = _factory.CreateManifests(release); + + // Assert + Assert.Equal(3, manifests.Count); + + var gameClient = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient); + var mapPack = manifests.FirstOrDefault(m => m.ContentType == ContentType.MapPack); + var gameDataPatch = manifests.FirstOrDefault(m => m.ContentType == ContentType.Patch); + + Assert.NotNull(gameClient); + Assert.NotNull(mapPack); + Assert.NotNull(gameDataPatch); + + // Verify GameClient manifest + Assert.Contains(GeneralsOnlineConstants.Variant60HzSuffix, gameClient.Id.Value); + Assert.Equal(GameType.ZeroHour, gameClient.TargetGame); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, gameClient.Name); + + // Verify MapPack manifest + Assert.Contains("quickmatchmaps", mapPack.Id.Value); + Assert.Equal(GameType.ZeroHour, mapPack.TargetGame); + Assert.Equal(GeneralsOnlineConstants.QuickMatchMapPackDisplayName, mapPack.Name); + + // Verify GameData Patch manifest + Assert.Contains(GeneralsOnlineConstants.GameDataPatchSuffix, gameDataPatch.Id.Value); + Assert.Equal(ContentType.Patch, gameDataPatch.ContentType); + Assert.Equal(GameType.ZeroHour, gameDataPatch.TargetGame); + Assert.Equal(GeneralsOnlineConstants.GameDataDisplayName, gameDataPatch.Name); + Assert.Equal(GeneralsOnlineConstants.GameDataDescription, gameDataPatch.Metadata?.Description); + Assert.Contains(GeneralsOnlineVariantTags.TagGameData, gameDataPatch.Metadata?.Tags ?? []); + } + + /// + /// Verifies that the GameData patch depends on the 60Hz GameClient and Zero Hour, + /// while the 60Hz GameClient does not depend on the GameData patch (making GameData patch optional). + /// + [Fact] + public void Dependencies_GameDataPatch_DependsOn60HzGameClientAndZeroHour_WhileGameClientDoesNotDependOnGameData() + { + // Arrange + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/test.zip", + }; + + // Act + var manifests = _factory.CreateManifests(release); + var gameClient = manifests.First(m => m.ContentType == ContentType.GameClient); + var gameDataPatch = manifests.First(m => m.ContentType == ContentType.Patch); + + // Assert - GameData patch has dependencies on Zero Hour and 60Hz GameClient + Assert.NotEmpty(gameDataPatch.Dependencies); + var zhDepInPatch = gameDataPatch.Dependencies.FirstOrDefault(d => d.DependencyType == ContentType.GameInstallation); + var clientDepInPatch = gameDataPatch.Dependencies.FirstOrDefault(d => d.DependencyType == ContentType.GameClient); + + Assert.NotNull(zhDepInPatch); + Assert.NotNull(clientDepInPatch); + Assert.Equal(gameClient.Id.Value, clientDepInPatch.Id.Value); + Assert.False(clientDepInPatch.IsOptional); + Assert.True(clientDepInPatch.StrictPublisher); + Assert.Equal(PublisherTypeConstants.GeneralsOnline, clientDepInPatch.PublisherType); + + // Assert - GameClient dependencies do NOT include Patch dependency + Assert.DoesNotContain(gameClient.Dependencies, d => d.DependencyType == ContentType.Patch); + Assert.DoesNotContain(gameClient.Dependencies, d => d.Id.Value.Contains(GeneralsOnlineConstants.GameDataPatchSuffix)); + } + + /// + /// Verifies that returns true for GameClient, MapPack, and Patch. + /// + [Fact] + public void CanHandle_WithValidManifestTypes_ReturnsTrue() + { + // Arrange + var publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }; + + var clientManifest = new ContentManifest { ContentType = ContentType.GameClient, Publisher = publisher }; + var mapPackManifest = new ContentManifest { ContentType = ContentType.MapPack, Publisher = publisher }; + var patchManifest = new ContentManifest { ContentType = ContentType.Patch, Publisher = publisher }; + var otherPublisherManifest = new ContentManifest { ContentType = ContentType.Patch, Publisher = new PublisherInfo { PublisherType = "other" } }; + var otherTypeManifest = new ContentManifest { ContentType = ContentType.Mod, Publisher = publisher }; + + // Act & Assert + Assert.True(_factory.CanHandle(clientManifest)); + Assert.True(_factory.CanHandle(mapPackManifest)); + Assert.True(_factory.CanHandle(patchManifest)); + Assert.False(_factory.CanHandle(otherPublisherManifest)); + Assert.False(_factory.CanHandle(otherTypeManifest)); + } + + /// + /// Verifies that separates files + /// correctly among GameClient, MapPack, and GameData Patch manifests. + /// + /// A representing the test execution. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_SeparatesFilesCorrectlyAsync() + { + // Arrange: Create simulated extracted directory structure + var exePath = Path.Combine(_tempDir, GameClientConstants.GeneralsOnline60HzExecutable); + var dllPath = Path.Combine(_tempDir, "GameNetworkingSockets.dll"); + File.WriteAllText(exePath, "fake exe content"); + File.WriteAllText(dllPath, "fake dll content"); + + var mapsDir = Path.Combine(_tempDir, GeneralsOnlineConstants.MapsSubdirectory, "Tournament Desert"); + Directory.CreateDirectory(mapsDir); + var mapFilePath = Path.Combine(mapsDir, "Tournament Desert.map"); + File.WriteAllText(mapFilePath, "fake map content"); + + var gameDataDir = Path.Combine(_tempDir, GeneralsOnlineConstants.GameDataSubdirectory); + Directory.CreateDirectory(gameDataDir); + var bigPath = Path.Combine(gameDataDir, "500_900_CommunityPatch_CoreINI.big"); + File.WriteAllText(bigPath, "fake big content"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Metadata = new ContentMetadata { ReleaseDate = DateTime.UtcNow }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, CancellationToken.None); + + // Assert + Assert.Equal(3, manifests.Count); + + var gameClient = manifests.First(m => m.ContentType == ContentType.GameClient); + var mapPack = manifests.First(m => m.ContentType == ContentType.MapPack); + var gameDataPatch = manifests.First(m => m.ContentType == ContentType.Patch); + + // Check GameClient files + Assert.Equal(2, gameClient.Files.Count); + Assert.Contains(gameClient.Files, f => f.RelativePath == GameClientConstants.GeneralsOnline60HzExecutable && f.IsExecutable && f.InstallTarget == ContentInstallTarget.Workspace); + Assert.Contains(gameClient.Files, f => f.RelativePath == "GameNetworkingSockets.dll" && !f.IsExecutable && f.InstallTarget == ContentInstallTarget.Workspace); + Assert.DoesNotContain(gameClient.Files, f => f.RelativePath.Contains("Maps")); + Assert.DoesNotContain(gameClient.Files, f => f.RelativePath.Contains("GeneralsOnlineGameData")); + + // Check MapPack files + Assert.Single(mapPack.Files); + var mapFile = mapPack.Files[0]; + Assert.Equal(ContentInstallTarget.UserMapsDirectory, mapFile.InstallTarget); + Assert.False(mapFile.IsExecutable); + Assert.EndsWith(".map", mapFile.RelativePath, StringComparison.OrdinalIgnoreCase); + Assert.False(mapFile.RelativePath.StartsWith("Maps", StringComparison.OrdinalIgnoreCase)); + + // Check GameData patch files + Assert.Single(gameDataPatch.Files); + Assert.All(gameDataPatch.Files, f => + { + Assert.Equal(ContentInstallTarget.UserDataDirectory, f.InstallTarget); + Assert.False(f.IsExecutable); + Assert.StartsWith(GeneralsOnlineConstants.GameDataSubdirectory, f.RelativePath, StringComparison.OrdinalIgnoreCase); + Assert.NotEmpty(f.Hash); + }); + Assert.Contains(gameDataPatch.Files, f => f.RelativePath.EndsWith("500_900_CommunityPatch_CoreINI.big", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies that directories with names starting with "Maps" or "GeneralsOnlineGameData" (e.g. Maps_backup, GeneralsOnlineGameData_backup) + /// are not misclassified as Maps or GeneralsOnlineGameData. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_SiblingDirectories_AreNotMisclassifiedAsync() + { + // Arrange + var siblingMapDir = Path.Combine(_tempDir, "Maps_backup"); + Directory.CreateDirectory(siblingMapDir); + File.WriteAllText(Path.Combine(siblingMapDir, "backup.map"), "fake map backup"); + + var siblingGameDataDir = Path.Combine(_tempDir, "GeneralsOnlineGameData_backup"); + Directory.CreateDirectory(siblingGameDataDir); + File.WriteAllText(Path.Combine(siblingGameDataDir, "backup.ini"), "fake ini backup"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + Metadata = new ContentMetadata { ReleaseDate = DateTime.UtcNow }, + }; + + // Act + var manifests = await _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, CancellationToken.None); + + // Assert - MapPack and GameData patch are omitted because they have 0 files + Assert.Single(manifests); + var gameClient = manifests.Single(); + Assert.Equal(ContentType.GameClient, gameClient.ContentType); + Assert.DoesNotContain(manifests, m => m.ContentType == ContentType.MapPack); + Assert.DoesNotContain(manifests, m => m.ContentType == ContentType.Patch); + + // Assert - GameClient must contain the sibling files as workspace files + Assert.Contains(gameClient.Files, f => f.RelativePath.Contains("Maps_backup")); + Assert.Contains(gameClient.Files, f => f.RelativePath.Contains("GeneralsOnlineGameData_backup")); + } + + /// + /// Verifies that throws + /// when passed a cancelled token. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_PreCancelledToken_ThrowsOperationCanceledExceptionAsync() + { + // Arrange + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + }; + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync(() => + _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, cts.Token)); + } + + /// + /// Verifies that GameData patch metadata tags do not contain duplicate tags. + /// + [Fact] + public void CreateManifests_GameDataPatchTags_HasNoDuplicateTags() + { + // Arrange + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/test.zip", + }; + + // Act + var manifests = _factory.CreateManifests(release); + var gameDataPatch = manifests.First(m => m.ContentType == ContentType.Patch); + + // Assert + var tags = gameDataPatch.Metadata?.Tags ?? []; + var distinctTags = tags.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + Assert.Equal(distinctTags.Count, tags.Count); + Assert.Contains("gamedata", tags); + Assert.Contains("patch", tags); + Assert.Contains("generalsonline", tags); + } + + /// + /// Verifies that throws + /// when the GameClient manifest has zero files. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task CreateManifestsFromExtractedContentAsync_EmptyGameClient_ThrowsInvalidDataExceptionAsync() + { + // Arrange: Only create map files, no GameClient files + var mapsDir = Path.Combine(_tempDir, GeneralsOnlineConstants.MapsSubdirectory, "TestMap"); + Directory.CreateDirectory(mapsDir); + File.WriteAllText(Path.Combine(mapsDir, "TestMap.map"), "fake map"); + + var originalManifest = new ContentManifest + { + Id = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"), + Name = GameClientConstants.GeneralsOnline60HzDisplayName, + Version = "101525_QFE5", + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + }; + + // Act & Assert + await Assert.ThrowsAsync(() => + _factory.CreateManifestsFromExtractedContentAsync(originalManifest, _tempDir, CancellationToken.None)); + } + + /// + /// Verifies directly returns the expected GameData dependencies. + /// + [Fact] + public void DependencyBuilder_GetDependenciesForGameData_ReturnsExpectedDependencies() + { + // Arrange + var expectedClientId = ManifestId.Create("1.1015255.generalsonline.gameclient.60hz"); + + // Act + var dependencies = GeneralsOnlineDependencyBuilder.GetDependenciesForGameData(1015255); + + // Assert + Assert.Equal(2, dependencies.Count); + Assert.Contains(dependencies, d => d.DependencyType == ContentType.GameInstallation); + var clientDep = dependencies.First(d => d.DependencyType == ContentType.GameClient); + Assert.Equal(expectedClientId.Value, clientDep.Id.Value); + + var builder = new GeneralsOnlineDependencyBuilder(); + var patchManifest = new ContentManifest + { + Version = "101525_QFE5", + ContentType = ContentType.Patch, + Publisher = new PublisherInfo { PublisherType = PublisherTypeConstants.GeneralsOnline }, + }; + var resolvedDeps = builder.GetDependencies(patchManifest); + Assert.Equal(2, resolvedDeps.Count); + Assert.Contains(resolvedDeps, d => d.DependencyType == ContentType.GameInstallation); + var resolvedClientDep = resolvedDeps.First(d => d.DependencyType == ContentType.GameClient); + Assert.Equal(expectedClientId.Value, resolvedClientDep.Id.Value); + } + + /// + /// Verifies that CreateManifests propagates Sha256 to file hash and installation instructions download hash. + /// + [Fact] + public void CreateManifests_WithSha256_SetsFileHashAndDownloadHash() + { + // Arrange + const string expectedHash = "abc123hash"; + var release = new GeneralsOnlineRelease + { + Version = "101525_QFE5", + ReleaseDate = DateTime.UtcNow, + PortableUrl = "https://example.com/GeneralsOnline_portable_101525_QFE5.zip", + PortableSize = 1048576, + Sha256 = expectedHash, + Changelog = "https://example.com/changelog", + }; + + // Act + var manifests = _factory.CreateManifests(release); + + // Assert + var gameClient = manifests.FirstOrDefault(m => m.ContentType == ContentType.GameClient); + Assert.NotNull(gameClient); + Assert.Equal(expectedHash, gameClient.InstallationInstructions?.DownloadHash); + var zipFile = Assert.Single(gameClient.Files); + Assert.Equal(expectedHash, zipFile.Hash); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs index ac8f9f7db..9734bbafc 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineProfileReconcilerTests.cs @@ -17,6 +17,7 @@ using GenHub.Tests.Core.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Moq; +using ContentType = GenHub.Core.Models.Enums.ContentType; namespace GenHub.Tests.Core.Features.Content.Services.GeneralsOnline; @@ -80,7 +81,7 @@ public GeneralsOnlineProfileReconcilerTests() /// /// A task representing the asynchronous operation. [Fact] - public async Task CheckAndReconcile_ShouldIgnore_LocalManifests() + public async Task CheckAndReconcile_ShouldIgnore_LocalManifestsAsync() { // Arrange string latestVersion = "0.0.99"; @@ -145,7 +146,7 @@ public async Task CheckAndReconcile_ShouldIgnore_LocalManifests() /// /// A task representing the asynchronous operation. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellation() + public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellationAsync() { // Arrange string latestVersion = "0.0.99"; @@ -180,4 +181,99 @@ await Assert.ThrowsAsync( x => x.ShowError(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + /// + /// Verifies that old and new GameData patch manifests are recognized by variant and included in reconciliation mapping. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task CheckAndReconcileIfNeededAsync_WithGameDataPatchManifest_MapsAndReconcilesGameDataPatchAsync() + { + // Arrange + const string oldVersion = "101524"; + const string newVersion = "101525"; + + _updateServiceMock.Setup(x => x.CheckForUpdatesAsync(It.IsAny())) + .ReturnsAsync(ContentUpdateCheckResult.CreateUpdateAvailable(newVersion, oldVersion)); + + var settings = new UserSettings(); + settings.SetAutoUpdatePreference(GeneralsOnlineConstants.PublisherType, true); + settings.GetOrCreateSubscription(GeneralsOnlineConstants.PublisherType).DeleteOldVersions = true; + _userSettingsServiceMock.Setup(x => x.Get()) + .Returns(settings); + + var oldClientManifest = new ContentManifest + { + Id = ManifestId.Create("1.101524.generalsonline.gameclient.60hz"), + Version = oldVersion, + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + }; + + var oldPatchManifest = new ContentManifest + { + Id = ManifestId.Create("1.101524.generalsonline.patch.gamedata"), + Version = oldVersion, + ContentType = ContentType.Patch, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["gamedata", "patch", "generalsonline"] }, + }; + + var newClientManifest = new ContentManifest + { + Id = ManifestId.Create("1.101525.generalsonline.gameclient.60hz"), + Version = newVersion, + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + }; + + var newPatchManifest = new ContentManifest + { + Id = ManifestId.Create("1.101525.generalsonline.patch.gamedata"), + Version = newVersion, + ContentType = ContentType.Patch, + Publisher = new PublisherInfo { PublisherType = GeneralsOnlineConstants.PublisherType }, + Metadata = new ContentMetadata { Tags = ["gamedata", "patch", "generalsonline"] }, + }; + + _manifestPoolMock.SetupSequence(x => x.GetAllManifestsAsync(It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([oldClientManifest, oldPatchManifest])) + .ReturnsAsync(OperationResult>.CreateSuccess([oldClientManifest, oldPatchManifest, newClientManifest, newPatchManifest])); + + _contentOrchestratorMock.Setup( + x => x.SearchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess( + [ + new() { Name = "New GO Version", Version = newVersion }, + ])); + + _contentOrchestratorMock.Setup(x => x.AcquireContentAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(newClientManifest)); + + IReadOnlyDictionary? capturedMapping = null; + _reconciliationServiceMock + .Setup(x => x.OrchestrateBulkUpdateAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Callback, bool, CancellationToken>((mapping, createNew, token) => capturedMapping = mapping) + .ReturnsAsync(OperationResult.CreateSuccess(new ReconciliationResult(1, 0))); + + // Act + var result = await _reconciler.CheckAndReconcileIfNeededAsync("profile1", CancellationToken.None); + + // Assert + Assert.True(result.Success, $"Reconciliation failed: {result.FirstError}"); + Assert.NotNull(capturedMapping); + Assert.True(capturedMapping.ContainsKey(oldClientManifest.Id.Value)); + Assert.Equal(newClientManifest.Id.Value, capturedMapping[oldClientManifest.Id.Value]); + Assert.True(capturedMapping.ContainsKey(oldPatchManifest.Id.Value)); + Assert.Equal(newPatchManifest.Id.Value, capturedMapping[oldPatchManifest.Id.Value]); + + _reconciliationServiceMock.Verify( + x => x.OrchestrateBulkRemovalAsync( + It.Is>(ids => ids.Contains(oldClientManifest.Id) && ids.Contains(oldPatchManifest.Id)), + It.IsAny()), + Times.Once); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateServiceTests.cs index 63f4b0034..ec0e4ad21 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateServiceTests.cs @@ -23,7 +23,7 @@ public class GeneralsOnlineUpdateServiceTests /// /// A representing the asynchronous test operation. [Fact] - public async Task CheckForUpdatesAsync_MultipleInstalledVersions_UsesNewestVersion() + public async Task CheckForUpdatesAsync_MultipleInstalledVersions_UsesNewestVersionAsync() { var manifestPool = new Mock(); manifestPool diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs index c056b5776..cb4f1bbff 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GitHub/GitHubContentDelivererTests.cs @@ -1,14 +1,21 @@ using FluentAssertions; +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Common; +using GenHub.Core.Models.Content; using GenHub.Core.Models.Enums; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; using GenHub.Features.Content.Services.GitHub; using GenHub.Features.Content.Services.Publishers; +using GenHub.Tests.Core.Infrastructure; using Microsoft.Extensions.Logging; using Moq; using Xunit; +using System.IO.Compression; using System.Reflection; +using System.Text; namespace GenHub.Tests.Features.Content.Services.GitHub; @@ -75,7 +82,7 @@ public void CanDeliver_ShouldReturnFalse_ForNonGitHubUrls() [InlineData(GenHub.Core.Models.Enums.ContentType.ModdingTool, true)] [InlineData(GenHub.Core.Models.Enums.ContentType.Executable, true)] [InlineData(GenHub.Core.Models.Enums.ContentType.MapPack, false)] - public Task DeliverContentAsync_ShouldExtractZip_ForMatchingContentTypes(GenHub.Core.Models.Enums.ContentType contentType, bool shouldExtract) + public Task DeliverContentAsync_ShouldExtractZip_ForMatchingContentTypesAsync(GenHub.Core.Models.Enums.ContentType contentType, bool shouldExtract) { // Dummy usage to satisfy xUnit analysis Assert.True(Enum.IsDefined(typeof(GenHub.Core.Models.Enums.ContentType), contentType)); @@ -83,4 +90,284 @@ public Task DeliverContentAsync_ShouldExtractZip_ForMatchingContentTypes(GenHub. return Task.CompletedTask; } + + /// + /// Surfaces a cancellation that lands part-way through extraction as a cancellation. The + /// downloaded archive is the only complete copy of the content, so it must survive, and the + /// truncated file set must never reach the manifest pool. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task DeliverContentAsync_CancelledDuringExtraction_KeepsArchiveAndRegistersNothingAsync() + { + var targetDirectory = Path.Combine(Path.GetTempPath(), "GenHubGitHubDeliverer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(targetDirectory); + + try + { + const int entryCount = 6; + _downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + CreateArchive(destination, entryCount); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var deliverer = new GitHubContentDeliverer( + _downloadService.Object, _manifestPool.Object, _factoryResolver.Object, _logger.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "release.zip", + DownloadUrl = "https://github.com/user/repo/release.zip", + }, + ], + }; + + using var cancellation = new CancellationTokenSource(); + var progress = new CancelOnFirstReport(cancellation); + + await Assert.ThrowsAnyAsync(() => + deliverer.DeliverContentAsync(manifest, targetDirectory, progress, cancellation.Token)); + + var archivePath = Path.Combine(targetDirectory, "release.zip"); + File.Exists(archivePath).Should().BeTrue("the archive is the only recoverable copy of the content"); + + var extracted = Directory.GetFiles(targetDirectory, "entry*.dat", SearchOption.AllDirectories); + extracted.Length.Should().BeLessThan(entryCount); + + _manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Fails delivery when an archive understates the size it decompresses to. The lie is only + /// visible while inflating, so the copy has to abort mid-stream, drop the partial file, and + /// leave the truncated file set out of the manifest pool. The failure is a result, not a + /// cancellation, so callers can tell a hostile archive from a user who changed their mind. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task DeliverContentAsync_ArchiveUnderstatingItsDeclaredSize_FailsWithoutRegisteringAManifestAsync() + { + var targetDirectory = Path.Combine(Path.GetTempPath(), "GenHubGitHubDeliverer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(targetDirectory); + + try + { + _downloadService + .Setup(d => d.DownloadFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .Returns((Uri _, string destination, string? _, IProgress? _, CancellationToken _) => + { + ArchiveFixtures.CreateWithSpoofedEntrySize(destination, "payload.dat", 12 * 1024 * 1024, 4096); + return Task.FromResult(DownloadResult.CreateSuccess(destination, 1, TimeSpan.FromSeconds(1))); + }); + + var deliverer = new GitHubContentDeliverer( + _downloadService.Object, _manifestPool.Object, _factoryResolver.Object, _logger.Object); + var manifest = new ContentManifest + { + Files = + [ + new ManifestFile + { + RelativePath = "release.zip", + DownloadUrl = "https://github.com/user/repo/release.zip", + }, + ], + }; + + var result = await deliverer.DeliverContentAsync(manifest, targetDirectory, cancellationToken: CancellationToken.None); + + result.Success.Should().BeFalse(); + result.FirstError.Should().Contain("potential zip bomb"); + + File.Exists(Path.Combine(targetDirectory, "payload.dat")).Should().BeFalse("the partial output is removed"); + + _manifestPool.Verify( + p => p.AddManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + finally + { + Directory.Delete(targetDirectory, recursive: true); + } + } + + /// + /// Refuses an entry whose key climbs out of the target directory rather than trusting the + /// archive library to block it. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchiveAsync_RejectsEntryEscapingTheTargetDirectoryAsync() + { + var root = CreateWorkingDirectory(); + + try + { + var targetDirectory = Path.Combine(root, "target"); + Directory.CreateDirectory(targetDirectory); + var archivePath = Path.Combine(root, "traversal.zip"); + CreateArchive(archivePath, "../escaped.dat"); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(CreateDeliverer(), archivePath, targetDirectory)); + + failure.Message.Should().Contain("outside target directory"); + File.Exists(Path.Combine(root, "escaped.dat")).Should().BeFalse(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Refuses an entry whose name cannot name a file before that name is turned into a path, + /// rather than letting the write fail several layers deeper with an unrelated error. + /// + /// The entry name the archive declares. + /// A representing the asynchronous unit test. + [Theory] + [InlineData(".")] + [InlineData("assets/..")] + [InlineData(" ")] + [InlineData("payload.dat:stream")] + public async Task ExtractArchiveAsync_RejectsEntryWithAnUnusableNameAsync(string entryName) + { + var root = CreateWorkingDirectory(); + + try + { + var targetDirectory = Path.Combine(root, "target"); + Directory.CreateDirectory(targetDirectory); + var archivePath = Path.Combine(root, "unusable.zip"); + CreateArchive(archivePath, entryName); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(CreateDeliverer(), archivePath, targetDirectory)); + + failure.Message.Should().Contain("cannot be extracted to a file"); + Directory.GetFileSystemEntries(root, "*.genhub-staging*").Should().BeEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Refuses an archive that declares more entries than the extraction budget allows, before any + /// of them is written. + /// + /// A representing the asynchronous unit test. + [Fact] + public async Task ExtractArchiveAsync_RejectsArchiveOverTheEntryBudgetAsync() + { + var root = CreateWorkingDirectory(); + + try + { + var targetDirectory = Path.Combine(root, "target"); + Directory.CreateDirectory(targetDirectory); + var archivePath = Path.Combine(root, "swarm.zip"); + CreateArchive(archivePath, GitHubConstants.MaxArchiveEntries + 1); + + var failure = await Assert.ThrowsAsync(() => + InvokeExtractArchiveAsync(CreateDeliverer(), archivePath, targetDirectory)); + + failure.Message.Should().Contain("too many entries"); + Directory.GetFileSystemEntries(targetDirectory).Should().BeEmpty(); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + private static string CreateWorkingDirectory() + { + var root = Path.Combine(Path.GetTempPath(), "GenHubGitHubDeliverer", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + return root; + } + + private static async Task InvokeExtractArchiveAsync( + GitHubContentDeliverer deliverer, + string archivePath, + string targetDirectory) + { + var extract = typeof(GitHubContentDeliverer).GetMethod( + "ExtractArchiveAsync", + BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("GitHubContentDeliverer.ExtractArchiveAsync was not found."); + + await (Task)extract.Invoke(deliverer, [archivePath, targetDirectory, null, CancellationToken.None])!; + } + + private static void CreateArchive(string archivePath, params string[] entryNames) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + foreach (var entryName in entryNames) + { + var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes("payload")); + } + } + + private static void CreateArchive(string archivePath, int entryCount) + { + using var archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + for (var index = 0; index < entryCount; index++) + { + var entry = archive.CreateEntry($"entry{index}.dat", CompressionLevel.Optimal); + using var stream = entry.Open(); + stream.Write(Encoding.UTF8.GetBytes($"payload {index}")); + } + } + + private GitHubContentDeliverer CreateDeliverer() => + new(_downloadService.Object, _manifestPool.Object, _factoryResolver.Object, _logger.Object); + + private sealed class CancelOnFirstReport(CancellationTokenSource cancellation) : IProgress + { + public void Report(ContentAcquisitionProgress value) + { + if (value.Phase == ContentAcquisitionPhase.Extracting) + { + cancellation.Cancel(); + } + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs new file mode 100644 index 000000000..d8f2d1692 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs @@ -0,0 +1,286 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Services.Content; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services; + +/// +/// Contains tests for . +/// +public class LocalContentServiceTests : IDisposable +{ + private readonly Mock _manifestGenServiceMock; + private readonly Mock _contentStorageServiceMock; + private readonly Mock _reconciliationServiceMock; + private readonly LocalContentService _service; + private readonly string _tempDir; + + /// + /// Initializes a new instance of the class. + /// + public LocalContentServiceTests() + { + _manifestGenServiceMock = new Mock(); + _contentStorageServiceMock = new Mock(); + _reconciliationServiceMock = new Mock(); + + _service = new LocalContentService( + _manifestGenServiceMock.Object, + _contentStorageServiceMock.Object, + _reconciliationServiceMock.Object, + NullLogger.Instance); + + _tempDir = Path.Combine(Path.GetTempPath(), "LocalContentServiceTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + /// + /// Cleans up temporary resources. + /// + public void Dispose() + { + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Ignore cleanup failures + } + + GC.SuppressFinalize(this); + } + + /// + /// Verifies that CreateLocalContentManifestAsync sets EntryPoint when provided. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithEntryPoint_SetsManifestEntryPoint() + { + SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "FinalBIG", + contentType: ContentType.ModdingTool, + targetGame: GameType.ZeroHour, + entryPoint: "FinalBIG.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("FinalBIG.exe", result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync normalizes backslashes to forward slashes in EntryPoint. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_NormalizesBackslashesInEntryPoint() + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "bin/sub/tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: "bin\\sub\\tool.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("bin/sub/tool.exe", result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when passed a whitespace-only value. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithWhitespaceOnlyEntryPoint_LeavesEntryPointNull() + { + SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "FinalBIG", + contentType: ContentType.ModdingTool, + targetGame: GameType.ZeroHour, + entryPoint: " "); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Null(result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync rejects rooted or parent-traversal entry points. + /// + /// The invalid entry point path to test. + /// A task representing the asynchronous test. + [Theory] + [InlineData("/usr/bin/tool.exe")] + [InlineData("../tool.exe")] + [InlineData("bin/../../tool.exe")] + public async Task CreateLocalContentManifestAsync_WithInvalidEntryPointPath_ReturnsFailure(string invalidEntryPoint) + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: invalidEntryPoint); + + Assert.False(result.Success); + Assert.Contains("invalid", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that CreateLocalContentManifestAsync accepts entry points with double dots in file or folder names. + /// + /// The valid entry point path with dots in name. + /// A task representing the asynchronous test. + [Theory] + [InlineData("game..exe")] + [InlineData("backup..old/tool.exe")] + public async Task CreateLocalContentManifestAsync_WithDoubleDotsInName_ReturnsSuccess(string validEntryPoint) + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", validEntryPoint); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: validEntryPoint); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(validEntryPoint, result.Data!.EntryPoint); + } + + /// + /// Verifies that CreateLocalContentManifestAsync rejects an entry point that does not exist in manifest files. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithNonExistentEntryPoint_ReturnsFailure() + { + SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "Tool", + contentType: ContentType.Executable, + targetGame: GameType.ZeroHour, + entryPoint: "missing.exe"); + + Assert.False(result.Success); + Assert.Contains("not found", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when not provided. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task CreateLocalContentManifestAsync_WithoutEntryPoint_LeavesEntryPointNull() + { + SetupManifestBuilder(ContentType.Mod, GameType.ZeroHour, "MyMod"); + + var result = await _service.CreateLocalContentManifestAsync( + directoryPath: _tempDir, + name: "MyMod", + contentType: ContentType.Mod, + targetGame: GameType.ZeroHour); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Null(result.Data!.EntryPoint); + } + + /// + /// Verifies that UpdateLocalContentManifestAsync passes entryPoint through to the created manifest. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateLocalContentManifestAsync_WithEntryPoint_SetsEntryPointOnUpdatedManifest() + { + SetupManifestBuilder(ContentType.GameClient, GameType.ZeroHour, "GeneralsClient", "generals.exe"); + + _reconciliationServiceMock + .Setup(x => x.OrchestrateLocalUpdateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new ContentUpdateResult())); + + var result = await _service.UpdateLocalContentManifestAsync( + existingManifestId: "1.0.local.gameclient.old", + name: "GeneralsClient", + directoryPath: _tempDir, + contentType: ContentType.GameClient, + targetGame: GameType.ZeroHour, + entryPoint: "generals.exe"); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal("generals.exe", result.Data!.EntryPoint); + } + + private void SetupManifestBuilder(ContentType contentType, GameType targetGame, string contentName, params string[] filePaths) + { + var files = filePaths.Length > 0 + ? filePaths.Select(f => new ManifestFile { RelativePath = f, IsExecutable = f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) }).ToList() + : new List(); + + var manifest = new ContentManifest + { + Id = ManifestId.Create($"1.0.local.{contentType.ToString().ToLowerInvariant()}.{contentName.ToLowerInvariant()}"), + Name = contentName, + ContentType = contentType, + TargetGame = targetGame, + Files = files, + }; + + var builderMock = new Mock(); + builderMock.Setup(b => b.Build()).Returns(manifest); + + _manifestGenServiceMock + .Setup(x => x.CreateContentManifestAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(builderMock.Object); + + _contentStorageServiceMock + .Setup(x => x.StoreContentAsync( + It.IsAny(), + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(manifest)); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs new file mode 100644 index 000000000..522569cd5 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Common; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Features.Content.Services.Publishers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.Publishers; + +/// +/// Unit tests for . +/// +public class PublisherManifestFactoryResolverTests +{ + private readonly Mock _hashProviderMock; + + /// + /// Initializes a new instance of the class. + /// + public PublisherManifestFactoryResolverTests() + { + _hashProviderMock = new Mock(); + } + + /// + /// Verifies that ResolveFactory returns the specialized factory when CanHandle matches. + /// + [Fact] + public void ResolveFactory_ReturnsSpecializedFactory_WhenCanHandleMatches() + { + // Arrange + var superHackersFactory = new SuperHackersManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var gitHubFactory = new GitHubManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [superHackersFactory, gitHubFactory], + NullLogger.Instance); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.thesuperhackers.gameclient.generals"), + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo + { + Name = "TheSuperHackers", + PublisherType = PublisherTypeConstants.TheSuperHackers, + }, + }; + + // Act + var result = resolver.ResolveFactory(manifest); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + /// + /// Verifies that ResolveFactory falls back to GitHubManifestFactory for non-GameClient publisher content. + /// + [Fact] + public void ResolveFactory_FallsBackToGitHubFactory_WhenSpecializedFactoryCannotHandle() + { + // Arrange + var superHackersFactory = new SuperHackersManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var gitHubFactory = new GitHubManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [superHackersFactory, gitHubFactory], + NullLogger.Instance); + + var patchManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.thesuperhackers.patch.generalsgamepatch2"), + ContentType = ContentType.Patch, + Publisher = new PublisherInfo + { + Name = "TheSuperHackers", + PublisherType = PublisherTypeConstants.TheSuperHackers, + }, + }; + + // Act + var result = resolver.ResolveFactory(patchManifest); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + /// + /// Verifies that ResolveFactory returns null when no specialized or fallback factory is available. + /// + [Fact] + public void ResolveFactory_ReturnsNull_WhenNoFactoryMatchesAndNoFallbackAvailable() + { + // Arrange + var superHackersFactory = new SuperHackersManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [superHackersFactory], + NullLogger.Instance); + + var patchManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.testpublisher.mod.sample"), + ContentType = ContentType.Mod, + Publisher = new PublisherInfo + { + Name = "Unknown", + PublisherType = "unknown", + }, + }; + + // Act + var result = resolver.ResolveFactory(patchManifest); + + // Assert + Assert.Null(result); + } + + /// + /// Verifies that ResolveFactory returns null when a GameClient manifest has no specialized factory, + /// rather than falling back to GitHubManifestFactory. + /// + [Fact] + public void ResolveFactory_ReturnsNull_WhenGameClientHasNoSpecializedFactory() + { + // Arrange + var gitHubFactory = new GitHubManifestFactory( + NullLogger.Instance, + _hashProviderMock.Object); + + var resolver = new PublisherManifestFactoryResolver( + [gitHubFactory], + NullLogger.Instance); + + var gameClientManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.unknownpublisher.gameclient.generals"), + ContentType = ContentType.GameClient, + Publisher = new PublisherInfo + { + Name = "UnknownPublisher", + PublisherType = "unknownpublisher", + }, + }; + + // Act + var result = resolver.ResolveFactory(gameClientManifest); + + // Assert + Assert.Null(result); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs new file mode 100644 index 000000000..ace7c1ac6 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Interfaces.Content; +using GenHub.Core.Interfaces.GitHub; +using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.Content; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.GitHub; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.Publishers; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Content.Services.Publishers; + +/// +/// Unit tests for . +/// +public class SuperHackersProviderTests +{ + private readonly Mock _providerDefinitionLoaderMock; + private readonly Mock _gitHubApiClientMock; + private readonly Mock _resolverMock; + private readonly Mock _delivererMock; + private readonly Mock _validatorMock; + private readonly Mock _instructionsServiceMock; + private readonly SuperHackersProvider _provider; + + /// + /// Initializes a new instance of the class. + /// + public SuperHackersProviderTests() + { + _providerDefinitionLoaderMock = new Mock(); + _gitHubApiClientMock = new Mock(); + _resolverMock = new Mock(); + _delivererMock = new Mock(); + _validatorMock = new Mock(); + _instructionsServiceMock = new Mock(); + + _resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId); + _delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer); + + _validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ValidationResult("test", [])); + + _instructionsServiceMock.Setup(s => s.ExecutePostInstallStepsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + _provider = new SuperHackersProvider( + _providerDefinitionLoaderMock.Object, + _gitHubApiClientMock.Object, + [_resolverMock.Object], + [_delivererMock.Object], + _validatorMock.Object, + NullLogger.Instance, + _instructionsServiceMock.Object); + } + + /// + /// Verifies that SearchAsync returns both GeneralsGameCode and GeneralsGamePatch2 releases when available. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_DiscoversBothGameCodeAndGamePatch2_WhenBothAvailableAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease + { + TagName = "weekly-2026-08-01", + Name = "Weekly Release 2026-08-01", + Body = "Generals and Zero Hour game code updates", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGameCode/releases/tag/weekly-2026-08-01", + CreatedAt = DateTimeOffset.UtcNow, + }; + + var gamePatch2Release = new GitHubRelease + { + TagName = "1.0.0", + Name = "Release 1.0.0", + Body = "Community Patch 2 to fix and improve Generals and Zero Hour", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Equal(2, items.Count); + + var gameCodeItem = items.FirstOrDefault(i => i.ContentType == ContentType.GameClient); + Assert.NotNull(gameCodeItem); + Assert.Equal("weekly-2026-08-01", gameCodeItem.Version); + Assert.Equal(SuperHackersConstants.GeneralsGameCodeRepo, gameCodeItem.ResolverMetadata[GitHubConstants.RepoMetadataKey]); + + var gamePatch2Item = items.FirstOrDefault(i => i.ContentType == ContentType.Patch); + Assert.NotNull(gamePatch2Item); + Assert.Equal("1.0.0", gamePatch2Item.Version); + Assert.Equal(SuperHackersConstants.GeneralsGamePatch2Repo, gamePatch2Item.ResolverMetadata[GitHubConstants.RepoMetadataKey]); + } + + /// + /// Verifies that SearchAsync filters properly by repository search term. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersBySearchTerm_CorrectlyAsync() + { + // Arrange + var gamePatch2Release = new GitHubRelease + { + TagName = "1.0.0", + Name = "Release 1.0.0", + Body = "Community Patch 2", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery { SearchTerm = "GeneralsGamePatch2" }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + } + + /// + /// Verifies that SearchAsync filters by ContentType correctly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersByContentType_ReturnsOnlyMatchingReleasesAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }; + var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery { ContentType = ContentType.Patch }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + Assert.Equal("1.0.0", items[0].Version); + } + + /// + /// Verifies that SearchAsync filters by TargetGame correctly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersByTargetGame_ReturnsMatchingReleasesAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }; + var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var zeroHourQuery = new ContentSearchQuery { TargetGame = GameType.ZeroHour }; + + // Act + var result = await _provider.SearchAsync(zeroHourQuery); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + Assert.Equal(GameType.ZeroHour, items[0].TargetGame); + } + + /// + /// Verifies that SearchAsync filters by author name and github author correctly. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_FiltersByAuthor_ReturnsEmptyWhenAuthorDoesNotMatchAsync() + { + // Arrange + var query = new ContentSearchQuery { AuthorName = "NonExistentAuthor" }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Empty(items); + } + + /// + /// Verifies that SearchAsync matches on display name and body text. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_MatchesSearchTerm_OnDisplayNameAndBodyAsync() + { + // Arrange + var gamePatch2Release = new GitHubRelease + { + TagName = "1.0.0", + Name = "Patch Release", + Body = "Community patch details", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1", Body = "Engine updates" }); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(gamePatch2Release); + + var query = new ContentSearchQuery { SearchTerm = SuperHackersConstants.GeneralsGamePatch2DisplayName }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.Patch, items[0].ContentType); + } + + /// + /// Verifies that SearchAsync returns failure when one target returns null release and the other throws an error. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_WhenOneTargetReturnsNullAndOtherErrors_ReturnsFailureAsync() + { + // Arrange + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync((GitHubRelease)null!); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API rate limit")); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.False(result.Success); + Assert.Contains("Search failed for SuperHackers targets", result.FirstError); + } + + /// + /// Verifies that SearchAsync returns successful results when one repository fails. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_ReturnsRemainingReleases_WhenOneRepositoryFailsAsync() + { + // Arrange + var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync(gameCodeRelease); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("API error")); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal(ContentType.GameClient, items[0].ContentType); + } + + /// + /// Verifies that SearchAsync returns failure when all matching repositories fail. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_ReturnsFailure_WhenAllRepositoriesFailAsync() + { + // Arrange + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Network failure 1")); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Network failure 2")); + + var query = new ContentSearchQuery(); + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.False(result.Success); + Assert.Contains("Search failed for SuperHackers targets", result.FirstError); + } + + /// + /// Verifies that SearchAsync propagates cancellation. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_PropagatesCancellation_WhenCancellationRequestedAsync() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAnyAsync( + () => _provider.SearchAsync(new ContentSearchQuery(), cts.Token)); + } + + /// + /// Verifies that SearchAsync falls back to display name and tag name when release name is blank. + /// + /// The candidate release name to test. + /// A representing the asynchronous operation. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task SearchAsync_UsesFallbackName_WhenReleaseNameIsBlankAsync(string? releaseName) + { + // Arrange + var release = new GitHubRelease + { + TagName = "alpha-4", + Name = releaseName ?? string.Empty, + Body = "Patch notes", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(release); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync((GitHubRelease)null!); + + var query = new ContentSearchQuery { ContentType = ContentType.Patch }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal($"{SuperHackersConstants.GeneralsGamePatch2DisplayName} alpha-4", items[0].Name); + } + + /// + /// Verifies that SearchAsync preserves the original release name when it is not blank. + /// + /// A representing the asynchronous operation. + [Fact] + public async Task SearchAsync_PreservesReleaseName_WhenReleaseNameIsNonBlankAsync() + { + // Arrange + var release = new GitHubRelease + { + TagName = "alpha-4", + Name = "Community Patch 2.0 Alpha 4", + Body = "Patch notes", + HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4", + CreatedAt = DateTimeOffset.UtcNow, + }; + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGamePatch2Owner, + SuperHackersConstants.GeneralsGamePatch2Repo, + It.IsAny())) + .ReturnsAsync(release); + + _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync( + SuperHackersConstants.GeneralsGameCodeOwner, + SuperHackersConstants.GeneralsGameCodeRepo, + It.IsAny())) + .ReturnsAsync((GitHubRelease)null!); + + var query = new ContentSearchQuery { ContentType = ContentType.Patch }; + + // Act + var result = await _provider.SearchAsync(query); + + // Assert + Assert.True(result.Success); + var items = result.Data?.ToList(); + Assert.NotNull(items); + Assert.Single(items); + Assert.Equal("Community Patch 2.0 Alpha 4", items[0].Name); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/SuperHackers/SuperHackersProfileReconcilerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/SuperHackers/SuperHackersProfileReconcilerTests.cs index 8eac51ea1..9471f7a44 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/SuperHackers/SuperHackersProfileReconcilerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/SuperHackers/SuperHackersProfileReconcilerTests.cs @@ -76,7 +76,7 @@ public SuperHackersProfileReconcilerTests() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_NoUpdateAvailable_ReturnsFalse() + public async Task CheckAndReconcileIfNeededAsync_NoUpdateAvailable_ReturnsFalseAsync() { _updateServiceMock .Setup(x => x.CheckForUpdatesAsync(It.IsAny())) @@ -93,7 +93,7 @@ public async Task CheckAndReconcileIfNeededAsync_NoUpdateAvailable_ReturnsFalse( /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_UpdateCheckFails_ReturnsFailure() + public async Task CheckAndReconcileIfNeededAsync_UpdateCheckFails_ReturnsFailureAsync() { _updateServiceMock .Setup(x => x.CheckForUpdatesAsync(It.IsAny())) @@ -109,7 +109,7 @@ public async Task CheckAndReconcileIfNeededAsync_UpdateCheckFails_ReturnsFailure /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_VersionSkipped_ReturnsFalse() + public async Task CheckAndReconcileIfNeededAsync_VersionSkipped_ReturnsFalseAsync() { const string latestVersion = "2.0.0"; @@ -136,7 +136,7 @@ public async Task CheckAndReconcileIfNeededAsync_VersionSkipped_ReturnsFalse() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_UserSkipsDialog_ReturnsFalse() + public async Task CheckAndReconcileIfNeededAsync_UserSkipsDialog_ReturnsFalseAsync() { _updateServiceMock .Setup(x => x.CheckForUpdatesAsync(It.IsAny())) @@ -167,7 +167,7 @@ public async Task CheckAndReconcileIfNeededAsync_UserSkipsDialog_ReturnsFalse() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireFails_ReturnsFailure() + public async Task CheckAndReconcileIfNeededAsync_AcquireFails_ReturnsFailureAsync() { const string latestVersion = "2.0.0"; @@ -205,7 +205,7 @@ public async Task CheckAndReconcileIfNeededAsync_AcquireFails_ReturnsFailure() /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellation() + public async Task CheckAndReconcileIfNeededAsync_AcquireCancelled_PropagatesCancellationAsync() { const string latestVersion = "2.0.0"; @@ -249,7 +249,7 @@ await Assert.ThrowsAsync( /// /// A representing the asynchronous unit test. [Fact] - public async Task CheckAndReconcileIfNeededAsync_AcquireFailsWhileCancelled_PropagatesCancellation() + public async Task CheckAndReconcileIfNeededAsync_AcquireFailsWhileCancelled_PropagatesCancellationAsync() { const string latestVersion = "2.0.0"; diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/PublisherCardViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/PublisherCardViewModelTests.cs index 4516807ff..707a5f78a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/PublisherCardViewModelTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Downloads/ViewModels/PublisherCardViewModelTests.cs @@ -50,7 +50,7 @@ public PublisherCardViewModelTests() /// /// A representing the asynchronous operation. [Fact] - public async Task RefreshInstallationStatus_DifferentAddonsSameVersion_DoNotCollide() + public async Task RefreshInstallationStatus_DifferentAddonsSameVersion_DoNotCollideAsync() { // Arrange var vm = CreateSystem(); @@ -65,7 +65,7 @@ public async Task RefreshInstallationStatus_DifferentAddonsSameVersion_DoNotColl ContentType = GenHub.Core.Models.Enums.ContentType.Addon, ProviderName = "testprovider", AuthorName = "Test Author", - LastUpdated = DateTime.Now, + LastUpdated = DateTime.UtcNow, }); // Item 2: HUD Mod v1.0 @@ -77,7 +77,7 @@ public async Task RefreshInstallationStatus_DifferentAddonsSameVersion_DoNotColl ContentType = GenHub.Core.Models.Enums.ContentType.Addon, ProviderName = "testprovider", AuthorName = "Test Author", - LastUpdated = DateTime.Now, + LastUpdated = DateTime.UtcNow, }); vm.ContentTypes.Add(new ContentTypeGroup @@ -119,7 +119,7 @@ public async Task RefreshInstallationStatus_DifferentAddonsSameVersion_DoNotColl /// /// A representing the asynchronous operation. [Fact] - public async Task RefreshInstallationStatus_GameClient_AllowsVersionMatch() + public async Task RefreshInstallationStatus_GameClient_AllowsVersionMatchAsync() { // Arrange var vm = CreateSystem(); @@ -134,7 +134,7 @@ public async Task RefreshInstallationStatus_GameClient_AllowsVersionMatch() ContentType = GenHub.Core.Models.Enums.ContentType.GameClient, ProviderName = "testprovider", AuthorName = "Test Author", - LastUpdated = DateTime.Now, + LastUpdated = DateTime.UtcNow, }); vm.ContentTypes.Add(new ContentTypeGroup @@ -171,7 +171,7 @@ public async Task RefreshInstallationStatus_GameClient_AllowsVersionMatch() /// /// A representing the asynchronous operation. [Fact] - public async Task RefreshInstallationStatus_GeneralsOnlineAcrossYearBoundary_ShowsUpdate() + public async Task RefreshInstallationStatus_GeneralsOnlineAcrossYearBoundary_ShowsUpdateAsync() { var vm = CreateSystem(); vm.PublisherId = PublisherTypeConstants.GeneralsOnline; @@ -184,7 +184,7 @@ public async Task RefreshInstallationStatus_GeneralsOnlineAcrossYearBoundary_Sho ContentType = GenHub.Core.Models.Enums.ContentType.GameClient, ProviderName = PublisherTypeConstants.GeneralsOnline, AuthorName = "Generals Online Team", - LastUpdated = DateTime.Now, + LastUpdated = DateTime.UtcNow, }); vm.ContentTypes.Add(new ContentTypeGroup diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectionOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectionOrchestratorTests.cs index 605f67c35..c458e4f4a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectionOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectionOrchestratorTests.cs @@ -40,7 +40,7 @@ public GameClientDetectionOrchestratorTests() /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllClientsAsync_OrchestratesDetection_Successfully() + public async Task DetectAllClientsAsync_OrchestratesDetection_SuccessfullyAsync() { // Arrange var installation = new GameInstallation("C:\\Test", GameInstallationType.Retail); @@ -77,7 +77,7 @@ public async Task DetectAllClientsAsync_OrchestratesDetection_Successfully() /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllClientsAsync_ReturnsFailure_WhenInstallationDetectionFails() + public async Task DetectAllClientsAsync_ReturnsFailure_WhenInstallationDetectionFailsAsync() { // Arrange _installationOrchestratorMock.Setup(i => i.DetectAllInstallationsAsync(It.IsAny())) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs index 90f9aa908..325977ec9 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientDetectorTests.cs @@ -8,6 +8,7 @@ using GenHub.Core.Models.GameInstallations; using GenHub.Core.Models.Manifest; using GenHub.Core.Models.Results; +using GenHub.Features.Content.Services.GeneralsOnline; using GenHub.Features.GameClients; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -19,7 +20,13 @@ namespace GenHub.Tests.Core.Features.GameClients; /// public class GameClientDetectorTests : IDisposable { - private static readonly IReadOnlyList PossibleExecutableNames = [GameClientConstants.GeneralsExecutable, GameClientConstants.GeneralsOnline60HzExecutable]; + private static readonly IReadOnlyList PossibleExecutableNames = + [ + GameClientConstants.GeneralsExecutable, + GameClientConstants.GeneralsOnlineEacLauncherExecutable, + GameClientConstants.GeneralsOnline60HzExecutable, + ]; + private readonly Mock _manifestGenerationServiceMock; private readonly Mock _contentManifestPoolMock; private readonly Mock _hashProviderMock; @@ -65,7 +72,7 @@ public GameClientDetectorTests() /// /// A representing the asynchronous test operation. [Fact] - public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsInstallation_DetectsGeneralsClient() + public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsInstallation_DetectsGeneralsClientAsync() { // Arrange var generalsPath = Path.Combine(_tempDirectory, "Generals"); @@ -115,7 +122,7 @@ public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsInstallati /// /// A representing the asynchronous test operation. [Fact] - public async Task DetectGameClientsFromInstallationsAsync_WithZeroHourInstallation_DetectsZeroHourClient() + public async Task DetectGameClientsFromInstallationsAsync_WithZeroHourInstallation_DetectsZeroHourClientAsync() { // Arrange var zeroHourPath = Path.Combine(_tempDirectory, "ZeroHour"); @@ -165,7 +172,7 @@ public async Task DetectGameClientsFromInstallationsAsync_WithZeroHourInstallati /// /// A representing the asynchronous test operation. [Fact] - public async Task ScanDirectoryForGameClientsAsync_WithValidExecutable_FindsGameClient() + public async Task ScanDirectoryForGameClientsAsync_WithValidExecutable_FindsGameClientAsync() { // Arrange var gameDir = Path.Combine(_tempDirectory, "TestGame"); @@ -207,7 +214,7 @@ public async Task ScanDirectoryForGameClientsAsync_WithValidExecutable_FindsGame /// /// A representing the asynchronous test operation. [Fact] - public async Task ScanDirectoryForGameClientsAsync_WithNonExistentDirectory_ReturnsFailure() + public async Task ScanDirectoryForGameClientsAsync_WithNonExistentDirectory_ReturnsFailureAsync() { // Arrange var nonExistentPath = Path.Combine(_tempDirectory, "NonExistent"); @@ -225,7 +232,7 @@ public async Task ScanDirectoryForGameClientsAsync_WithNonExistentDirectory_Retu /// /// A representing the asynchronous test operation. [Fact] - public async Task ValidateGameClientAsync_WithValidClient_ReturnsTrue() + public async Task ValidateGameClientAsync_WithValidClient_ReturnsTrueAsync() { // Arrange var executablePath = Path.Combine(_tempDirectory, "generals.exe"); @@ -248,7 +255,7 @@ public async Task ValidateGameClientAsync_WithValidClient_ReturnsTrue() /// /// A representing the asynchronous test operation. [Fact] - public async Task ValidateGameClientAsync_WithInvalidClient_ReturnsFalse() + public async Task ValidateGameClientAsync_WithInvalidClient_ReturnsFalseAsync() { // Arrange var client = new GameClient @@ -268,7 +275,7 @@ public async Task ValidateGameClientAsync_WithInvalidClient_ReturnsFalse() /// /// A representing the asynchronous test operation. [Fact] - public async Task ScanDirectoryForGameClientsAsync_WithUnknownHash_CreatesUnknownClient() + public async Task ScanDirectoryForGameClientsAsync_WithUnknownHash_CreatesUnknownClientAsync() { // Arrange var gameDir = Path.Combine(_tempDirectory, "UnknownGame"); @@ -305,12 +312,184 @@ public async Task ScanDirectoryForGameClientsAsync_WithUnknownHash_CreatesUnknow Assert.Contains("Unknown Game", client.Name); } + /// + /// A GeneralsOnline directory holds both the Easy Anti-Cheat bootstrapper and the binary it + /// wraps. A scan must report the installation once, through the bootstrapper. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WithEacLauncherBesideSixtyHertz_FindsOnlyWrapperAsync() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnline"); + Directory.CreateDirectory(gameDir); + var wrapperPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + var sixtyHertzPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnline60HzExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + await File.WriteAllTextAsync(sixtyHertzPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.genhub.gameclient.unknownclient") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var result = await _detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(wrapperPath, only.ExecutablePath); + } + + /// + /// The bootstrapper is absent from the retail hash registry by definition, so an unrecognized + /// hash must fall through to the publisher identifier rather than to the generic entry. A + /// GeneralsOnline client reported as GameType.Generals never matches the Zero Hour launch + /// path, which is what writes settings.json. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WithEacLauncherAndUnknownHash_ClassifiesAsZeroHourGeneralsOnlineAsync() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnline"); + Directory.CreateDirectory(gameDir); + var wrapperPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.generalsonline.gameclient.generals-generalsonline-60hz") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // The production identifier, so this pins real classification rather than a mock's answer. + var detector = new GameClientDetector( + _manifestGenerationServiceMock.Object, + _contentManifestPoolMock.Object, + _hashProviderMock.Object, + _hashRegistryMock.Object, + [new GeneralsOnlineClientIdentifier()], + NullLogger.Instance); + + var result = await detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(wrapperPath, only.ExecutablePath); + Assert.Equal(GameType.ZeroHour, only.GameType); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, only.Name); + + // IsPublisherClient turns on PublisherType alone. Without it the client reads as a base + // retail install, so version resolution treats it as the base game and the launcher UI + // never sees a publisher client. + Assert.Equal(PublisherTypeConstants.GeneralsOnline, only.PublisherType); + Assert.True(only.IsPublisherClient); + } + + /// + /// One misbehaving identifier must not take the rest down with it. The caller's handler + /// swallows anything thrown here and returns null, so an escaping exception would drop the + /// executable entirely rather than falling through to the identifiers after it. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WhenAnIdentifierThrows_StillTriesTheRestAsync() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnline"); + Directory.CreateDirectory(gameDir); + var wrapperPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.generalsonline.gameclient.generals-generalsonline-60hz") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Throws from CanIdentify, which is the probe that runs before Identify. + var throwingIdentifier = new Mock(); + throwingIdentifier.Setup(x => x.PublisherId).Returns("throwing"); + throwingIdentifier.Setup(x => x.CanIdentify(It.IsAny())).Throws(new InvalidOperationException("boom")); + + var detector = new GameClientDetector( + _manifestGenerationServiceMock.Object, + _contentManifestPoolMock.Object, + _hashProviderMock.Object, + _hashRegistryMock.Object, + [throwingIdentifier.Object, new GeneralsOnlineClientIdentifier()], + NullLogger.Instance); + + var result = await detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(GameType.ZeroHour, only.GameType); + Assert.Equal(GameClientConstants.GeneralsOnline60HzDisplayName, only.Name); + } + + /// + /// Portables predating 060526_QFE1 ship no bootstrapper, so the wrapped binary stays the + /// entry point rather than being filtered out with it. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ScanDirectoryForGameClientsAsync_WithoutEacLauncher_FindsSixtyHertzClientAsync() + { + var gameDir = Path.Combine(_tempDirectory, "GeneralsOnlinePreEac"); + Directory.CreateDirectory(gameDir); + var sixtyHertzPath = Path.Combine(gameDir, GameClientConstants.GeneralsOnline60HzExecutable); + await File.WriteAllTextAsync(sixtyHertzPath, "dummy content"); + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("unknown_hash_12345"); + + var manifestBuilderMock = new Mock(); + manifestBuilderMock.Setup(x => x.Build()) + .Returns(new ContentManifest { Id = ManifestId.Create("1.0.genhub.gameclient.unknownclient") }); + + _manifestGenerationServiceMock.Setup(x => x.CreateGameClientManifestAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(manifestBuilderMock.Object); + + _contentManifestPoolMock.Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var result = await _detector.ScanDirectoryForGameClientsAsync(_tempDirectory); + + Assert.True(result.Success); + var only = Assert.Single(result.Items); + Assert.Equal(sixtyHertzPath, only.ExecutablePath); + } + /// /// Tests that DetectGameClientsFromInstallationsAsync detects GeneralsOnline 60Hz client. /// /// A representing the asynchronous test operation. [Fact] - public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60HzExecutable_DetectsClient() + public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60HzExecutable_DetectsClientAsync() { // Arrange - Create identifier for GeneralsOnline 60Hz var generalsOnlineIdentifierMock = new Mock(); @@ -403,12 +582,65 @@ public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60Hz Assert.Contains("60Hz", generalsOnlineClient.Name); } + /// + /// Since 060526_QFE1 the Easy Anti-Cheat bootstrapper ships beside the binary it wraps. + /// Detection must yield a single client pointing at the bootstrapper, not one client per + /// recognised executable name. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task DetectGameClientsFromInstallationsAsync_WithEacLauncherBesideSixtyHertz_DetectsOnlyWrapperAsync() + { + var identifierMock = new Mock(); + identifierMock.Setup(x => x.PublisherId).Returns(PublisherTypeConstants.GeneralsOnline); + identifierMock.Setup(x => x.CanIdentify(It.IsAny())).Returns(false); + + var detector = new GameClientDetector( + _manifestGenerationServiceMock.Object, + _contentManifestPoolMock.Object, + _hashProviderMock.Object, + _hashRegistryMock.Object, + [identifierMock.Object], + NullLogger.Instance); + + var zeroHourPath = Path.Combine(_tempDirectory, "ZeroHourEac"); + Directory.CreateDirectory(zeroHourPath); + + var wrapperPath = Path.Combine(zeroHourPath, GameClientConstants.GeneralsOnlineEacLauncherExecutable); + var sixtyHertzPath = Path.Combine(zeroHourPath, GameClientConstants.GeneralsOnline60HzExecutable); + await File.WriteAllTextAsync(wrapperPath, "dummy content"); + await File.WriteAllTextAsync(sixtyHertzPath, "dummy content"); + + var installation = new GameInstallation("C:\\TestInstallEac", GameInstallationType.Steam) + { + HasZeroHour = true, + ZeroHourPath = zeroHourPath, + }; + + _hashProviderMock.Setup(x => x.ComputeFileHashAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("any_hash"); + + _contentManifestPoolMock + .Setup(x => x.AddManifestAsync(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var result = await detector.DetectGameClientsFromInstallationsAsync([installation]); + + Assert.True(result.Success); + var generalsOnlineClients = result.Items + .Where(client => client.Name.Contains("GeneralsOnline", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var only = Assert.Single(generalsOnlineClients); + Assert.Equal(wrapperPath, only.ExecutablePath); + } + /// /// Tests that DetectGameClientsFromInstallationsAsync detects GeneralsOnline 60Hz client for Zero Hour. /// /// A representing the asynchronous test operation. [Fact] - public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60HzExecutable_DetectsZeroHourClient() + public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60HzExecutable_DetectsZeroHourClientAsync() { // Arrange - Create identifier for GeneralsOnline 60Hz var generalsOnlineIdentifierMock = new Mock(); @@ -492,7 +724,7 @@ public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60Hz /// /// A representing the asynchronous test operation. [Fact] - public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60HzVariant_DetectsClientWithStandard() + public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60HzVariant_DetectsClientWithStandardAsync() { // Arrange - Create identifier for 60Hz var identifier60HzMock = new Mock(); @@ -567,7 +799,7 @@ public async Task DetectGameClientsFromInstallationsAsync_WithGeneralsOnline60Hz /// /// A representing the asynchronous test operation. [Fact] - public async Task DetectGameClientsFromInstallationsAsync_WithMissingGeneralsOnlineExecutable_SkipsAndContinues() + public async Task DetectGameClientsFromInstallationsAsync_WithMissingGeneralsOnlineExecutable_SkipsAndContinuesAsync() { // Arrange - create installation with only standard executable, no GeneralsOnline var generalsPath = Path.Combine(_tempDirectory, "GeneralsNoGeneralsOnline"); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs index 0fbb3446d..e06f8a87a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameClients/GameClientManifestIntegrationTests.cs @@ -66,7 +66,7 @@ public GameClientManifestIntegrationTests() /// /// A representing the asynchronous test operation. [Fact] - public async Task GenerateGameClientManifest_WithSteamGeneralsInstallation_CreatesManifestWithExecutable() + public async Task GenerateGameClientManifest_WithSteamGeneralsInstallation_CreatesManifestWithExecutableAsync() { var generalsPath = Path.Combine(_tempDirectory, "Steam", "Generals"); Directory.CreateDirectory(generalsPath); @@ -99,7 +99,7 @@ public async Task GenerateGameClientManifest_WithSteamGeneralsInstallation_Creat /// /// A representing the asynchronous test operation. [Fact] - public async Task GenerateGameClientManifest_ExecutableHashIsComputed() + public async Task GenerateGameClientManifest_ExecutableHashIsComputedAsync() { var clientPath = Path.Combine(_tempDirectory, "TestClient"); Directory.CreateDirectory(clientPath); @@ -125,7 +125,7 @@ public async Task GenerateGameClientManifest_ExecutableHashIsComputed() /// /// A representing the asynchronous test operation. [Fact] - public async Task GenerateGameClientManifest_IncludesAllExpectedFiles() + public async Task GenerateGameClientManifest_IncludesAllExpectedFilesAsync() { var clientPath = Path.Combine(_tempDirectory, "FullClient"); Directory.CreateDirectory(clientPath); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationDetectionOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationDetectionOrchestratorTests.cs index 684d319c8..baa4dba35 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationDetectionOrchestratorTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationDetectionOrchestratorTests.cs @@ -18,7 +18,7 @@ public class GameInstallationDetectionOrchestratorTests /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllInstallationsAsync_AllDetectorsSucceed_CombinesItems() + public async Task DetectAllInstallationsAsync_AllDetectorsSucceed_CombinesItemsAsync() { // Arrange var instA = new GameInstallation("C:\\Steam\\Games", GameInstallationType.Steam, NullLogger.Instance); @@ -55,7 +55,7 @@ public async Task DetectAllInstallationsAsync_AllDetectorsSucceed_CombinesItems( /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllInstallationsAsync_DetectorFails_ReturnsFailed() + public async Task DetectAllInstallationsAsync_DetectorFails_ReturnsFailedAsync() { // Arrange var mockD = new Mock(); @@ -81,7 +81,7 @@ public async Task DetectAllInstallationsAsync_DetectorFails_ReturnsFailed() /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllInstallationsAsync_PlatformFiltering_SkipsIncompatibleDetectors() + public async Task DetectAllInstallationsAsync_PlatformFiltering_SkipsIncompatibleDetectorsAsync() { // Arrange var instA = new GameInstallation("C:\\Steam\\Games", GameInstallationType.Steam, NullLogger.Instance); @@ -117,7 +117,7 @@ public async Task DetectAllInstallationsAsync_PlatformFiltering_SkipsIncompatibl /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllInstallationsAsync_MixedResults_CombinesSuccessAndFailures() + public async Task DetectAllInstallationsAsync_MixedResults_CombinesSuccessAndFailuresAsync() { // Arrange var instA = new GameInstallation("C:\\Steam\\Games", GameInstallationType.Steam, NullLogger.Instance); @@ -151,7 +151,7 @@ public async Task DetectAllInstallationsAsync_MixedResults_CombinesSuccessAndFai /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllInstallationsAsync_EmptyDetectors_ReturnsEmptySuccess() + public async Task DetectAllInstallationsAsync_EmptyDetectors_ReturnsEmptySuccessAsync() { // Arrange var svc = new GameInstallationDetectionOrchestrator( @@ -172,7 +172,7 @@ public async Task DetectAllInstallationsAsync_EmptyDetectors_ReturnsEmptySuccess /// /// A task representing the asynchronous operation. [Fact] - public async Task DetectAllInstallationsAsync_DetectorThrowsException_HandlesGracefully() + public async Task DetectAllInstallationsAsync_DetectorThrowsException_HandlesGracefullyAsync() { // Arrange var mockSuccess = new Mock(); @@ -204,7 +204,7 @@ public async Task DetectAllInstallationsAsync_DetectorThrowsException_HandlesGra /// /// A task representing the asynchronous operation. [Fact] - public async Task GetDetectedInstallationsAsync_ReturnsResults() + public async Task GetDetectedInstallationsAsync_ReturnsResultsAsync() { // Arrange var instA = new GameInstallation("C:\\Steam\\Games", GameInstallationType.Steam, NullLogger.Instance); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs index dc6da8dbb..ba6f293c3 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameInstallations/GameInstallationServiceTests.cs @@ -81,7 +81,7 @@ public void Dispose() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetInstallationAsync_WithValidId_ShouldReturnInstallation() + public async Task GetInstallationAsync_WithValidId_ShouldReturnInstallationAsync() { // Arrange var installation = new GameInstallation(Path.GetTempPath(), GameInstallationType.Steam, new Mock>().Object); @@ -104,7 +104,7 @@ public async Task GetInstallationAsync_WithValidId_ShouldReturnInstallation() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetInstallationAsync_WithInvalidId_ShouldReturnFailure() + public async Task GetInstallationAsync_WithInvalidId_ShouldReturnFailureAsync() { // Arrange var detectionResult = DetectionResult.CreateSuccess([], TimeSpan.Zero); @@ -124,7 +124,7 @@ public async Task GetInstallationAsync_WithInvalidId_ShouldReturnFailure() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetInstallationAsync_WithDetectionFailure_ShouldReturnFailure() + public async Task GetInstallationAsync_WithDetectionFailure_ShouldReturnFailureAsync() { // Arrange var detectionResult = DetectionResult.CreateFailure("Detection failed"); @@ -144,7 +144,7 @@ public async Task GetInstallationAsync_WithDetectionFailure_ShouldReturnFailure( /// /// A task representing the asynchronous operation. [Fact] - public async Task GetInstallationAsync_WithNullId_ShouldReturnFailure() + public async Task GetInstallationAsync_WithNullId_ShouldReturnFailureAsync() { // Act var result = await _service.GetInstallationAsync(null!); @@ -159,7 +159,7 @@ public async Task GetInstallationAsync_WithNullId_ShouldReturnFailure() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetInstallationAsync_WithEmptyId_ShouldReturnFailure() + public async Task GetInstallationAsync_WithEmptyId_ShouldReturnFailureAsync() { // Act var result = await _service.GetInstallationAsync(string.Empty); @@ -174,7 +174,7 @@ public async Task GetInstallationAsync_WithEmptyId_ShouldReturnFailure() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAllInstallationsAsync_ShouldReturnAllInstallations() + public async Task GetAllInstallationsAsync_ShouldReturnAllInstallationsAsync() { // Arrange var installation1 = new GameInstallation(Path.GetTempPath(), GameInstallationType.Steam, new Mock>().Object); @@ -205,7 +205,7 @@ public async Task GetAllInstallationsAsync_ShouldReturnAllInstallations() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAllInstallationsAsync_WithDetectionFailure_ShouldReturnFailure() + public async Task GetAllInstallationsAsync_WithDetectionFailure_ShouldReturnFailureAsync() { // Arrange _service.InvalidateCache(); @@ -226,7 +226,7 @@ public async Task GetAllInstallationsAsync_WithDetectionFailure_ShouldReturnFail /// /// A task representing the asynchronous operation. [Fact] - public async Task GetInstallationAsync_WithCaching_ShouldUseCachedResults() + public async Task GetInstallationAsync_WithCaching_ShouldUseCachedResultsAsync() { // Arrange var installation = new GameInstallation(Path.GetTempPath(), GameInstallationType.Steam, new Mock>().Object); @@ -265,7 +265,7 @@ public void Dispose_ShouldDisposeResources() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAllInstallationsAsync_WhenDetectionFailsWithNoResults_DoesNotCacheAndRescansOnRetry() + public async Task GetAllInstallationsAsync_WhenDetectionFailsWithNoResults_DoesNotCacheAndRescansOnRetryAsync() { var denied = DetectionResult.CreateFailure( "Could not search /Users/test/Documents because macOS denied access"); @@ -295,7 +295,7 @@ public async Task GetAllInstallationsAsync_WhenDetectionFailsWithNoResults_DoesN /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAllInstallationsAsync_WhenDetectionFailsWithPersistedManifest_DoesNotCache() + public async Task GetAllInstallationsAsync_WhenDetectionFailsWithPersistedManifest_DoesNotCacheAsync() { var denied = DetectionResult.CreateFailure( "Could not search /Users/test/Documents because macOS denied access"); @@ -338,7 +338,7 @@ public async Task GetAllInstallationsAsync_WhenDetectionFailsWithPersistedManife /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAllInstallationsAsync_WhenDetectionSucceedsWithNoResults_CachesTheEmptyResult() + public async Task GetAllInstallationsAsync_WhenDetectionSucceedsWithNoResults_CachesTheEmptyResultAsync() { _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); @@ -350,4 +350,197 @@ public async Task GetAllInstallationsAsync_WhenDetectionSucceedsWithNoResults_Ca x => x.DetectAllInstallationsAsync(It.IsAny()), Times.Once); } + + /// + /// Verifies that persisted manifests reconstruct an installation with both Generals and Zero Hour capabilities. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetAllInstallationsAsync_ReconstructsInstallationWithGeneralsAndZeroHour_FromPersistedManifestsAsync() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubManifestReconstruct_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), string.Empty); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + var generalsManifest = new ContentManifest + { + Id = "1.108.retail.gameinstallation.generals", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.Generals, + Version = "1.08", + Metadata = new ContentMetadata { SourcePath = tempDir }, + }; + + var zeroHourManifest = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir }, + }; + + _manifestPoolMock + .Setup(x => x.SearchManifestsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([generalsManifest, zeroHourManifest])); + + var result = await _service.GetAllInstallationsAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + var install = Assert.Single(result.Data); + Assert.True(install.HasGenerals); + Assert.Equal(tempDir, install.GeneralsPath); + Assert.True(install.HasZeroHour); + Assert.Equal(tempDir, install.ZeroHourPath); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that when TargetGame defaults to Generals (0) because it was omitted in JSON, a Zero Hour manifest ID only sets the Zero Hour path. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetAllInstallationsAsync_ReconstructsZeroHourInstallation_WhenTargetGameOmittedAsync() + { + var tempDir = Path.Combine(Path.GetTempPath(), "GenHubZHManifestReconstruct_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + try + { + File.WriteAllText(Path.Combine(tempDir, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir, "INIZH.big"), string.Empty); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + // TargetGame is omitted / default(GameType) which equals GameType.Generals (0) + var zeroHourManifest = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir }, + }; + + _manifestPoolMock + .Setup(x => x.SearchManifestsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([zeroHourManifest])); + + var result = await _service.GetAllInstallationsAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + var install = Assert.Single(result.Data); + Assert.True(install.HasZeroHour); + Assert.Equal(tempDir, install.ZeroHourPath); + Assert.False(install.HasGenerals); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + /// + /// Verifies that manifests with distinct source paths reconstruct into distinct installations. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetAllInstallationsAsync_ReconstructsInstallations_RespectingPathComparerAsync() + { + var tempDir1 = Path.Combine(Path.GetTempPath(), "GenHubPathTest_A_" + Guid.NewGuid().ToString("N")); + var tempDir2 = Path.Combine(Path.GetTempPath(), "GenHubPathTest_B_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir1); + Directory.CreateDirectory(tempDir2); + try + { + File.WriteAllText(Path.Combine(tempDir1, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir1, "INIZH.big"), string.Empty); + File.WriteAllText(Path.Combine(tempDir2, "generals.exe"), string.Empty); + File.WriteAllText(Path.Combine(tempDir2, "INIZH.big"), string.Empty); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + var manifest1 = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir1 }, + }; + + var manifest2 = new ContentManifest + { + Id = "1.104.retail.gameinstallation.zerohour", + ContentType = GenHub.Core.Models.Enums.ContentType.GameInstallation, + TargetGame = GameType.ZeroHour, + Version = "1.04", + Metadata = new ContentMetadata { SourcePath = tempDir2 }, + }; + + _manifestPoolMock + .Setup(x => x.SearchManifestsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([manifest1, manifest2])); + + var result = await _service.GetAllInstallationsAsync(); + + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(2, result.Data.Count); + } + finally + { + Directory.Delete(tempDir1, true); + Directory.Delete(tempDir2, true); + } + } + + /// + /// Verifies that installations with paths differing only by case are handled according to platform path comparison semantics. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task AddInstallationToCacheAsync_HandlesCaseDistinctPaths_AccordingToPlatformPathComparisonAsync() + { + var basePath = Path.Combine(Path.GetTempPath(), "GenHubCaseTest_" + Guid.NewGuid().ToString("N")); + var path1 = Path.Combine(basePath, "zh"); + var path2 = Path.Combine(basePath, "ZH"); + + var install1 = new GameInstallation(path1, GameInstallationType.Steam); + var install2 = new GameInstallation(path2, GameInstallationType.Retail); + + _orchestratorMock.Setup(x => x.DetectAllInstallationsAsync(It.IsAny())) + .ReturnsAsync(DetectionResult.CreateSuccess([], TimeSpan.Zero)); + + var addResult1 = await _service.AddInstallationToCacheAsync(install1); + var addResult2 = await _service.AddInstallationToCacheAsync(install2); + + Assert.True(addResult1.Success); + Assert.True(addResult2.Success); + + var allResult = await _service.GetAllInstallationsAsync(); + Assert.True(allResult.Success); + Assert.NotNull(allResult.Data); + + if (OperatingSystem.IsWindows()) + { + Assert.Single(allResult.Data); + } + else + { + Assert.Equal(2, allResult.Data.Count); + } + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs new file mode 100644 index 000000000..200992255 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/EngineLaunchSmokeTests.cs @@ -0,0 +1,237 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using GenHub.Core.Constants; +using GenHub.Core.Models.Launching; +using GenHub.Features.GameProfiles.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace GenHub.Tests.Core.Features.GameProfiles; + +/// +/// Engine-only launch smoke test: starts the native client with no game data at all and +/// requires the failure the engine is known to produce. +/// +/// The engine cannot reach its main loop without content — with no readable INI it aborts +/// with exit code 1 during initialisation. Launching it in an empty workspace therefore +/// still proves the things CI otherwise never covers: the binary loads, its dylibs resolve +/// relative to the executable, initialisation runs as far as INI loading, and the failure +/// is a prompt exit rather than a hang. No licensed retail data is involved. +/// +/// +/// Like the other native-client tests this skips when no client is present — unless +/// GENHUB_REQUIRE_NATIVE_SMOKE is set, which CI uses to turn a missing client into +/// a failure instead of a silent green run. +/// +/// +[Collection(NativeClientLaunchCollection.Name)] +public class EngineLaunchSmokeTests : IDisposable +{ + /// + /// Environment variable that forbids skipping: when set to 1 or true, a + /// missing native client fails the test rather than passing it vacuously. + /// + public const string RequireEnvironmentVariable = "GENHUB_REQUIRE_NATIVE_SMOKE"; + + /// + /// How long the engine gets to exit before the test declares a hang. The observed + /// failure takes about a second; the margin covers a cold CI runner, not the engine. + /// + private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(60); + + private readonly string _tempRoot = Path.Combine( + Path.GetTempPath(), + $"genhub-engine-smoke-{Guid.NewGuid():N}"); + + private readonly GameProcessManager _processManager = new(NullLogger.Instance); + + /// + /// Initializes a new instance of the class. + /// + public EngineLaunchSmokeTests() => Directory.CreateDirectory(_tempRoot); + + private static bool IsSmokeRequired + { + get + { + var value = Environment.GetEnvironmentVariable(RequireEnvironmentVariable); + return string.Equals(value, "1", StringComparison.Ordinal) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + } + + /// + /// Stages the engine binary and its libraries into an empty workspace — no archives, + /// no retail roots — and launches headless with HOME redirected so the crash report + /// lands in the sandbox. The engine must exit with code 1 and leave its crash report + /// in the redirected HOME — the diagnostic that identifies this as the known abort. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task EngineWithNoGameData_ExitsWithCodeOne() + { + var installDirectory = NativeClientFixture.Directory; + if (installDirectory is null) + { + var missingClientMessage = + $"{RequireEnvironmentVariable} is set but no native client was found. " + + $"Point {NativeClientFixture.EnvironmentOverride} at a directory containing " + + $"'{NativeClientFixture.BinaryName}'."; + Assert.False(IsSmokeRequired, missingClientMessage); + return; + } + + var workspace = StageEngineOnlyWorkspace(installDirectory); + var sandboxHome = Path.Combine(_tempRoot, "home"); + Directory.CreateDirectory(sandboxHome); + + // The exit code is only observable through the manager's exit event: the process + // handle stays internal, and GetProcessInfoAsync reports an exited process as + // not found. Subscribed before launch so a fast exit cannot slip past. + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _processManager.ProcessExited += (_, e) => exited.TrySetResult(e.ExitCode); + + // The install-path variables are pinned to the empty workspace so a developer who + // has them exported cannot feed this "no data" launch their real retail content + // through the inherited environment. GameProcessManager assigns these into + // ProcessStartInfo.EnvironmentVariables by indexer, which the framework + // pre-populates from the parent environment — so an inherited value is replaced, + // not merely joined. The trailing separator matches how GameLauncher sets these + // for real launches: the engine requires it on the value. + var pinnedInstallPath = workspace + Path.DirectorySeparatorChar; + var configuration = new GameLaunchConfiguration + { + ExecutablePath = Path.Combine(workspace, NativeClientFixture.BinaryName), + WorkingDirectory = workspace, + Arguments = new() { ["-headless"] = string.Empty }, + EnvironmentVariables = new() + { + ["HOME"] = sandboxHome, + [RetailArchiveConstants.ZeroHourInstallPathVariable] = pinnedInstallPath, + [RetailArchiveConstants.GeneralsInstallPathVariable] = pinnedInstallPath, + }, + }; + + var result = await _processManager.StartProcessAsync(configuration); + + if (!result.Success) + { + // The engine beat the launcher-detection delay. The manager folds the exit + // code into the error, so the assertion still pins it to exactly 1. + Assert.Contains( + "exited immediately with code 1", + string.Join(" ", result.Errors), + StringComparison.OrdinalIgnoreCase); + } + else + { + var completed = await Task.WhenAny(exited.Task, Task.Delay(ExitTimeout)); + if (completed != exited.Task) + { + await _processManager.TerminateProcessAsync(result.Data!.ProcessId); + Assert.Fail( + $"The engine was still running {ExitTimeout.TotalSeconds:F0}s after launch with " + + "no game data. The known behaviour is a prompt abort with exit code 1; a hang " + + "here means startup no longer fails fast and the launcher could wait forever."); + } + + var exitCode = await exited.Task; + Assert.NotNull(exitCode); + Assert.Equal(1, exitCode); + } + + AssertCrashReportWasWritten(sandboxHome); + } + + /// + /// Releases the temporary workspace and sandbox HOME. + /// + public void Dispose() + { + GC.SuppressFinalize(this); + _processManager.Dispose(); + try + { + if (Directory.Exists(_tempRoot)) + { + Directory.Delete(_tempRoot, recursive: true); + } + } + catch (IOException) + { + // A leftover temp directory is not worth failing a test over. + } + } + + /// + /// Asserts the abort produced its diagnostic. In this failure mode stderr is empty; + /// what the engine leaves behind is a crash report named ReleaseCrashInfo.txt + /// under HOME (on macOS beneath Library/Application Support). Searched + /// recursively so the intermediate segments — engine behaviour, and platform + /// dependent — are not hardcoded. The sandbox HOME is created empty by this test, so + /// any report found here was newly written by this launch; finding it in the sandbox + /// also proves the HOME redirection worked, keeping the user's real profile untouched. + /// + /// The redirected HOME directory. + private static void AssertCrashReportWasWritten(string sandboxHome) + { + var reports = Directory + .EnumerateFiles(sandboxHome, "ReleaseCrashInfo.txt", SearchOption.AllDirectories) + .ToList(); + + var missingReportMessage = + "The engine exited with code 1 but wrote no ReleaseCrashInfo.txt under the " + + $"redirected HOME '{sandboxHome}'. The known abort writes that report before " + + "exiting, so its absence means this was a different failure than the " + + "no-game-data INI abort this test pins down."; + Assert.True(reports.Count > 0, missingReportMessage); + + var reportContents = File.ReadAllText(reports[0]); + Assert.False( + string.IsNullOrWhiteSpace(reportContents), + $"The crash report at '{reports[0]}' is empty; the known abort records its reason."); + + // The stable line the abort writes is "; Reason Uncaught Exception during + // initialization." — asserted without the leading punctuation so a formatting + // change there cannot break the test, while the reason itself stays pinned. + Assert.Contains( + "Reason Uncaught Exception during initialization.", + reportContents, + StringComparison.Ordinal); + } + + /// + /// Copies only the engine binary and its dynamic libraries into a fresh directory. + /// Everything else in the source install — archives, retail roots, user files — is + /// deliberately left behind; their absence is the point of the test. + /// + /// The native client install to stage from. + /// The staged workspace directory. + private string StageEngineOnlyWorkspace(string installDirectory) + { + var workspace = Path.Combine(_tempRoot, "workspace"); + Directory.CreateDirectory(workspace); + + foreach (var path in Directory.EnumerateFiles(installDirectory, "*", SearchOption.TopDirectoryOnly)) + { + var name = Path.GetFileName(path); + if (name != NativeClientFixture.BinaryName && !NativeClientFixture.IsDynamicLibrary(name)) + { + continue; + } + + File.Copy(path, Path.Combine(workspace, name)); + } + + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + Path.Combine(workspace, NativeClientFixture.BinaryName), + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + + return workspace; + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs index cd6e2f30a..a36576dd3 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs @@ -1,3 +1,4 @@ +using GenHub.Core.Constants; using GenHub.Core.Interfaces.Common; using GenHub.Core.Models.Launching; using GenHub.Features.GameProfiles.Infrastructure; @@ -22,12 +23,321 @@ public GameProcessManagerTests() _processManager = new GameProcessManager(_loggerMock.Object); } + /// + /// A process that was just started successfully is running, and the returned information has + /// to say so — consumers read to decide launch state. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WithLiveProcess_ReportsItAsRunningAsync() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + }; + + var result = await _processManager.StartProcessAsync(config); + + Assert.True(result.Success, string.Join(", ", result.Errors)); + Assert.True(result.Data!.IsRunning); + + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + + /// + /// Launch state is re-read through after + /// the launch returns, so that path has to report running state too — not just the one that + /// started the process. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task GetProcessInfoAsync_ForALiveProcess_ReportsItAsRunningAsync() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + }; + + var started = await _processManager.StartProcessAsync(config); + Assert.True(started.Success, string.Join(", ", started.Errors)); + + var info = await _processManager.GetProcessInfoAsync(started.Data!.ProcessId); + + Assert.True(info.Success, string.Join(", ", info.Errors)); + Assert.True(info.Data!.IsRunning); + + await _processManager.TerminateProcessAsync(started.Data.ProcessId); + } + + /// + /// The Easy Anti-Cheat bootstrapper spawns the game and then keeps running for about a minute. + /// Tracking must follow the spawned child and must not wait for the launcher to exit first. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WithExpectedChild_TracksTheChildWhileTheLauncherStillRunsAsync() + { + if (!OperatingSystem.IsWindows()) + { + // The hosted macOS runners do not start the harness child within the discovery + // timeout, so this asserts nothing there. Adoption itself is covered on Unix by + // StartProcessAsync_WhenAnUndeclaredLauncherForksAndExits_AdoptsTheSpawnedGameAsync. + return; + } + + using var harness = LauncherHarness.Create(); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await _processManager.StartProcessAsync(config); + stopwatch.Stop(); + + Assert.True(result.Success, string.Join(", ", result.Errors)); + Assert.NotNull(result.Data); + Assert.Equal(LauncherHarness.ChildProcessName, result.Data!.ProcessName); + + // The launcher outlives this call by design; returning quickly proves tracking did not + // wait for it to exit, which is what made the real bootstrapper untrackable. + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(LauncherHarness.LauncherLifetimeSeconds / 2.0), + $"tracking took {stopwatch.Elapsed}, so it waited for the launcher"); + + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + + /// + /// When a child is expected but never appears, the launch fails rather than silently falling + /// back to tracking the launcher — which would report the game as running when it is not. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WithExpectedChildThatNeverAppears_FailsInsteadOfTrackingTheLauncherAsync() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + ExpectedChildDiscoveryTimeout = TimeSpan.FromMilliseconds(750), + }; + + var result = await _processManager.StartProcessAsync(config); + + Assert.False(result.Success); + Assert.Contains(LauncherHarness.ChildProcessName, string.Join(", ", result.Errors)); + } + + /// + /// A bootstrapper that bails without launching the game exits with code 0, so the exit code + /// alone cannot distinguish it from success. Once the launcher is gone no child is coming, and + /// waiting out the full discovery timeout only delays the failure behind a misleading message. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_FailsWithoutWaitingOutTheTimeoutAsync() + { + using var harness = LauncherHarness.Create(spawnChild: false, exitImmediately: true, stderrMessage: null); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + ExpectedChildDiscoveryTimeout = TimeSpan.FromSeconds(10), + }; + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var result = await _processManager.StartProcessAsync(config); + stopwatch.Stop(); + + Assert.False(result.Success); + var errors = string.Join(", ", result.Errors); + Assert.True( + errors.Contains("without starting") || errors.Contains("start time could not be read"), + $"Expected exit failure message, but got: {errors}"); + Assert.True( + stopwatch.Elapsed < TimeSpan.FromSeconds(5), + $"Expected a fast failure once the launcher exited, but it took {stopwatch.Elapsed}."); + } + + /// + /// The clean-exit failure and the stderr diagnostics are complementary and belong together: + /// this path fires only once the launcher has provably exited, which is exactly the condition + /// AppendLauncherErrors requires before draining is safe. So the message that says the game + /// never started can also carry the bootstrapper's own explanation of why. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_ReportsItsStderrAsync() + { + const string complaint = "EasyAntiCheat_is_not_installed"; + using var harness = LauncherHarness.Create(spawnChild: false, exitImmediately: true, stderrMessage: complaint); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + ExpectedChildDiscoveryTimeout = TimeSpan.FromSeconds(10), + }; + + var result = await _processManager.StartProcessAsync(config); + + Assert.False(result.Success); + + var errors = string.Join(", ", result.Errors); + Assert.True( + errors.Contains("without starting") || errors.Contains("did not start") || errors.Contains("start time could not be read"), + $"Expected start failure message, but got: {errors}"); + Assert.Contains(complaint, errors); + } + + /// + /// A launcher that forks the game and exits 0 without declaring a child — a Wine or Proton + /// wrapper, or a stub — must have its game adopted instead of being reported as an immediate + /// exit. Adoption was gated to Windows, so these launches failed on Unix while the game ran. + /// Windows cannot exercise this path with a script launcher: a .bat is handled as a batch file + /// and skips immediate-exit handling entirely. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenAnUndeclaredLauncherForksAndExits_AdoptsTheSpawnedGameAsync() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var harness = LauncherHarness.Create(exitImmediately: true, launcherSharesChildName: true); + + if (!harness.ChildBinaryRuns) + { + // The platform refuses the copied system binary, so no child can exist to adopt and + // the assertions below would be measuring the fixture rather than the manager. + return; + } + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + }; + + var result = await _processManager.StartProcessAsync(config); + + try + { + Assert.True(result.Success, string.Join(", ", result.Errors)); + Assert.Equal(LauncherHarness.ChildProcessName, result.Data!.ProcessName); + Assert.True(result.Data.IsRunning); + } + finally + { + if (result.Success && result.Data is not null) + { + await _processManager.TerminateProcessAsync(result.Data.ProcessId); + } + } + } + + /// + /// A cancelled adoption must surface as cancellation rather than a generic start failure. + /// Swallowing it disagrees with TerminateProcessAsync, which rethrows, and prevents + /// GameLauncher.LaunchProfileAsync from reaching its own cancellation branch. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenAdoptionIsCancelled_PropagatesCancellationAsync() + { + using var harness = LauncherHarness.Create(spawnChild: false); + + var config = new GameLaunchConfiguration + { + ExecutablePath = harness.LauncherPath, + WorkingDirectory = harness.WorkingDirectory, + ExpectedChildProcessName = LauncherHarness.ChildProcessName, + + // Long enough that the timeout cannot be what ends the wait. + ExpectedChildDiscoveryTimeout = TimeSpan.FromSeconds(30), + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); + + await Assert.ThrowsAnyAsync( + () => _processManager.StartProcessAsync(config, cts.Token)); + } + + /// + /// When the launcher exits immediately with code 0 and the subsequent adoption poll loop is cancelled, + /// the operation must throw OperationCanceledException and clean up any resources. + /// + /// A task representing the asynchronous operation. + [Fact] + public async Task StartProcessAsync_WhenImmediateExitPollIsCancelled_ThrowsOperationCanceledExceptionAsync() + { + if (OperatingSystem.IsWindows()) + { + // On Windows batch files bypass immediate-exit adoption handling. + return; + } + + // Arrange + var tempScript = Path.Combine(Path.GetTempPath(), $"genhub_exit0_{Guid.NewGuid():N}.sh"); + var scriptContent = "#!/bin/sh\nexit 0\n"; + await File.WriteAllTextAsync(tempScript, scriptContent); + + using var chmod = System.Diagnostics.Process.Start("chmod", ["+x", tempScript]); + chmod?.WaitForExit(); + + try + { + var config = new GameLaunchConfiguration + { + ExecutablePath = tempScript, + }; + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(ProcessConstants.LauncherDetectionDelayMs + 200)); + + // Act & Assert + await Assert.ThrowsAnyAsync( + () => _processManager.StartProcessAsync(config, cts.Token)); + } + finally + { + if (File.Exists(tempScript)) + { + try + { + File.Delete(tempScript); + } + catch + { + // Best effort. + } + } + } + } + /// /// Tests that StartProcessAsync handles invalid executable path. /// /// A task representing the asynchronous operation. [Fact] - public async Task StartProcessAsync_WithInvalidExecutablePath_ShouldReturnFailure() + public async Task StartProcessAsync_WithInvalidExecutablePath_ShouldReturnFailureAsync() { // Arrange var config = new GameLaunchConfiguration @@ -47,7 +357,7 @@ public async Task StartProcessAsync_WithInvalidExecutablePath_ShouldReturnFailur /// /// A task representing the asynchronous operation. [Fact] - public async Task TerminateProcessAsync_WithNonExistentProcessId_ShouldReturnFailure() + public async Task TerminateProcessAsync_WithNonExistentProcessId_ShouldReturnFailureAsync() { // Act var result = await _processManager.TerminateProcessAsync(99999); @@ -61,7 +371,7 @@ public async Task TerminateProcessAsync_WithNonExistentProcessId_ShouldReturnFai /// /// A task representing the asynchronous operation. [Fact] - public async Task GetProcessInfoAsync_WithNonExistentProcessId_ShouldReturnFailure() + public async Task GetProcessInfoAsync_WithNonExistentProcessId_ShouldReturnFailureAsync() { // Act var result = await _processManager.GetProcessInfoAsync(99999); @@ -76,7 +386,7 @@ public async Task GetProcessInfoAsync_WithNonExistentProcessId_ShouldReturnFailu /// /// A task representing the asynchronous operation. [Fact] - public async Task GetActiveProcessesAsync_Initially_ShouldReturnEmptyList() + public async Task GetActiveProcessesAsync_Initially_ShouldReturnEmptyListAsync() { // Act var result = await _processManager.GetActiveProcessesAsync(); @@ -91,20 +401,20 @@ public async Task GetActiveProcessesAsync_Initially_ShouldReturnEmptyList() /// /// A task representing the asynchronous operation. [Fact] - public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccess() + public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccessAsync() { // Arrange - Use cross-platform approach - string tempExe; - string scriptContent; + string tempExe = string.Empty; + string scriptContent = string.Empty; if (OperatingSystem.IsWindows()) { - tempExe = Path.GetTempFileName() + ".bat"; + tempExe = Path.Combine(Path.GetTempPath(), $"genhub_test_{Guid.NewGuid():N}.bat"); scriptContent = "@echo off\nping -n 6 127.0.0.1 >nul\n"; } else { - tempExe = Path.GetTempFileName() + ".sh"; + tempExe = Path.Combine(Path.GetTempPath(), $"genhub_test_{Guid.NewGuid():N}.sh"); scriptContent = "#!/bin/bash\nping -c 5 127.0.0.1 > /dev/null\n"; } @@ -145,7 +455,302 @@ public async Task TerminateProcessAsync_WithRunningProcess_ShouldReturnSuccess() } finally { - File.Delete(tempExe); + try + { + if (File.Exists(tempExe)) + { + File.Delete(tempExe); + } + } + catch (IOException) + { + // Process termination lock release may be slightly deferred by the OS + } + catch (UnauthorizedAccessException) + { + // Ignored if access denied during process termination + } + } + } + + /// + /// A disposable stand-in for the Easy Anti-Cheat bootstrapper: a launcher that outlives the + /// call which starts it, optionally spawning a distinctly named child inside the working + /// directory. Uses copies of real long-running system binaries so the child has a process name + /// of its own, which is what selection keys on. + /// + private sealed class LauncherHarness : IDisposable + { + /// The process name the spawned child reports. + public const string ChildProcessName = "genhubchild"; + + /// How long the launcher keeps running after it spawns the child. + public const int LauncherLifetimeSeconds = 20; + + /// File the launcher writes its own PID into, so Dispose can stop it. + private const string LauncherPidFileName = "launcher.pid"; + + /// How long to wait for the one-shot checks that prepare and vet the child. + private const int ChildProbeTimeoutMs = 5000; + + private LauncherHarness(string workingDirectory, string launcherPath, bool childBinaryRuns) + { + WorkingDirectory = workingDirectory; + LauncherPath = launcherPath; + ChildBinaryRuns = childBinaryRuns; + } + + /// Gets the directory the launcher and child run from. + public string WorkingDirectory { get; } + + /// Gets the path of the launcher to start. + public string LauncherPath { get; } + + /// Gets a value indicating whether the copied child binary runs on this machine. + public bool ChildBinaryRuns { get; } + + /// Creates a harness, optionally spawning a child. + /// Whether the launcher should spawn the child. + /// Whether the launcher should exit cleanly instead of staying alive. + /// A line the launcher writes to stderr before doing anything else. + /// Whether the launcher takes the child's name, as an undeclared child is looked up by the launcher's own name. Unix only. + /// The created harness. + public static LauncherHarness Create( + bool spawnChild = true, + bool exitImmediately = false, + string? stderrMessage = null, + bool launcherSharesChildName = false) + { + var workingDirectory = Path.Combine(Path.GetTempPath(), "genhub-launcher-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + workingDirectory = Canonicalize(workingDirectory); + + var childPath = Path.Combine(workingDirectory, OperatingSystem.IsWindows() ? ChildProcessName + ".exe" : ChildProcessName); + File.Copy(LongRunningSystemBinary(), childPath); + + string launcherPath = string.Empty; + string script = string.Empty; + if (OperatingSystem.IsWindows()) + { + launcherPath = Path.Combine(workingDirectory, "genhublauncher.bat"); + var spawn = spawnChild ? $"start \"\" /b \"{childPath}\" -n {LauncherLifetimeSeconds + 1} 127.0.0.1 >nul\n" : string.Empty; + + // Batch has no $$. PowerShell's own parent is the batch host, so it can report the + // PID the harness needs. If PowerShell is unavailable the loop simply writes + // nothing and Dispose falls back to leaving the launcher alone. + var recordPid = exitImmediately + ? string.Empty + : $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n"; + + // Leave the working directory afterwards: a batch host holds its current directory + // open, which would defeat the cleanup delete for the launcher's whole lifetime. + var linger = exitImmediately ? string.Empty : $"ping -n {LauncherLifetimeSeconds + 1} 127.0.0.1 >nul\n"; + var complain = stderrMessage is null ? string.Empty : $"echo {stderrMessage} 1>&2\n"; + script = $"@echo off\n{spawn}{complain}{recordPid}cd /d \"%TEMP%\"\n{linger}"; + } + else + { + launcherPath = Path.Combine( + workingDirectory, + (launcherSharesChildName ? ChildProcessName : "genhublauncher") + ".sh"); + var spawn = spawnChild ? $"\"{childPath}\" {LauncherLifetimeSeconds} &\n" : string.Empty; + var linger = exitImmediately ? string.Empty : $"sleep {LauncherLifetimeSeconds}\n"; + var complain = stderrMessage is null ? string.Empty : $"echo \"{stderrMessage}\" >&2\n"; + var recordPid = exitImmediately ? string.Empty : $"echo $$ > \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n"; + + // The harness does not start the launcher, so the launcher reports its own PID. + script = $"#!/bin/bash\n{recordPid}{complain}{spawn}{linger}"; + } + + File.WriteAllText(launcherPath, script); + MakeExecutable(launcherPath); + MakeExecutable(childPath); + SignForLocalExecution(childPath); + + return new LauncherHarness(workingDirectory, launcherPath, CanExecute(childPath)); + } + + /// + public void Dispose() + { + KillLauncher(); + + foreach (var process in System.Diagnostics.Process.GetProcessesByName(ChildProcessName)) + { + try + { + if (GetImagePath(process)?.StartsWith(WorkingDirectory, StringComparison.OrdinalIgnoreCase) == true) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(2000); + } + } + catch + { + // Best effort - the process may already be gone. + } + finally + { + process.Dispose(); + } + } + + DeleteWorkingDirectory(); + } + + private static string? GetImagePath(System.Diagnostics.Process process) + { + try + { + return process.MainModule?.FileName; + } + catch + { + return null; + } + } + + private static string LongRunningSystemBinary() + { + if (OperatingSystem.IsWindows()) + { + return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "PING.EXE"); + } + + return File.Exists("/bin/sleep") ? "/bin/sleep" : "/usr/bin/sleep"; + } + + /// + /// Resolves symlinked components so the configured working directory is spelled the way a + /// process image path is. The temp root is reached through a symlink on macOS, while a real + /// workspace is not, and selection compares the two spellings without resolving either. + /// + /// An existing directory path. + /// The path with every symlinked component replaced by its target. + private static string Canonicalize(string path) + { + var resolved = Path.GetPathRoot(path) ?? string.Empty; + + foreach (var segment in path[resolved.Length..].Split( + Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) + { + resolved = Path.Combine(resolved, segment); + resolved = Directory.ResolveLinkTarget(resolved, returnFinalTarget: true)?.FullName ?? resolved; + } + + return resolved; + } + + /// + /// Re-signs the copied system binary so the platform will run it. macOS kills a copy of a + /// platform binary on sight, and an ad-hoc signature is what makes the copy executable. + /// + private static void SignForLocalExecution(string path) + { + if (!OperatingSystem.IsMacOS()) + { + return; + } + + try + { + using var codesign = System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = "codesign", + ArgumentList = { "--force", "--sign", "-", path }, + RedirectStandardError = true, + }); + codesign?.WaitForExit(ChildProbeTimeoutMs); + } + catch + { + // Best effort - CanExecute is what decides whether the child is usable. + } + } + + /// + /// Confirms the copied child really runs here, so a platform that refuses it reads as an + /// unusable fixture rather than as a launch that failed to adopt. + /// + private static bool CanExecute(string childPath) + { + if (OperatingSystem.IsWindows()) + { + return true; + } + + try + { + using var probe = System.Diagnostics.Process.Start(childPath, "0"); + if (probe is null) + { + return false; + } + + return probe.WaitForExit(ChildProbeTimeoutMs) && probe.ExitCode == 0; + } + catch + { + return false; + } + } + + private static void MakeExecutable(string path) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + using var chmod = System.Diagnostics.Process.Start("chmod", ["+x", path]); + chmod?.WaitForExit(); + } + + /// + /// Stops the launcher so it does not outlive the test by . + /// + private void KillLauncher() + { + var pidFile = Path.Combine(WorkingDirectory, LauncherPidFileName); + + try + { + if (!File.Exists(pidFile) || !int.TryParse(File.ReadAllText(pidFile).Trim(), out var launcherId)) + { + return; + } + + using var launcher = System.Diagnostics.Process.GetProcessById(launcherId); + launcher.Kill(entireProcessTree: true); + launcher.WaitForExit(2000); + } + catch + { + // Best effort - the launcher may have exited, or never recorded a PID. + } + } + + private void DeleteWorkingDirectory() + { + // A killed process can hold a handle for a moment after it stops, so retry rather than + // leaking the directory for the rest of the run. + for (var attempt = 0; attempt < 5; attempt++) + { + try + { + Directory.Delete(WorkingDirectory, recursive: true); + return; + } + catch when (attempt < 4) + { + Thread.Sleep(100); + } + catch + { + // Best effort. + } + } } } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProfileManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProfileManagerTests.cs index c6cf50a78..f769159f2 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProfileManagerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProfileManagerTests.cs @@ -46,7 +46,7 @@ public GameProfileManagerTests() /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateProfileAsync_Should_ReturnSuccess_When_InstallationAndClientExist() + public async Task CreateProfileAsync_Should_ReturnSuccess_When_InstallationAndClientExistAsync() { // Arrange var clientId = Guid.NewGuid().ToString(); @@ -78,7 +78,7 @@ public async Task CreateProfileAsync_Should_ReturnSuccess_When_InstallationAndCl /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateProfileAsync_Should_ReturnFailure_When_InstallationNotFound() + public async Task CreateProfileAsync_Should_ReturnFailure_When_InstallationNotFoundAsync() { // Arrange var request = new CreateProfileRequest { Name = "New Profile", GameInstallationId = "bad-id", GameClientId = "v1" }; @@ -98,7 +98,7 @@ public async Task CreateProfileAsync_Should_ReturnFailure_When_InstallationNotFo /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateProfileAsync_Should_ReturnFailure_When_ClientNotFoundInInstallation() + public async Task CreateProfileAsync_Should_ReturnFailure_When_ClientNotFoundInInstallationAsync() { // Arrange var installation = CreateTestInstallation("client-1"); @@ -125,7 +125,7 @@ public async Task CreateProfileAsync_Should_ReturnFailure_When_ClientNotFoundInI /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateProfileAsync_Should_ReturnFailure_When_RepositorySaveFails() + public async Task CreateProfileAsync_Should_ReturnFailure_When_RepositorySaveFailsAsync() { // Arrange var clientId = Guid.NewGuid().ToString(); @@ -155,7 +155,7 @@ public async Task CreateProfileAsync_Should_ReturnFailure_When_RepositorySaveFai /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_ReturnSuccess_When_ProfileExists() + public async Task UpdateProfileAsync_Should_ReturnSuccess_When_ProfileExistsAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -186,7 +186,7 @@ public async Task UpdateProfileAsync_Should_ReturnSuccess_When_ProfileExists() /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_ReturnFailure_When_ProfileNotFound() + public async Task UpdateProfileAsync_Should_ReturnFailure_When_ProfileNotFoundAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -208,7 +208,7 @@ public async Task UpdateProfileAsync_Should_ReturnFailure_When_ProfileNotFound() /// /// A task representing the asynchronous operation. [Fact] - public async Task DeleteProfileAsync_Should_ReturnSuccess_When_ProfileExists() + public async Task DeleteProfileAsync_Should_ReturnSuccess_When_ProfileExistsAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -238,7 +238,7 @@ public async Task DeleteProfileAsync_Should_ReturnSuccess_When_ProfileExists() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAvailableContentAsync_Should_ReturnFilteredManifests() + public async Task GetAvailableContentAsync_Should_ReturnFilteredManifestsAsync() { // Arrange var gameClient = new GameClient { GameType = GameType.Generals }; @@ -265,7 +265,7 @@ public async Task GetAvailableContentAsync_Should_ReturnFilteredManifests() /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAvailableContentAsync_Should_ReturnFailure_When_ManifestPoolFails() + public async Task GetAvailableContentAsync_Should_ReturnFailure_When_ManifestPoolFailsAsync() { // Arrange var gameClient = new GameClient { GameType = GameType.Generals }; @@ -285,7 +285,7 @@ public async Task GetAvailableContentAsync_Should_ReturnFailure_When_ManifestPoo /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAvailableContentAsync_Should_ReturnEmptyList_When_NoCompatibleContent() + public async Task GetAvailableContentAsync_Should_ReturnEmptyList_When_NoCompatibleContentAsync() { // Arrange var gameClient = new GameClient { GameType = GameType.Generals }; @@ -310,7 +310,7 @@ public async Task GetAvailableContentAsync_Should_ReturnEmptyList_When_NoCompati /// /// A task representing the asynchronous operation. [Fact] - public async Task GetAllProfilesAsync_Should_ReturnAllProfiles() + public async Task GetAllProfilesAsync_Should_ReturnAllProfilesAsync() { // Arrange var profiles = new List @@ -336,7 +336,7 @@ public async Task GetAllProfilesAsync_Should_ReturnAllProfiles() /// /// A task representing the asynchronous operation. [Fact] - public async Task CreateProfileAsync_Should_ValidateProfile_BeforeCreation() + public async Task CreateProfileAsync_Should_ValidateProfile_BeforeCreationAsync() { // Arrange var clientId = Guid.NewGuid().ToString(); @@ -364,7 +364,7 @@ public async Task CreateProfileAsync_Should_ValidateProfile_BeforeCreation() /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_UpdateEnabledContent_Successfully() + public async Task UpdateProfileAsync_Should_UpdateEnabledContent_SuccessfullyAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -399,7 +399,7 @@ public async Task UpdateProfileAsync_Should_UpdateEnabledContent_Successfully() /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_ClearWorkspace_When_ContentChanges() + public async Task UpdateProfileAsync_Should_ClearWorkspace_When_ContentChangesAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -435,7 +435,7 @@ public async Task UpdateProfileAsync_Should_ClearWorkspace_When_ContentChanges() /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_ClearWorkspace_When_GameClientChanges() + public async Task UpdateProfileAsync_Should_ClearWorkspace_When_GameClientChangesAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -472,7 +472,7 @@ public async Task UpdateProfileAsync_Should_ClearWorkspace_When_GameClientChange /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_KeepWorkspace_When_ContentUnchanged() + public async Task UpdateProfileAsync_Should_KeepWorkspace_When_ContentUnchangedAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -508,7 +508,7 @@ public async Task UpdateProfileAsync_Should_KeepWorkspace_When_ContentUnchanged( /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_KeepWorkspace_When_ContentUpdateRequestIsNull() + public async Task UpdateProfileAsync_Should_KeepWorkspace_When_ContentUpdateRequestIsNullAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -544,7 +544,7 @@ public async Task UpdateProfileAsync_Should_KeepWorkspace_When_ContentUpdateRequ /// /// A task representing the asynchronous operation. [Fact] - public async Task UpdateProfileAsync_Should_SendProfileUpdatedMessage_OnSuccess() + public async Task UpdateProfileAsync_Should_SendProfileUpdatedMessage_OnSuccessAsync() { // Arrange var profileId = Guid.NewGuid().ToString(); @@ -578,10 +578,7 @@ public async Task UpdateProfileAsync_Should_SendProfileUpdatedMessage_OnSuccess( ProfileUpdatedMessage? receivedMessage = null; - WeakReferenceMessenger.Default.Register(this, (r, m) => - { - receivedMessage = m; - }); + WeakReferenceMessenger.Default.Register(this, (_, m) => receivedMessage = m); // Act var result = await _profileManager.UpdateProfileAsync(profileId, request); diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs index 209152f37..889ced810 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientLaunchIntegrationTests.cs @@ -42,7 +42,7 @@ public class NativeClientLaunchIntegrationTests /// /// A task representing the asynchronous test. [Fact] - public async Task RealNativeClient_LaunchesThroughGameProcessManager() + public async Task RealNativeClient_LaunchesThroughGameProcessManagerAsync() { var installDirectory = NativeClientFixture.Directory; if (installDirectory is null) @@ -94,7 +94,7 @@ public async Task RealNativeClient_LaunchesThroughGameProcessManager() /// /// A task representing the asynchronous test. [Fact] - public async Task RealNativeClient_RequiresItsInstallDirectoryAsWorkingDirectory() + public async Task RealNativeClient_RequiresItsInstallDirectoryAsWorkingDirectoryAsync() { var installDirectory = NativeClientFixture.Directory; if (installDirectory is null) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs index bf61a7246..b48bcd6ea 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeClientManifestLaunchTests.cs @@ -42,7 +42,7 @@ public class NativeClientManifestLaunchTests /// /// A task representing the asynchronous test. [Fact] - public async Task ManifestResolvedEntryPoint_LaunchesTheRealEngine() + public async Task ManifestResolvedEntryPoint_LaunchesTheRealEngineAsync() { var installDirectory = NativeClientFixture.Directory; if (installDirectory is null) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs index c2efd3425..9e1cc099d 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/NativeLaunchDiagnosticsTests.cs @@ -39,7 +39,7 @@ public class NativeLaunchDiagnosticsTests : IDisposable /// /// A task representing the asynchronous test. [Fact] - public async Task NonExecutableFile_IsRefusedWithANamedError() + public async Task NonExecutableFile_IsRefusedWithANamedErrorAsync() { if (!OnUnix) { @@ -71,7 +71,7 @@ public async Task NonExecutableFile_IsRefusedWithANamedError() /// /// A task representing the asynchronous test. [Fact] - public async Task ProcessThatDiesAtStartup_SurfacesItsStderr() + public async Task ProcessThatDiesAtStartup_SurfacesItsStderrAsync() { if (!OnUnix) { @@ -109,7 +109,7 @@ await File.WriteAllTextAsync( /// /// A task representing the asynchronous test. [Fact] - public async Task ChattyProcess_StillLaunchesWithoutDeadlocking() + public async Task ChattyProcess_StillLaunchesWithoutDeadlockingAsync() { if (!OnUnix) { diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs index a743de6bf..4c913d65e 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/RetailArchiveRootTests.cs @@ -41,7 +41,7 @@ public class RetailArchiveRootTests : IDisposable /// /// A task representing the asynchronous test. [Fact] - public async Task EngineOnlyWorkspace_ReachesRetailArchivesThroughEnvironment() + public async Task EngineOnlyWorkspace_ReachesRetailArchivesThroughEnvironmentAsync() { var installDirectory = NativeClientFixture.Directory; if (installDirectory is null) @@ -91,7 +91,7 @@ public async Task EngineOnlyWorkspace_ReachesRetailArchivesThroughEnvironment() /// /// A task representing the asynchronous test. [Fact] - public async Task EngineOnlyWorkspace_WithoutArchiveRoots_DoesNotSurvive() + public async Task EngineOnlyWorkspace_WithoutArchiveRoots_DoesNotSurviveAsync() { var installDirectory = NativeClientFixture.Directory; if (installDirectory is null) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs new file mode 100644 index 000000000..42e313b51 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs @@ -0,0 +1,445 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.GameInstallations; +using GenHub.Core.Interfaces.GameProfiles; +using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Launching; +using GenHub.Core.Interfaces.Manifest; +using GenHub.Core.Models.GameClients; +using GenHub.Core.Models.GameProfile; +using GenHub.Core.Models.Launching; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Features.GameProfiles.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; +using WorkspaceStrategy = GenHub.Core.Models.Enums.WorkspaceStrategy; + +namespace GenHub.Tests.Core.Features.GameProfiles.Services; + +/// +/// Unit tests for runtime content hot-swapping validation in . +/// +public class GameProfileManagerHotswapTests +{ + private readonly Mock _profileRepositoryMock = new(); + private readonly Mock _installationServiceMock = new(); + private readonly Mock _manifestPoolMock = new(); + private readonly Mock _gameSettingsServiceMock = new(); + private readonly Mock _launchRegistryMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly GameProfileManager _profileManager; + + /// + /// Initializes a new instance of the class. + /// + public GameProfileManagerHotswapTests() + { + _profileManager = new GameProfileManager( + _profileRepositoryMock.Object, + _installationServiceMock.Object, + _manifestPoolMock.Object, + _gameSettingsServiceMock.Object, + _loggerMock.Object, + _launchRegistryMock.Object); + } + + /// + /// Verifies that updating a non-running profile clears the ActiveWorkspaceId when content changes. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileNotRunning_ClearsActiveWorkspaceIdOnContentChangeAsync() + { + // Arrange + const string profileId = "profile-1"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Existing Profile", + ActiveWorkspaceId = "workspace-abc", + EnabledContentIds = ["1.0.0.mod.first"], + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync(new List()); + + var request = new UpdateProfileRequest + { + EnabledContentIds = ["1.0.0.mod.second"], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.True(result.Success); + Assert.Empty(existingProfile.ActiveWorkspaceId); + } + + /// + /// Verifies that updating a running profile with map changes succeeds and preserves ActiveWorkspaceId. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithMapChanges_SucceedsAndPreservesActiveWorkspaceIdAsync() + { + // Arrange + const string profileId = "profile-running-1"; + const string oldMapId = "1.0.0.map.oldmap"; + const string newMapId = "1.0.0.mappack.newpack"; + + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + EnabledContentIds = [oldMapId], + }; + + var oldMapManifest = new ContentManifest + { + Id = ManifestId.Create(oldMapId), + Name = "Old Map", + ContentType = ContentType.Map, + }; + + var newMapManifest = new ContentManifest + { + Id = ManifestId.Create(newMapId), + Name = "New Map Pack", + ContentType = ContentType.MapPack, + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(oldMapId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(oldMapManifest)); + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(newMapId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(newMapManifest)); + + var request = new UpdateProfileRequest + { + EnabledContentIds = [newMapId], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.True(result.Success); + Assert.Equal("workspace-live-123", existingProfile.ActiveWorkspaceId); + Assert.Contains(newMapId, existingProfile.EnabledContentIds); + } + + /// + /// Verifies that updating a running profile with locked mod changes fails with a descriptive error. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithModChanges_FailsWithDescriptiveErrorAsync() + { + // Arrange + const string profileId = "profile-running-2"; + const string baseModId = "1.0.0.mod.base"; + const string addedModId = "1.0.0.mod.shockwave"; + + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + EnabledContentIds = [baseModId], + }; + + var modManifest = new ContentManifest + { + Id = ManifestId.Create(addedModId), + Name = "ShockWave Mod", + ContentType = ContentType.Mod, + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(addedModId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(modManifest)); + + var request = new UpdateProfileRequest + { + EnabledContentIds = [baseModId, addedModId], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.False(result.Success); + Assert.Contains("ShockWave Mod", result.FirstError); + Assert.Contains("while profile is running", result.FirstError, StringComparison.OrdinalIgnoreCase); + Assert.Contains("hot swapped", result.FirstError, StringComparison.OrdinalIgnoreCase); + _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that updating a running profile with game client changes fails with a descriptive error. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithGameClientChanges_FailsWithDescriptiveErrorAsync() + { + // Arrange + const string profileId = "profile-running-3"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + GameClient = new GameClient { Id = "client-original", Name = "Client 1.04" }, + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + var request = new UpdateProfileRequest + { + GameClient = new GameClient { Id = "client-new", Name = "Client 1.06" }, + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.False(result.Success); + Assert.Contains("game client", result.FirstError, StringComparison.OrdinalIgnoreCase); + _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that updating immutable metadata on a running profile is rejected. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithImmutableMetadataChanges_FailsAsync() + { + // Arrange + const string profileId = "profile-running-4"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + WorkspaceStrategy = WorkspaceStrategy.SymlinkOnly, + GameInstallationId = "install-1", + CustomExecutablePath = "C:\\game\\generals.exe", + WorkingDirectory = "C:\\game", + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + // 1. Workspace Strategy change + var req1 = new UpdateProfileRequest { WorkspaceStrategy = WorkspaceStrategy.HardLink }; + var res1 = await _profileManager.UpdateProfileAsync(profileId, req1); + Assert.False(res1.Success); + Assert.Contains("workspace strategy", res1.FirstError, StringComparison.OrdinalIgnoreCase); + + // 2. Installation change + var req2 = new UpdateProfileRequest { GameInstallationId = "install-2" }; + var res2 = await _profileManager.UpdateProfileAsync(profileId, req2); + Assert.False(res2.Success); + Assert.Contains("game installation", res2.FirstError, StringComparison.OrdinalIgnoreCase); + + // 2b. Empty installation change + var req2b = new UpdateProfileRequest { GameInstallationId = string.Empty }; + var res2b = await _profileManager.UpdateProfileAsync(profileId, req2b); + Assert.False(res2b.Success); + Assert.Contains("game installation", res2b.FirstError, StringComparison.OrdinalIgnoreCase); + + // 3. Custom executable path change + var req3 = new UpdateProfileRequest { CustomExecutablePath = "C:\\game\\new_generals.exe" }; + var res3 = await _profileManager.UpdateProfileAsync(profileId, req3); + Assert.False(res3.Success); + Assert.Contains("custom executable path", res3.FirstError, StringComparison.OrdinalIgnoreCase); + + // 4. Working directory change + var req4 = new UpdateProfileRequest { WorkingDirectory = "C:\\other_dir" }; + var res4 = await _profileManager.UpdateProfileAsync(profileId, req4); + Assert.False(res4.Success); + Assert.Contains("working directory", res4.FirstError, StringComparison.OrdinalIgnoreCase); + + // 5. Command line arguments change + var req5 = new UpdateProfileRequest { CommandLineArguments = "-win -quickstart" }; + var res5 = await _profileManager.UpdateProfileAsync(profileId, req5); + Assert.False(res5.Success); + Assert.Contains("command line arguments", res5.FirstError, StringComparison.OrdinalIgnoreCase); + + // 6. Active workspace ID change + var req6 = new UpdateProfileRequest { ActiveWorkspaceId = "workspace-new-999" }; + var res6 = await _profileManager.UpdateProfileAsync(profileId, req6); + Assert.False(res6.Success); + Assert.Contains("active workspace", res6.FirstError, StringComparison.OrdinalIgnoreCase); + + // 7. Game client change + var req7 = new UpdateProfileRequest { GameClient = new GameClient { Id = "different-client-id" } }; + var res7 = await _profileManager.UpdateProfileAsync(profileId, req7); + Assert.False(res7.Success); + Assert.Contains("game client", res7.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that updating a running profile with content whose manifest cannot be found fails gracefully. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithManifestNotFound_ReturnsFailureAsync() + { + // Arrange + const string profileId = "profile-running-missing-manifest"; + const string missingManifestId = "1.0.0.map.missing"; + + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + EnabledContentIds = [], + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(missingManifestId), It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Manifest not found")); + + var request = new UpdateProfileRequest + { + EnabledContentIds = [missingManifestId], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.False(result.Success); + Assert.Contains("manifest not found", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that updating a running profile with an invalid manifest ID format fails gracefully. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenProfileRunning_WithInvalidManifestId_ReturnsFailureAsync() + { + // Arrange + const string profileId = "profile-running-invalid-manifest"; + const string invalidManifestId = "invalid manifest id!"; + + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-123", + EnabledContentIds = [], + }; + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(profileId)]); + + var request = new UpdateProfileRequest + { + EnabledContentIds = [invalidManifestId], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.False(result.Success); + Assert.Contains("invalid manifest ID format", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that stale launch records with TerminatedAt set are ignored when checking running status. + /// + /// A task representing the test operation. + [Fact] + public async Task UpdateProfileAsync_WhenLaunchRecordIsTerminated_TreatsProfileAsNotRunningAsync() + { + // Arrange + const string profileId = "profile-terminated-1"; + var existingProfile = new GameProfile + { + Id = profileId, + Name = "Terminated Profile", + ActiveWorkspaceId = "workspace-stale", + EnabledContentIds = ["1.0.0.mod.first"], + }; + + var terminatedLaunch = CreateActiveLaunch(profileId); + terminatedLaunch.TerminatedAt = DateTime.UtcNow.AddMinutes(-5); + + _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile)); + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([terminatedLaunch]); + + var request = new UpdateProfileRequest + { + EnabledContentIds = ["1.0.0.mod.second"], + }; + + // Act + var result = await _profileManager.UpdateProfileAsync(profileId, request); + + // Assert + Assert.True(result.Success); + Assert.Empty(existingProfile.ActiveWorkspaceId); + } + + private static GameLaunchInfo CreateActiveLaunch(string profileId, string launchId = "launch-1", string workspaceId = "ws-1") => new() + { + LaunchId = launchId, + ProfileId = profileId, + WorkspaceId = workspaceId, + ProcessInfo = new GameProcessInfo + { + ProcessId = 1234, + ProcessName = "generals.exe", + StartTime = DateTime.UtcNow, + }, + }; +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ProfileLauncherFacadeCancellationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ProfileLauncherFacadeCancellationTests.cs index 891dd1314..7a59e4b24 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ProfileLauncherFacadeCancellationTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/ProfileLauncherFacadeCancellationTests.cs @@ -30,7 +30,7 @@ public class ProfileLauncherFacadeCancellationTests /// /// A task representing the asynchronous operation. [Fact] - public async Task LaunchProfileAsync_WhenCancelled_PropagatesCancellation() + public async Task LaunchProfileAsync_WhenCancelled_PropagatesCancellationAsync() { using var cts = new CancellationTokenSource(); await cts.CancelAsync(); @@ -51,7 +51,7 @@ await Assert.ThrowsAnyAsync( /// /// A task representing the asynchronous operation. [Fact] - public async Task LaunchProfileAsync_WhenDependencyTimesOut_ReturnsFailure() + public async Task LaunchProfileAsync_WhenDependencyTimesOut_ReturnsFailureAsync() { _profileManagerMock .Setup(manager => manager.GetProfileAsync(It.IsAny(), It.IsAny())) diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs index 3aef47b33..494345b3a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/StderrCaptureRaceTests.cs @@ -35,40 +35,35 @@ public class StderrCaptureRaceTests /// /// A task representing the asynchronous test operation. [Fact] - public async Task StartProcessAsync_WithImmediateFailure_CapturesBothEndsOfStderr() + public async Task StartProcessAsync_WithImmediateFailure_CapturesBothEndsOfStderrAsync() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return; } - // Arguments are key/value pairs; a leading '-' key is emitted as a flag followed - // by its value, which produces `/bin/sh -c